From d29fcbdb30159733501cb352ea1c2079eac87408 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Tue, 11 Aug 2026 22:25:49 +0000 Subject: [PATCH] feat(workspace): log workspace and agent state changes Add WorkspaceStateLogger, which logs workspace, agent, and lifecycle status transitions at info level with the old and new values. Wire it into WorkspaceMonitor so state changes observed while tracking a workspace are recorded (tagged with the session ID) for connection debugging. --- src/workspace/workspaceMonitor.ts | 5 + src/workspace/workspaceStateLogger.ts | 87 ++++++++++++ .../workspace/workspaceStateLogger.test.ts | 129 ++++++++++++++++++ 3 files changed, 221 insertions(+) create mode 100644 src/workspace/workspaceStateLogger.ts create mode 100644 test/unit/workspace/workspaceStateLogger.test.ts diff --git a/src/workspace/workspaceMonitor.ts b/src/workspace/workspaceMonitor.ts index ff6d23da46..17ce727e15 100644 --- a/src/workspace/workspaceMonitor.ts +++ b/src/workspace/workspaceMonitor.ts @@ -14,6 +14,8 @@ import { import { createStatusBarItem } from "../util/statusBar"; import { vscodeProposed } from "../vscodeProposed"; +import { WorkspaceStateLogger } from "./workspaceStateLogger"; + import type { CoderApi } from "../api/coderApi"; import type { ServiceContainer } from "../core/container"; import type { ContextManager } from "../core/contextManager"; @@ -46,6 +48,7 @@ export class WorkspaceMonitor implements vscode.Disposable { // For logging. private readonly name: string; private readonly telemetry: WorkspaceStateTelemetry; + private readonly stateLogger: WorkspaceStateLogger; private readonly logger: Logger; private readonly contextManager: ContextManager; @@ -63,6 +66,7 @@ export class WorkspaceMonitor implements vscode.Disposable { container.getTelemetryService(), this.name, ); + this.stateLogger = new WorkspaceStateLogger(this.logger, this.name); this.latestWorkspace = workspace; const statusBarItem = createStatusBarItem("workspaceUpdate"); @@ -136,6 +140,7 @@ export class WorkspaceMonitor implements vscode.Disposable { private update(workspace: Workspace) { this.telemetry.observe(workspace); + this.stateLogger.observe(workspace); this.latestWorkspace = workspace; this.updateContext(workspace); this.updateStatusBar(workspace); diff --git a/src/workspace/workspaceStateLogger.ts b/src/workspace/workspaceStateLogger.ts new file mode 100644 index 0000000000..224f55a63c --- /dev/null +++ b/src/workspace/workspaceStateLogger.ts @@ -0,0 +1,87 @@ +import { extractAgents } from "../api/api-helper"; + +import type { + Workspace, + WorkspaceAgentLifecycle, + WorkspaceAgentStatus, + WorkspaceStatus, +} from "coder/site/src/api/typesGenerated"; + +import type { Logger } from "../logging/logger"; + +/** Sentinel for the "from" side before any state is observed, and for the + * agent/lifecycle dimensions while no agent exists yet. `"unknown"` is a real + * server-reported value, so avoid it. */ +const INITIAL_STATE = "none"; + +interface ObservedState { + readonly workspaceStatus: WorkspaceStatus; + readonly agentStatus: WorkspaceAgentStatus | typeof INITIAL_STATE; + readonly lifecycleState: WorkspaceAgentLifecycle | typeof INITIAL_STATE; +} + +/** + * Logs workspace, agent, and lifecycle status transitions at `info` level so + * connection debugging has a record of state changes correlated by the session + * ID. Tracks state per agent (keyed by agent ID) because a workspace can have + * several. Construct one per workspace; `WorkspaceMonitor` is the sole call + * site. + */ +export class WorkspaceStateLogger { + private readonly observed = new Map(); + + public constructor( + private readonly logger: Logger, + private readonly workspaceName: string, + ) {} + + public observe(workspace: Workspace): void { + const workspaceStatus = workspace.latest_build.status; + const agents = extractAgents(workspace.latest_build.resources); + + if (agents.length === 0) { + this.observeState(INITIAL_STATE, { + workspaceStatus, + agentStatus: INITIAL_STATE, + lifecycleState: INITIAL_STATE, + }); + return; + } + + for (const agent of agents) { + this.observeState(agent.id, { + workspaceStatus, + agentStatus: agent.status, + lifecycleState: agent.lifecycle_state, + }); + } + } + + private observeState(key: string, next: ObservedState): void { + const previous = this.observed.get(key); + if ( + previous?.workspaceStatus === next.workspaceStatus && + previous?.agentStatus === next.agentStatus && + previous?.lifecycleState === next.lifecycleState + ) { + return; + } + + this.logger.info(`Workspace ${this.workspaceName} state changed`, { + workspaceStatus: { + from: previous?.workspaceStatus ?? INITIAL_STATE, + to: next.workspaceStatus, + }, + agentStatus: { + from: previous?.agentStatus ?? INITIAL_STATE, + to: next.agentStatus, + }, + lifecycleState: { + from: previous?.lifecycleState ?? INITIAL_STATE, + to: next.lifecycleState, + }, + }); + + this.observed.set(key, next); + } +} diff --git a/test/unit/workspace/workspaceStateLogger.test.ts b/test/unit/workspace/workspaceStateLogger.test.ts new file mode 100644 index 0000000000..544bea40d8 --- /dev/null +++ b/test/unit/workspace/workspaceStateLogger.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; + +import { WorkspaceStateLogger } from "@/workspace/workspaceStateLogger"; + +import { + agent as createAgent, + resource as createResource, + workspace as createWorkspace, +} from "@repo/mocks"; + +import { createMockLogger } from "../../mocks/testHelpers"; + +import type { + Workspace, + WorkspaceAgent, + WorkspaceStatus, +} from "coder/site/src/api/typesGenerated"; + +function workspaceWith( + status: WorkspaceStatus, + agents: WorkspaceAgent[] = [], +): Workspace { + return createWorkspace({ + latest_build: { + status, + resources: [createResource({ agents })], + }, + }); +} + +describe("WorkspaceStateLogger", () => { + it("logs the initial observed state with a `none` origin", () => { + const logger = createMockLogger(); + const stateLogger = new WorkspaceStateLogger(logger, "testuser/ws"); + + stateLogger.observe( + workspaceWith("running", [ + createAgent({ status: "connected", lifecycle_state: "ready" }), + ]), + ); + + expect(logger.info).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledWith( + "Workspace testuser/ws state changed", + { + workspaceStatus: { from: "none", to: "running" }, + agentStatus: { from: "none", to: "connected" }, + lifecycleState: { from: "none", to: "ready" }, + }, + ); + }); + + it("logs a transition when the agent status and lifecycle change", () => { + const logger = createMockLogger(); + const stateLogger = new WorkspaceStateLogger(logger, "testuser/ws"); + + stateLogger.observe( + workspaceWith("starting", [ + createAgent({ status: "connecting", lifecycle_state: "starting" }), + ]), + ); + stateLogger.observe( + workspaceWith("running", [ + createAgent({ status: "connected", lifecycle_state: "ready" }), + ]), + ); + + expect(logger.info).toHaveBeenCalledTimes(2); + expect(logger.info).toHaveBeenLastCalledWith( + "Workspace testuser/ws state changed", + { + workspaceStatus: { from: "starting", to: "running" }, + agentStatus: { from: "connecting", to: "connected" }, + lifecycleState: { from: "starting", to: "ready" }, + }, + ); + }); + + it("does not log when nothing changes", () => { + const logger = createMockLogger(); + const stateLogger = new WorkspaceStateLogger(logger, "testuser/ws"); + const snapshot = workspaceWith("running", [ + createAgent({ status: "connected", lifecycle_state: "ready" }), + ]); + + stateLogger.observe(snapshot); + stateLogger.observe(snapshot); + + expect(logger.info).toHaveBeenCalledTimes(1); + }); + + it("uses `none` for the agent dimensions while no agent exists yet", () => { + const logger = createMockLogger(); + const stateLogger = new WorkspaceStateLogger(logger, "testuser/ws"); + + stateLogger.observe(workspaceWith("pending")); + + expect(logger.info).toHaveBeenCalledWith( + "Workspace testuser/ws state changed", + { + workspaceStatus: { from: "none", to: "pending" }, + agentStatus: { from: "none", to: "none" }, + lifecycleState: { from: "none", to: "none" }, + }, + ); + }); + + it("tracks each agent independently", () => { + const logger = createMockLogger(); + const stateLogger = new WorkspaceStateLogger(logger, "testuser/ws"); + + stateLogger.observe( + workspaceWith("running", [ + createAgent({ id: "a1", name: "first", status: "connected" }), + createAgent({ id: "a2", name: "second", status: "connecting" }), + ]), + ); + expect(logger.info).toHaveBeenCalledTimes(2); + + // Only the second agent changes; expect a single new log. + stateLogger.observe( + workspaceWith("running", [ + createAgent({ id: "a1", name: "first", status: "connected" }), + createAgent({ id: "a2", name: "second", status: "connected" }), + ]), + ); + expect(logger.info).toHaveBeenCalledTimes(3); + }); +});