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); + }); +});