From be665e9ae40ded143c7b3a1c4d05bf0194c91729 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Tue, 11 Aug 2026 22:25:49 +0000 Subject: [PATCH 01/24] 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); + }); +}); From 66c84be02b75fb165a27287acb08c6f76a292e72 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Tue, 18 Aug 2026 00:42:00 +0000 Subject: [PATCH 02/24] refactor(workspace): split state logging into workspace and agent loggers Address review feedback on the combined WorkspaceStateLogger: it logged once per agent for a single workspace-status change and duplicated the transition-detection already in the telemetry observers. - Add a shared TransitionTracker and use it in WorkspaceStateTelemetry and WorkspaceAgentTelemetry (no behavior change). - Split the logger into WorkspaceStateLogger (one log per workspace status change) and WorkspaceAgentLogger (per-agent status/lifecycle), mirroring the two telemetry observers. Both are wired in WorkspaceMonitor. --- src/instrumentation/workspace.ts | 55 ++++----- src/util/transitionTracker.ts | 36 ++++++ src/workspace/workspaceAgentLogger.ts | 65 ++++++++++ src/workspace/workspaceMonitor.ts | 4 + src/workspace/workspaceStateLogger.ts | 75 +++--------- test/unit/util/transitionTracker.test.ts | 58 +++++++++ .../workspace/workspaceAgentLogger.test.ts | 111 ++++++++++++++++++ .../workspace/workspaceStateLogger.test.ts | 79 ++++--------- 8 files changed, 340 insertions(+), 143 deletions(-) create mode 100644 src/util/transitionTracker.ts create mode 100644 src/workspace/workspaceAgentLogger.ts create mode 100644 test/unit/util/transitionTracker.test.ts create mode 100644 test/unit/workspace/workspaceAgentLogger.test.ts diff --git a/src/instrumentation/workspace.ts b/src/instrumentation/workspace.ts index f98f32eda4..a9fed7a650 100644 --- a/src/instrumentation/workspace.ts +++ b/src/instrumentation/workspace.ts @@ -1,4 +1,5 @@ import { WorkspaceUpdateCancelledError } from "../api/updateParameters"; +import { TransitionTracker } from "../util/transitionTracker"; import type { Workspace, @@ -47,7 +48,12 @@ interface ObservedAgentState { * Construct one per workspace; `WorkspaceMonitor` is the sole call site. */ export class WorkspaceStateTelemetry { - private observed: ObservedWorkspaceState | undefined; + private readonly tracker = new TransitionTracker( + (a, b) => + a.status === b.status && + a.buildTransition === b.buildTransition && + a.buildReason === b.buildReason, + ); /** Set on first observation of a provisioning status; cleared when the build resolves. */ private buildStartedAtMs: number | undefined; @@ -62,16 +68,18 @@ export class WorkspaceStateTelemetry { transition: buildTransition, reason: buildReason, } = workspace.latest_build; - const previous = this.observed; - if ( - previous?.status === status && - previous.buildTransition === buildTransition && - previous.buildReason === buildReason - ) { + const now = performance.now(); + const change = this.tracker.observe({ + status, + buildTransition, + buildReason, + observedAtMs: now, + }); + if (!change) { return; } + const previous = change.from; - const now = performance.now(); const measurements: Record = previous ? { observed_duration_ms: now - previous.observedAtMs } : {}; @@ -99,12 +107,6 @@ export class WorkspaceStateTelemetry { }, measurements, ); - this.observed = { - status, - buildTransition, - buildReason, - observedAtMs: now, - }; } } @@ -115,7 +117,9 @@ export class WorkspaceStateTelemetry { * workspace. */ export class WorkspaceAgentTelemetry { - private observed: ObservedAgentState | undefined; + private readonly tracker = new TransitionTracker( + (a, b) => a.status === b.status && a.lifecycleState === b.lifecycleState, + ); public constructor( private readonly telemetry: TelemetryReporter, @@ -123,14 +127,16 @@ export class WorkspaceAgentTelemetry { ) {} public observe(agent: WorkspaceAgent): void { - const previous = this.observed; - if ( - previous?.status === agent.status && - previous.lifecycleState === agent.lifecycle_state - ) { + const now = performance.now(); + const change = this.tracker.observe({ + status: agent.status, + lifecycleState: agent.lifecycle_state, + observedAtMs: now, + }); + if (!change) { return; } - const now = performance.now(); + const previous = change.from; this.telemetry.log( "workspace.agent.state_transitioned", @@ -144,15 +150,10 @@ export class WorkspaceAgentTelemetry { }, previous ? { observed_duration_ms: now - previous.observedAtMs } : {}, ); - this.observed = { - status: agent.status, - lifecycleState: agent.lifecycle_state, - observedAtMs: now, - }; } public reset(): void { - this.observed = undefined; + this.tracker.reset(); } } diff --git a/src/util/transitionTracker.ts b/src/util/transitionTracker.ts new file mode 100644 index 0000000000..6460ad71df --- /dev/null +++ b/src/util/transitionTracker.ts @@ -0,0 +1,36 @@ +/** + * Tracks the last observed value per key and reports the previous value each + * time it changes. Shared by the workspace/agent telemetry observers and the + * corresponding loggers so transition detection lives in one place. + * + * A single tracked entity (e.g. a workspace) can omit the key; callers that + * track many entities (e.g. agents keyed by ID) pass a distinct key each time. + */ +export class TransitionTracker { + private readonly previous = new Map(); + + public constructor(private readonly equals: (a: T, b: T) => boolean) {} + + /** + * Record `next` for `key`. Returns `{ from }` when it differs from the last + * recorded value (`from` is `undefined` on the first observation), or + * `undefined` when unchanged. + */ + public observe(next: T, key = ""): { from: T | undefined } | undefined { + const prior = this.previous.get(key); + if (prior !== undefined && this.equals(prior, next)) { + return undefined; + } + this.previous.set(key, next); + return { from: prior }; + } + + /** Forget a single key, or all keys when `key` is omitted. */ + public reset(key?: string): void { + if (key === undefined) { + this.previous.clear(); + } else { + this.previous.delete(key); + } + } +} diff --git a/src/workspace/workspaceAgentLogger.ts b/src/workspace/workspaceAgentLogger.ts new file mode 100644 index 0000000000..2605045898 --- /dev/null +++ b/src/workspace/workspaceAgentLogger.ts @@ -0,0 +1,65 @@ +import { extractAgents } from "../api/api-helper"; +import { TransitionTracker } from "../util/transitionTracker"; + +import type { + Workspace, + WorkspaceAgentLifecycle, + WorkspaceAgentStatus, +} from "coder/site/src/api/typesGenerated"; + +import type { Logger } from "../logging/logger"; + +/** Sentinel for the "from" side before any state is observed. `"unknown"` is a + * real server-reported value, so avoid it. */ +const INITIAL_STATE = "none"; + +interface AgentState { + readonly status: WorkspaceAgentStatus; + readonly lifecycleState: WorkspaceAgentLifecycle; +} + +/** + * Logs agent status and lifecycle transitions at `info` level so connection + * debugging has a record of state changes correlated by the session ID. Mirrors + * `WorkspaceAgentTelemetry` but tracks every agent (keyed by agent ID) for the + * connection's lifetime, since a workspace can have several and they change + * independently of the workspace status. Construct one per workspace; + * `WorkspaceMonitor` is the sole call site. + */ +export class WorkspaceAgentLogger { + private readonly tracker = new TransitionTracker( + (a, b) => a.status === b.status && a.lifecycleState === b.lifecycleState, + ); + + public constructor( + private readonly logger: Logger, + private readonly workspaceName: string, + ) {} + + public observe(workspace: Workspace): void { + for (const agent of extractAgents(workspace.latest_build.resources)) { + const next: AgentState = { + status: agent.status, + lifecycleState: agent.lifecycle_state, + }; + const change = this.tracker.observe(next, agent.id); + if (!change) { + continue; + } + + this.logger.info( + `Workspace ${this.workspaceName} agent ${agent.name} state changed`, + { + status: { + from: change.from?.status ?? INITIAL_STATE, + to: next.status, + }, + lifecycleState: { + from: change.from?.lifecycleState ?? INITIAL_STATE, + to: next.lifecycleState, + }, + }, + ); + } + } +} diff --git a/src/workspace/workspaceMonitor.ts b/src/workspace/workspaceMonitor.ts index 17ce727e15..374411097e 100644 --- a/src/workspace/workspaceMonitor.ts +++ b/src/workspace/workspaceMonitor.ts @@ -14,6 +14,7 @@ import { import { createStatusBarItem } from "../util/statusBar"; import { vscodeProposed } from "../vscodeProposed"; +import { WorkspaceAgentLogger } from "./workspaceAgentLogger"; import { WorkspaceStateLogger } from "./workspaceStateLogger"; import type { CoderApi } from "../api/coderApi"; @@ -49,6 +50,7 @@ export class WorkspaceMonitor implements vscode.Disposable { private readonly name: string; private readonly telemetry: WorkspaceStateTelemetry; private readonly stateLogger: WorkspaceStateLogger; + private readonly agentLogger: WorkspaceAgentLogger; private readonly logger: Logger; private readonly contextManager: ContextManager; @@ -67,6 +69,7 @@ export class WorkspaceMonitor implements vscode.Disposable { this.name, ); this.stateLogger = new WorkspaceStateLogger(this.logger, this.name); + this.agentLogger = new WorkspaceAgentLogger(this.logger, this.name); this.latestWorkspace = workspace; const statusBarItem = createStatusBarItem("workspaceUpdate"); @@ -141,6 +144,7 @@ export class WorkspaceMonitor implements vscode.Disposable { private update(workspace: Workspace) { this.telemetry.observe(workspace); this.stateLogger.observe(workspace); + this.agentLogger.observe(workspace); this.latestWorkspace = workspace; this.updateContext(workspace); this.updateStatusBar(workspace); diff --git a/src/workspace/workspaceStateLogger.ts b/src/workspace/workspaceStateLogger.ts index 224f55a63c..bf4f0a63f2 100644 --- a/src/workspace/workspaceStateLogger.ts +++ b/src/workspace/workspaceStateLogger.ts @@ -1,34 +1,27 @@ -import { extractAgents } from "../api/api-helper"; +import { TransitionTracker } from "../util/transitionTracker"; 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. */ +/** Sentinel for the "from" side before any state is observed. `"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. + * Logs workspace status transitions at `info` level so connection debugging has + * a record of state changes correlated by the session ID. Mirrors + * `WorkspaceStateTelemetry`: one log per workspace state change, independent of + * how many agents the workspace has. Construct one per workspace; + * `WorkspaceMonitor` is the sole call site. */ export class WorkspaceStateLogger { - private readonly observed = new Map(); + private readonly tracker = new TransitionTracker( + (a, b) => a === b, + ); public constructor( private readonly logger: Logger, @@ -36,52 +29,14 @@ export class WorkspaceStateLogger { ) {} 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 - ) { + const status = workspace.latest_build.status; + const change = this.tracker.observe(status); + if (!change) { 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, - }, + status: { from: change.from ?? INITIAL_STATE, to: status }, }); - - this.observed.set(key, next); } } diff --git a/test/unit/util/transitionTracker.test.ts b/test/unit/util/transitionTracker.test.ts new file mode 100644 index 0000000000..99163f7088 --- /dev/null +++ b/test/unit/util/transitionTracker.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { TransitionTracker } from "@/util/transitionTracker"; + +describe("TransitionTracker", () => { + const equals = (a: string, b: string) => a === b; + + it("reports `from: undefined` on the first observation", () => { + const tracker = new TransitionTracker(equals); + + expect(tracker.observe("a")).toEqual({ from: undefined }); + }); + + it("returns undefined when the value is unchanged", () => { + const tracker = new TransitionTracker(equals); + + tracker.observe("a"); + + expect(tracker.observe("a")).toBeUndefined(); + }); + + it("returns the prior value when the value changes", () => { + const tracker = new TransitionTracker(equals); + + tracker.observe("a"); + + expect(tracker.observe("b")).toEqual({ from: "a" }); + }); + + it("tracks keys independently", () => { + const tracker = new TransitionTracker(equals); + + expect(tracker.observe("a", "k1")).toEqual({ from: undefined }); + expect(tracker.observe("b", "k2")).toEqual({ from: undefined }); + expect(tracker.observe("a", "k1")).toBeUndefined(); + expect(tracker.observe("c", "k2")).toEqual({ from: "b" }); + }); + + it("forgets a single key on reset", () => { + const tracker = new TransitionTracker(equals); + + tracker.observe("a", "k1"); + tracker.reset("k1"); + + expect(tracker.observe("a", "k1")).toEqual({ from: undefined }); + }); + + it("forgets all keys on reset()", () => { + const tracker = new TransitionTracker(equals); + + tracker.observe("a", "k1"); + tracker.observe("b", "k2"); + tracker.reset(); + + expect(tracker.observe("a", "k1")).toEqual({ from: undefined }); + expect(tracker.observe("b", "k2")).toEqual({ from: undefined }); + }); +}); diff --git a/test/unit/workspace/workspaceAgentLogger.test.ts b/test/unit/workspace/workspaceAgentLogger.test.ts new file mode 100644 index 0000000000..9a042416e0 --- /dev/null +++ b/test/unit/workspace/workspaceAgentLogger.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; + +import { WorkspaceAgentLogger } from "@/workspace/workspaceAgentLogger"; + +import { + agent as createAgent, + resource as createResource, + workspace as createWorkspace, +} from "@repo/mocks"; + +import { createMockLogger } from "../../mocks/testHelpers"; + +import type { + Workspace, + WorkspaceAgent, +} from "coder/site/src/api/typesGenerated"; + +function workspaceWith(agents: WorkspaceAgent[]): Workspace { + return createWorkspace({ + latest_build: { + status: "running", + resources: [createResource({ agents })], + }, + }); +} + +describe("WorkspaceAgentLogger", () => { + it("logs the initial agent state with a `none` origin", () => { + const logger = createMockLogger(); + const agentLogger = new WorkspaceAgentLogger(logger, "testuser/ws"); + + agentLogger.observe( + workspaceWith([ + createAgent({ + name: "main", + status: "connected", + lifecycle_state: "ready", + }), + ]), + ); + + expect(logger.info).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledWith( + "Workspace testuser/ws agent main state changed", + { + status: { from: "none", to: "connected" }, + lifecycleState: { from: "none", to: "ready" }, + }, + ); + }); + + it("logs when the agent status or lifecycle changes", () => { + const logger = createMockLogger(); + const agentLogger = new WorkspaceAgentLogger(logger, "testuser/ws"); + + agentLogger.observe( + workspaceWith([ + createAgent({ status: "connecting", lifecycle_state: "starting" }), + ]), + ); + agentLogger.observe( + workspaceWith([ + createAgent({ status: "connected", lifecycle_state: "ready" }), + ]), + ); + + expect(logger.info).toHaveBeenCalledTimes(2); + expect(logger.info).toHaveBeenLastCalledWith( + "Workspace testuser/ws agent main state changed", + { + status: { from: "connecting", to: "connected" }, + lifecycleState: { from: "starting", to: "ready" }, + }, + ); + }); + + it("does not log when the agent state is unchanged", () => { + const logger = createMockLogger(); + const agentLogger = new WorkspaceAgentLogger(logger, "testuser/ws"); + const snapshot = workspaceWith([ + createAgent({ status: "connected", lifecycle_state: "ready" }), + ]); + + agentLogger.observe(snapshot); + agentLogger.observe(snapshot); + + expect(logger.info).toHaveBeenCalledTimes(1); + }); + + it("tracks each agent independently", () => { + const logger = createMockLogger(); + const agentLogger = new WorkspaceAgentLogger(logger, "testuser/ws"); + + agentLogger.observe( + workspaceWith([ + 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. + agentLogger.observe( + workspaceWith([ + createAgent({ id: "a1", name: "first", status: "connected" }), + createAgent({ id: "a2", name: "second", status: "connected" }), + ]), + ); + expect(logger.info).toHaveBeenCalledTimes(3); + }); +}); diff --git a/test/unit/workspace/workspaceStateLogger.test.ts b/test/unit/workspace/workspaceStateLogger.test.ts index 544bea40d8..3c931bc576 100644 --- a/test/unit/workspace/workspaceStateLogger.test.ts +++ b/test/unit/workspace/workspaceStateLogger.test.ts @@ -29,101 +29,68 @@ function workspaceWith( } describe("WorkspaceStateLogger", () => { - it("logs the initial observed state with a `none` origin", () => { + it("logs the initial observed status with a `none` origin", () => { const logger = createMockLogger(); const stateLogger = new WorkspaceStateLogger(logger, "testuser/ws"); - stateLogger.observe( - workspaceWith("running", [ - createAgent({ status: "connected", lifecycle_state: "ready" }), - ]), - ); + stateLogger.observe(workspaceWith("running")); 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" }, - }, + { status: { from: "none", to: "running" } }, ); }); - it("logs a transition when the agent status and lifecycle change", () => { + it("logs once when the workspace status changes", () => { 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" }), - ]), - ); + stateLogger.observe(workspaceWith("starting")); + stateLogger.observe(workspaceWith("running")); 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" }, - }, + { status: { from: "starting", to: "running" } }, ); }); - it("does not log when nothing changes", () => { + it("does not log when the workspace status is unchanged", () => { 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); + stateLogger.observe(workspaceWith("running")); + stateLogger.observe(workspaceWith("running")); expect(logger.info).toHaveBeenCalledTimes(1); }); - it("uses `none` for the agent dimensions while no agent exists yet", () => { + it("logs a workspace change exactly once regardless of agent count", () => { 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" }, - }, + stateLogger.observe( + workspaceWith("running", [ + createAgent({ id: "a1", name: "first" }), + createAgent({ id: "a2", name: "second" }), + ]), ); + + expect(logger.info).toHaveBeenCalledTimes(1); }); - it("tracks each agent independently", () => { + it("ignores agent-only changes", () => { 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" }), - ]), + workspaceWith("running", [createAgent({ 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" }), - ]), + workspaceWith("running", [createAgent({ status: "connected" })]), ); - expect(logger.info).toHaveBeenCalledTimes(3); + + expect(logger.info).toHaveBeenCalledTimes(1); }); }); From 7dc5332c4029e01defa7bcaab836f0bfde892a41 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Tue, 18 Aug 2026 22:53:18 +0000 Subject: [PATCH 03/24] feat(workspace): log build transition and reason on state changes Mirror WorkspaceStateTelemetry: key the workspace state logger on status, transition, and reason, and include transition and reason in the log payload so a state-change line matches the telemetry event. --- src/workspace/workspaceStateLogger.ts | 30 +++++++++----- .../workspace/workspaceStateLogger.test.ts | 39 +++++++++++++++++-- 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/src/workspace/workspaceStateLogger.ts b/src/workspace/workspaceStateLogger.ts index bf4f0a63f2..6f545acff3 100644 --- a/src/workspace/workspaceStateLogger.ts +++ b/src/workspace/workspaceStateLogger.ts @@ -2,6 +2,7 @@ import { TransitionTracker } from "../util/transitionTracker"; import type { Workspace, + WorkspaceBuild, WorkspaceStatus, } from "coder/site/src/api/typesGenerated"; @@ -11,16 +12,25 @@ import type { Logger } from "../logging/logger"; * real server-reported value, so avoid it. */ const INITIAL_STATE = "none"; +interface ObservedWorkspaceState { + readonly status: WorkspaceStatus; + readonly transition: WorkspaceBuild["transition"]; + readonly reason: WorkspaceBuild["reason"]; +} + /** - * Logs workspace status transitions at `info` level so connection debugging has + * Logs workspace build transitions at `info` level so connection debugging has * a record of state changes correlated by the session ID. Mirrors - * `WorkspaceStateTelemetry`: one log per workspace state change, independent of - * how many agents the workspace has. Construct one per workspace; - * `WorkspaceMonitor` is the sole call site. + * `WorkspaceStateTelemetry`: keyed on status, transition, and reason, so it logs + * once per workspace state change regardless of how many agents the workspace + * has. Construct one per workspace; `WorkspaceMonitor` is the sole call site. */ export class WorkspaceStateLogger { - private readonly tracker = new TransitionTracker( - (a, b) => a === b, + private readonly tracker = new TransitionTracker( + (a, b) => + a.status === b.status && + a.transition === b.transition && + a.reason === b.reason, ); public constructor( @@ -29,14 +39,16 @@ export class WorkspaceStateLogger { ) {} public observe(workspace: Workspace): void { - const status = workspace.latest_build.status; - const change = this.tracker.observe(status); + const { status, transition, reason } = workspace.latest_build; + const change = this.tracker.observe({ status, transition, reason }); if (!change) { return; } this.logger.info(`Workspace ${this.workspaceName} state changed`, { - status: { from: change.from ?? INITIAL_STATE, to: status }, + status: { from: change.from?.status ?? INITIAL_STATE, to: status }, + transition, + reason, }); } } diff --git a/test/unit/workspace/workspaceStateLogger.test.ts b/test/unit/workspace/workspaceStateLogger.test.ts index 3c931bc576..4c005a79d1 100644 --- a/test/unit/workspace/workspaceStateLogger.test.ts +++ b/test/unit/workspace/workspaceStateLogger.test.ts @@ -13,21 +13,28 @@ import { createMockLogger } from "../../mocks/testHelpers"; import type { Workspace, WorkspaceAgent, + WorkspaceBuild, WorkspaceStatus, } from "coder/site/src/api/typesGenerated"; function workspaceWith( status: WorkspaceStatus, agents: WorkspaceAgent[] = [], + build: Partial = {}, ): Workspace { return createWorkspace({ latest_build: { status, resources: [createResource({ agents })], + ...build, }, }); } +// Defaults supplied by the workspace mock factory. +const DEFAULT_TRANSITION = "start"; +const DEFAULT_REASON = "initiator"; + describe("WorkspaceStateLogger", () => { it("logs the initial observed status with a `none` origin", () => { const logger = createMockLogger(); @@ -38,7 +45,11 @@ describe("WorkspaceStateLogger", () => { expect(logger.info).toHaveBeenCalledTimes(1); expect(logger.info).toHaveBeenCalledWith( "Workspace testuser/ws state changed", - { status: { from: "none", to: "running" } }, + { + status: { from: "none", to: "running" }, + transition: DEFAULT_TRANSITION, + reason: DEFAULT_REASON, + }, ); }); @@ -52,11 +63,33 @@ describe("WorkspaceStateLogger", () => { expect(logger.info).toHaveBeenCalledTimes(2); expect(logger.info).toHaveBeenLastCalledWith( "Workspace testuser/ws state changed", - { status: { from: "starting", to: "running" } }, + { + status: { from: "starting", to: "running" }, + transition: DEFAULT_TRANSITION, + reason: DEFAULT_REASON, + }, + ); + }); + + it("logs when the transition or reason changes even if status is unchanged", () => { + const logger = createMockLogger(); + const stateLogger = new WorkspaceStateLogger(logger, "testuser/ws"); + + stateLogger.observe(workspaceWith("running", [], { transition: "start" })); + stateLogger.observe(workspaceWith("running", [], { transition: "stop" })); + + expect(logger.info).toHaveBeenCalledTimes(2); + expect(logger.info).toHaveBeenLastCalledWith( + "Workspace testuser/ws state changed", + { + status: { from: "running", to: "running" }, + transition: "stop", + reason: DEFAULT_REASON, + }, ); }); - it("does not log when the workspace status is unchanged", () => { + it("does not log when status, transition, and reason are unchanged", () => { const logger = createMockLogger(); const stateLogger = new WorkspaceStateLogger(logger, "testuser/ws"); From 73594238a0fdf8de35e7b96e266086eb396848d6 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Tue, 18 Aug 2026 22:55:41 +0000 Subject: [PATCH 04/24] refactor(instrumentation): fold TransitionTracker into the observers file --- src/instrumentation/workspace.ts | 38 +++++++++++++- src/util/transitionTracker.ts | 36 ------------- src/workspace/workspaceAgentLogger.ts | 2 +- src/workspace/workspaceStateLogger.ts | 2 +- test/unit/instrumentation/workspace.test.ts | 56 ++++++++++++++++++++ test/unit/util/transitionTracker.test.ts | 58 --------------------- 6 files changed, 95 insertions(+), 97 deletions(-) delete mode 100644 src/util/transitionTracker.ts delete mode 100644 test/unit/util/transitionTracker.test.ts diff --git a/src/instrumentation/workspace.ts b/src/instrumentation/workspace.ts index a9fed7a650..2be8ee7316 100644 --- a/src/instrumentation/workspace.ts +++ b/src/instrumentation/workspace.ts @@ -1,5 +1,4 @@ import { WorkspaceUpdateCancelledError } from "../api/updateParameters"; -import { TransitionTracker } from "../util/transitionTracker"; import type { Workspace, @@ -29,6 +28,43 @@ const PROVISIONING_STATUSES: ReadonlySet = new Set([ export type WorkspacePromptAction = "start" | "update"; export type WorkspaceUpdatePrompt = "parameters" | "confirmation"; +/** + * Tracks the last observed value per key and reports the previous value each + * time it changes. Shared by the workspace/agent observers and the corresponding + * loggers so transition detection lives in one place. + * + * A single tracked entity (e.g. a workspace) can omit the key; callers that + * track many entities (e.g. agents keyed by ID) pass a distinct key each time. + */ +export class TransitionTracker { + private readonly previous = new Map(); + + public constructor(private readonly equals: (a: T, b: T) => boolean) {} + + /** + * Record `next` for `key`. Returns `{ from }` when it differs from the last + * recorded value (`from` is `undefined` on the first observation), or + * `undefined` when unchanged. + */ + public observe(next: T, key = ""): { from: T | undefined } | undefined { + const prior = this.previous.get(key); + if (prior !== undefined && this.equals(prior, next)) { + return undefined; + } + this.previous.set(key, next); + return { from: prior }; + } + + /** Forget a single key, or all keys when `key` is omitted. */ + public reset(key?: string): void { + if (key === undefined) { + this.previous.clear(); + } else { + this.previous.delete(key); + } + } +} + interface ObservedWorkspaceState { readonly status: WorkspaceStatus; readonly buildTransition: WorkspaceBuild["transition"]; diff --git a/src/util/transitionTracker.ts b/src/util/transitionTracker.ts deleted file mode 100644 index 6460ad71df..0000000000 --- a/src/util/transitionTracker.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Tracks the last observed value per key and reports the previous value each - * time it changes. Shared by the workspace/agent telemetry observers and the - * corresponding loggers so transition detection lives in one place. - * - * A single tracked entity (e.g. a workspace) can omit the key; callers that - * track many entities (e.g. agents keyed by ID) pass a distinct key each time. - */ -export class TransitionTracker { - private readonly previous = new Map(); - - public constructor(private readonly equals: (a: T, b: T) => boolean) {} - - /** - * Record `next` for `key`. Returns `{ from }` when it differs from the last - * recorded value (`from` is `undefined` on the first observation), or - * `undefined` when unchanged. - */ - public observe(next: T, key = ""): { from: T | undefined } | undefined { - const prior = this.previous.get(key); - if (prior !== undefined && this.equals(prior, next)) { - return undefined; - } - this.previous.set(key, next); - return { from: prior }; - } - - /** Forget a single key, or all keys when `key` is omitted. */ - public reset(key?: string): void { - if (key === undefined) { - this.previous.clear(); - } else { - this.previous.delete(key); - } - } -} diff --git a/src/workspace/workspaceAgentLogger.ts b/src/workspace/workspaceAgentLogger.ts index 2605045898..b391c1ae5c 100644 --- a/src/workspace/workspaceAgentLogger.ts +++ b/src/workspace/workspaceAgentLogger.ts @@ -1,5 +1,5 @@ import { extractAgents } from "../api/api-helper"; -import { TransitionTracker } from "../util/transitionTracker"; +import { TransitionTracker } from "../instrumentation/workspace"; import type { Workspace, diff --git a/src/workspace/workspaceStateLogger.ts b/src/workspace/workspaceStateLogger.ts index 6f545acff3..3adeb523d3 100644 --- a/src/workspace/workspaceStateLogger.ts +++ b/src/workspace/workspaceStateLogger.ts @@ -1,4 +1,4 @@ -import { TransitionTracker } from "../util/transitionTracker"; +import { TransitionTracker } from "../instrumentation/workspace"; import type { Workspace, diff --git a/test/unit/instrumentation/workspace.test.ts b/test/unit/instrumentation/workspace.test.ts index 4b00b61016..ded1172604 100644 --- a/test/unit/instrumentation/workspace.test.ts +++ b/test/unit/instrumentation/workspace.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { WorkspaceUpdateCancelledError } from "@/api/updateParameters"; import { + TransitionTracker, WorkspaceAgentTelemetry, WorkspaceOperationTelemetry, WorkspaceStateTelemetry, @@ -300,3 +301,58 @@ describe("WorkspaceAgentTelemetry.observe", () => { ); }); }); + +describe("TransitionTracker", () => { + const equals = (a: string, b: string) => a === b; + + it("reports `from: undefined` on the first observation", () => { + const tracker = new TransitionTracker(equals); + + expect(tracker.observe("a")).toEqual({ from: undefined }); + }); + + it("returns undefined when the value is unchanged", () => { + const tracker = new TransitionTracker(equals); + + tracker.observe("a"); + + expect(tracker.observe("a")).toBeUndefined(); + }); + + it("returns the prior value when the value changes", () => { + const tracker = new TransitionTracker(equals); + + tracker.observe("a"); + + expect(tracker.observe("b")).toEqual({ from: "a" }); + }); + + it("tracks keys independently", () => { + const tracker = new TransitionTracker(equals); + + expect(tracker.observe("a", "k1")).toEqual({ from: undefined }); + expect(tracker.observe("b", "k2")).toEqual({ from: undefined }); + expect(tracker.observe("a", "k1")).toBeUndefined(); + expect(tracker.observe("c", "k2")).toEqual({ from: "b" }); + }); + + it("forgets a single key on reset", () => { + const tracker = new TransitionTracker(equals); + + tracker.observe("a", "k1"); + tracker.reset("k1"); + + expect(tracker.observe("a", "k1")).toEqual({ from: undefined }); + }); + + it("forgets all keys on reset()", () => { + const tracker = new TransitionTracker(equals); + + tracker.observe("a", "k1"); + tracker.observe("b", "k2"); + tracker.reset(); + + expect(tracker.observe("a", "k1")).toEqual({ from: undefined }); + expect(tracker.observe("b", "k2")).toEqual({ from: undefined }); + }); +}); diff --git a/test/unit/util/transitionTracker.test.ts b/test/unit/util/transitionTracker.test.ts deleted file mode 100644 index 99163f7088..0000000000 --- a/test/unit/util/transitionTracker.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { TransitionTracker } from "@/util/transitionTracker"; - -describe("TransitionTracker", () => { - const equals = (a: string, b: string) => a === b; - - it("reports `from: undefined` on the first observation", () => { - const tracker = new TransitionTracker(equals); - - expect(tracker.observe("a")).toEqual({ from: undefined }); - }); - - it("returns undefined when the value is unchanged", () => { - const tracker = new TransitionTracker(equals); - - tracker.observe("a"); - - expect(tracker.observe("a")).toBeUndefined(); - }); - - it("returns the prior value when the value changes", () => { - const tracker = new TransitionTracker(equals); - - tracker.observe("a"); - - expect(tracker.observe("b")).toEqual({ from: "a" }); - }); - - it("tracks keys independently", () => { - const tracker = new TransitionTracker(equals); - - expect(tracker.observe("a", "k1")).toEqual({ from: undefined }); - expect(tracker.observe("b", "k2")).toEqual({ from: undefined }); - expect(tracker.observe("a", "k1")).toBeUndefined(); - expect(tracker.observe("c", "k2")).toEqual({ from: "b" }); - }); - - it("forgets a single key on reset", () => { - const tracker = new TransitionTracker(equals); - - tracker.observe("a", "k1"); - tracker.reset("k1"); - - expect(tracker.observe("a", "k1")).toEqual({ from: undefined }); - }); - - it("forgets all keys on reset()", () => { - const tracker = new TransitionTracker(equals); - - tracker.observe("a", "k1"); - tracker.observe("b", "k2"); - tracker.reset(); - - expect(tracker.observe("a", "k1")).toEqual({ from: undefined }); - expect(tracker.observe("b", "k2")).toEqual({ from: undefined }); - }); -}); From e518ee4163872edb983f622c257f73eb209bc88d Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 19 Aug 2026 00:40:37 +0000 Subject: [PATCH 05/24] refactor(workspace): detect state in observers, log inline, record telemetry Reshape workspace/agent state handling into three layers, one job each: - detect: WorkspaceStateObserver / WorkspaceAgentObserver report a transition object; stateful but effect-free (no logger/telemetry imports) - record: recordWorkspaceState / recordAgentState emit telemetry - log: inline in WorkspaceMonitor.update, matching the repo's other log sites Deletes the parallel WorkspaceStateLogger / WorkspaceAgentLogger classes and the WorkspaceStateTelemetry / WorkspaceAgentTelemetry classes. Agent state is now observed once in WorkspaceMonitor for every agent over the whole session, so WorkspaceStateMachine no longer tracks agent telemetry. Logs use flat scalar payloads and say "state observed" on first sight, "state changed" after, and log when an agent disappears. --- src/instrumentation/workspace.ts | 224 ++++++++---- src/remote/workspaceStateMachine.ts | 9 +- src/workspace/workspaceAgentLogger.ts | 65 ---- src/workspace/workspaceMonitor.ts | 71 +++- src/workspace/workspaceStateLogger.ts | 54 --- test/unit/instrumentation/workspace.test.ts | 335 +++++++++++++----- .../unit/remote/workspaceStateMachine.test.ts | 70 ---- .../workspace/workspaceAgentLogger.test.ts | 111 ------ test/unit/workspace/workspaceMonitor.test.ts | 150 +++++++- .../workspace/workspaceStateLogger.test.ts | 129 ------- 10 files changed, 623 insertions(+), 595 deletions(-) delete mode 100644 src/workspace/workspaceAgentLogger.ts delete mode 100644 src/workspace/workspaceStateLogger.ts delete mode 100644 test/unit/workspace/workspaceAgentLogger.test.ts delete mode 100644 test/unit/workspace/workspaceStateLogger.test.ts diff --git a/src/instrumentation/workspace.ts b/src/instrumentation/workspace.ts index 2be8ee7316..97d78b38fb 100644 --- a/src/instrumentation/workspace.ts +++ b/src/instrumentation/workspace.ts @@ -1,8 +1,8 @@ +import { extractAgents } from "../api/api-helper"; import { WorkspaceUpdateCancelledError } from "../api/updateParameters"; import type { Workspace, - WorkspaceAgent, WorkspaceAgentLifecycle, WorkspaceAgentStatus, WorkspaceBuild, @@ -14,7 +14,7 @@ import type { TelemetryReporter } from "../telemetry/reporter"; import type { Span } from "../telemetry/span"; /** Sentinel for `from*` before any state is observed. `"unknown"` is a real server-reported value, so avoid it. */ -const INITIAL_STATE = "none"; +export const INITIAL_STATE = "none"; /** Statuses where a provisioner job is actively running. */ const PROVISIONING_STATUSES: ReadonlySet = new Set([ @@ -78,12 +78,52 @@ interface ObservedAgentState { readonly observedAtMs: number; } +/** A detected workspace status change, reported by `WorkspaceStateObserver`. */ +export interface WorkspaceStateTransition { + /** Previous status, or `undefined` on the first observation. */ + readonly from: WorkspaceStatus | undefined; + readonly to: WorkspaceStatus; + readonly buildTransition: WorkspaceBuild["transition"]; + readonly buildReason: WorkspaceBuild["reason"]; + /** Time spent in the previous state; `undefined` on the first observation. */ + readonly durationMs: number | undefined; + /** Set only on the observation where a provisioner run resolves. */ + readonly buildDurationMs: number | undefined; +} + +/** A detected agent status/lifecycle change, reported by `WorkspaceAgentObserver`. */ +export interface AgentStateTransition { + readonly agentName: string; + readonly status: { + readonly from: WorkspaceAgentStatus | undefined; + readonly to: WorkspaceAgentStatus; + }; + readonly lifecycleState: { + readonly from: WorkspaceAgentLifecycle | undefined; + readonly to: WorkspaceAgentLifecycle; + }; + /** Time since the previous observation of this agent; `undefined` on the first. */ + readonly durationMs: number | undefined; +} + +/** An agent present on a prior observation and absent now (e.g. after a rebuild). */ +export interface RemovedAgent { + readonly name: string; +} + +/** The result of observing all agents in a workspace snapshot. */ +export interface AgentObservation { + readonly transitions: AgentStateTransition[]; + readonly removed: RemovedAgent[]; +} + /** - * Emits `workspace.state_transitioned` as a workspace progresses through - * statuses, plus `observed_build_duration_ms` when a provisioner run resolves. - * Construct one per workspace; `WorkspaceMonitor` is the sole call site. + * Detects workspace status changes as a workspace progresses through statuses, + * reporting a transition object plus timing (including build duration when a + * provisioner run resolves). Stateful but effect-free: it holds no logger or + * telemetry references. Construct one per workspace. */ -export class WorkspaceStateTelemetry { +export class WorkspaceStateObserver { private readonly tracker = new TransitionTracker( (a, b) => a.status === b.status && @@ -93,12 +133,7 @@ export class WorkspaceStateTelemetry { /** Set on first observation of a provisioning status; cleared when the build resolves. */ private buildStartedAtMs: number | undefined; - public constructor( - private readonly telemetry: TelemetryReporter, - private readonly workspaceName: string, - ) {} - - public observe(workspace: Workspace): void { + public observe(workspace: Workspace): WorkspaceStateTransition | undefined { const { status, transition: buildTransition, @@ -112,87 +147,156 @@ export class WorkspaceStateTelemetry { observedAtMs: now, }); if (!change) { - return; + return undefined; } const previous = change.from; - const measurements: Record = previous - ? { observed_duration_ms: now - previous.observedAtMs } - : {}; - const wasProvisioning = previous && PROVISIONING_STATUSES.has(previous.status); const isProvisioning = PROVISIONING_STATUSES.has(status); + let buildDurationMs: number | undefined; if (isProvisioning) { this.buildStartedAtMs ??= now; } else { if (wasProvisioning && this.buildStartedAtMs !== undefined) { - measurements.observed_build_duration_ms = now - this.buildStartedAtMs; + buildDurationMs = now - this.buildStartedAtMs; } this.buildStartedAtMs = undefined; } - this.telemetry.log( - "workspace.state_transitioned", - { - workspace_name: this.workspaceName, - from: previous?.status ?? INITIAL_STATE, - to: status, - "build.transition": buildTransition, - "build.reason": buildReason, - }, - measurements, - ); + return { + from: previous?.status, + to: status, + buildTransition, + buildReason, + durationMs: previous ? now - previous.observedAtMs : undefined, + buildDurationMs, + }; + } + + public reset(): void { + this.tracker.reset(); + this.buildStartedAtMs = undefined; } } /** - * Emits `workspace.agent.state_transitioned` as the agent's `status` and - * `lifecycle_state` change. The agent has two state dimensions so the event - * carries `status.*` and `lifecycle_state.*` properties. Construct one per - * workspace. + * Detects agent status/lifecycle changes for every agent in a workspace, + * keyed by agent ID so each is tracked independently, and reports agents that + * have disappeared since the previous observation. Stateful but effect-free. + * Construct one per workspace. */ -export class WorkspaceAgentTelemetry { +export class WorkspaceAgentObserver { private readonly tracker = new TransitionTracker( (a, b) => a.status === b.status && a.lifecycleState === b.lifecycleState, ); + /** Last-seen agent name per ID, so removals can be reported by name. */ + private readonly names = new Map(); - public constructor( - private readonly telemetry: TelemetryReporter, - private readonly workspaceName: string, - ) {} - - public observe(agent: WorkspaceAgent): void { + public observe(workspace: Workspace): AgentObservation { const now = performance.now(); - const change = this.tracker.observe({ - status: agent.status, - lifecycleState: agent.lifecycle_state, - observedAtMs: now, - }); - if (!change) { - return; + const transitions: AgentStateTransition[] = []; + const seen = new Set(); + + for (const agent of extractAgents(workspace.latest_build.resources)) { + seen.add(agent.id); + this.names.set(agent.id, agent.name); + const change = this.tracker.observe( + { + status: agent.status, + lifecycleState: agent.lifecycle_state, + observedAtMs: now, + }, + agent.id, + ); + if (!change) { + continue; + } + const previous = change.from; + transitions.push({ + agentName: agent.name, + status: { from: previous?.status, to: agent.status }, + lifecycleState: { + from: previous?.lifecycleState, + to: agent.lifecycle_state, + }, + durationMs: previous ? now - previous.observedAtMs : undefined, + }); } - const previous = change.from; - this.telemetry.log( - "workspace.agent.state_transitioned", - { - workspace_name: this.workspaceName, - agent_name: agent.name, - "status.from": previous?.status ?? INITIAL_STATE, - "status.to": agent.status, - "lifecycle_state.from": previous?.lifecycleState ?? INITIAL_STATE, - "lifecycle_state.to": agent.lifecycle_state, - }, - previous ? { observed_duration_ms: now - previous.observedAtMs } : {}, - ); + const removed: RemovedAgent[] = []; + for (const [id, name] of this.names) { + if (!seen.has(id)) { + removed.push({ name }); + this.names.delete(id); + this.tracker.reset(id); + } + } + + return { transitions, removed }; } public reset(): void { this.tracker.reset(); + this.names.clear(); } } +/** + * Emits `workspace.state_transitioned` for a detected workspace transition. + * Telemetry only; pair with `WorkspaceStateObserver`. + */ +export function recordWorkspaceState( + telemetry: TelemetryReporter, + workspaceName: string, + transition: WorkspaceStateTransition, +): void { + const measurements: Record = {}; + if (transition.durationMs !== undefined) { + measurements.observed_duration_ms = transition.durationMs; + } + if (transition.buildDurationMs !== undefined) { + measurements.observed_build_duration_ms = transition.buildDurationMs; + } + + telemetry.log( + "workspace.state_transitioned", + { + workspace_name: workspaceName, + from: transition.from ?? INITIAL_STATE, + to: transition.to, + "build.transition": transition.buildTransition, + "build.reason": transition.buildReason, + }, + measurements, + ); +} + +/** + * Emits `workspace.agent.state_transitioned` for a detected agent transition. + * Telemetry only; pair with `WorkspaceAgentObserver`. + */ +export function recordAgentState( + telemetry: TelemetryReporter, + workspaceName: string, + transition: AgentStateTransition, +): void { + telemetry.log( + "workspace.agent.state_transitioned", + { + workspace_name: workspaceName, + agent_name: transition.agentName, + "status.from": transition.status.from ?? INITIAL_STATE, + "status.to": transition.status.to, + "lifecycle_state.from": transition.lifecycleState.from ?? INITIAL_STATE, + "lifecycle_state.to": transition.lifecycleState.to, + }, + transition.durationMs !== undefined + ? { observed_duration_ms: transition.durationMs } + : {}, + ); +} + /** * Wraps user-initiated workspace operations (start, update) as traced spans. * Stateless; safe to construct per call site. diff --git a/src/remote/workspaceStateMachine.ts b/src/remote/workspaceStateMachine.ts index f0915da4cf..fc4242d967 100644 --- a/src/remote/workspaceStateMachine.ts +++ b/src/remote/workspaceStateMachine.ts @@ -16,10 +16,7 @@ import { streamAgentLogs, streamBuildLogs, } from "../api/workspace"; -import { - WorkspaceAgentTelemetry, - WorkspaceOperationTelemetry, -} from "../instrumentation/workspace"; +import { WorkspaceOperationTelemetry } from "../instrumentation/workspace"; import { maybeAskAgent } from "../promptUtils"; import { vscodeProposed } from "../vscodeProposed"; @@ -47,7 +44,6 @@ export class WorkspaceStateMachine implements vscode.Disposable { private readonly terminal: TerminalOutputChannel; private readonly buildLogStream = new LazyStream(); private readonly agentLogStream = new LazyStream(); - private readonly agentTelemetry: WorkspaceAgentTelemetry; private readonly operationTelemetry: WorkspaceOperationTelemetry; private agent: { id: string; name: string } | undefined; @@ -68,7 +64,6 @@ export class WorkspaceStateMachine implements vscode.Disposable { this.terminal = new TerminalOutputChannel("Coder: Workspace Build"); const telemetry = container.getTelemetryService(); const workspaceName = `${parts.username}/${parts.workspace}`; - this.agentTelemetry = new WorkspaceAgentTelemetry(telemetry, workspaceName); this.operationTelemetry = new WorkspaceOperationTelemetry( telemetry, workspaceName, @@ -184,7 +179,6 @@ export class WorkspaceStateMachine implements vscode.Disposable { `Agent ${this.agent.name} not found in ${workspaceName} resources`, ); } - this.agentTelemetry.observe(agent); switch (agent.status) { case "connecting": @@ -365,7 +359,6 @@ export class WorkspaceStateMachine implements vscode.Disposable { private resetAgent(): void { this.agent = undefined; - this.agentTelemetry.reset(); } dispose(): void { diff --git a/src/workspace/workspaceAgentLogger.ts b/src/workspace/workspaceAgentLogger.ts deleted file mode 100644 index b391c1ae5c..0000000000 --- a/src/workspace/workspaceAgentLogger.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { extractAgents } from "../api/api-helper"; -import { TransitionTracker } from "../instrumentation/workspace"; - -import type { - Workspace, - WorkspaceAgentLifecycle, - WorkspaceAgentStatus, -} from "coder/site/src/api/typesGenerated"; - -import type { Logger } from "../logging/logger"; - -/** Sentinel for the "from" side before any state is observed. `"unknown"` is a - * real server-reported value, so avoid it. */ -const INITIAL_STATE = "none"; - -interface AgentState { - readonly status: WorkspaceAgentStatus; - readonly lifecycleState: WorkspaceAgentLifecycle; -} - -/** - * Logs agent status and lifecycle transitions at `info` level so connection - * debugging has a record of state changes correlated by the session ID. Mirrors - * `WorkspaceAgentTelemetry` but tracks every agent (keyed by agent ID) for the - * connection's lifetime, since a workspace can have several and they change - * independently of the workspace status. Construct one per workspace; - * `WorkspaceMonitor` is the sole call site. - */ -export class WorkspaceAgentLogger { - private readonly tracker = new TransitionTracker( - (a, b) => a.status === b.status && a.lifecycleState === b.lifecycleState, - ); - - public constructor( - private readonly logger: Logger, - private readonly workspaceName: string, - ) {} - - public observe(workspace: Workspace): void { - for (const agent of extractAgents(workspace.latest_build.resources)) { - const next: AgentState = { - status: agent.status, - lifecycleState: agent.lifecycle_state, - }; - const change = this.tracker.observe(next, agent.id); - if (!change) { - continue; - } - - this.logger.info( - `Workspace ${this.workspaceName} agent ${agent.name} state changed`, - { - status: { - from: change.from?.status ?? INITIAL_STATE, - to: next.status, - }, - lifecycleState: { - from: change.from?.lifecycleState ?? INITIAL_STATE, - to: next.lifecycleState, - }, - }, - ); - } - } -} diff --git a/src/workspace/workspaceMonitor.ts b/src/workspace/workspaceMonitor.ts index 374411097e..5cdf2e7ea7 100644 --- a/src/workspace/workspaceMonitor.ts +++ b/src/workspace/workspaceMonitor.ts @@ -6,7 +6,13 @@ import { formatDistanceToNowStrict } from "date-fns"; import * as vscode from "vscode"; import { createWorkspaceIdentifier, errToStr } from "../api/api-helper"; -import { WorkspaceStateTelemetry } from "../instrumentation/workspace"; +import { + INITIAL_STATE, + recordAgentState, + recordWorkspaceState, + WorkspaceAgentObserver, + WorkspaceStateObserver, +} from "../instrumentation/workspace"; import { areNotificationsDisabled, areUpdateNotificationsDisabled, @@ -14,13 +20,11 @@ import { import { createStatusBarItem } from "../util/statusBar"; import { vscodeProposed } from "../vscodeProposed"; -import { WorkspaceAgentLogger } from "./workspaceAgentLogger"; -import { WorkspaceStateLogger } from "./workspaceStateLogger"; - import type { CoderApi } from "../api/coderApi"; import type { ServiceContainer } from "../core/container"; import type { ContextManager } from "../core/contextManager"; import type { Logger } from "../logging/logger"; +import type { TelemetryReporter } from "../telemetry/reporter"; import type { UnidirectionalStream } from "../websocket/eventStreamConnection"; /** @@ -48,9 +52,9 @@ export class WorkspaceMonitor implements vscode.Disposable { // For logging. private readonly name: string; - private readonly telemetry: WorkspaceStateTelemetry; - private readonly stateLogger: WorkspaceStateLogger; - private readonly agentLogger: WorkspaceAgentLogger; + private readonly telemetry: TelemetryReporter; + private readonly stateObserver = new WorkspaceStateObserver(); + private readonly agentObserver = new WorkspaceAgentObserver(); private readonly logger: Logger; private readonly contextManager: ContextManager; @@ -64,12 +68,7 @@ export class WorkspaceMonitor implements vscode.Disposable { this.logger = container.getLogger(); this.contextManager = container.getContextManager(); this.name = createWorkspaceIdentifier(workspace); - this.telemetry = new WorkspaceStateTelemetry( - container.getTelemetryService(), - this.name, - ); - this.stateLogger = new WorkspaceStateLogger(this.logger, this.name); - this.agentLogger = new WorkspaceAgentLogger(this.logger, this.name); + this.telemetry = container.getTelemetryService(); this.latestWorkspace = workspace; const statusBarItem = createStatusBarItem("workspaceUpdate"); @@ -142,14 +141,54 @@ export class WorkspaceMonitor implements vscode.Disposable { } private update(workspace: Workspace) { - this.telemetry.observe(workspace); - this.stateLogger.observe(workspace); - this.agentLogger.observe(workspace); + this.observeState(workspace); + this.observeAgents(workspace); this.latestWorkspace = workspace; this.updateContext(workspace); this.updateStatusBar(workspace); } + /** Detect a workspace status change, then log it inline and record telemetry. */ + private observeState(workspace: Workspace) { + const transition = this.stateObserver.observe(workspace); + if (!transition) { + return; + } + const verb = + transition.from === undefined ? "state observed" : "state changed"; + this.logger.info(`Workspace ${this.name} ${verb}`, { + from: transition.from ?? INITIAL_STATE, + to: transition.to, + transition: transition.buildTransition, + reason: transition.buildReason, + }); + recordWorkspaceState(this.telemetry, this.name, transition); + } + + /** Detect agent status/lifecycle changes and removals, logging and recording each. */ + private observeAgents(workspace: Workspace) { + const { transitions, removed } = this.agentObserver.observe(workspace); + for (const transition of transitions) { + const verb = + transition.status.from === undefined + ? "state observed" + : "state changed"; + this.logger.info( + `Workspace ${this.name} agent ${transition.agentName} ${verb}`, + { + statusFrom: transition.status.from ?? INITIAL_STATE, + statusTo: transition.status.to, + lifecycleFrom: transition.lifecycleState.from ?? INITIAL_STATE, + lifecycleTo: transition.lifecycleState.to, + }, + ); + recordAgentState(this.telemetry, this.name, transition); + } + for (const agent of removed) { + this.logger.info(`Workspace ${this.name} agent ${agent.name} removed`); + } + } + private maybeNotify(workspace: Workspace) { const cfg = vscode.workspace.getConfiguration(); if (areNotificationsDisabled(cfg)) { diff --git a/src/workspace/workspaceStateLogger.ts b/src/workspace/workspaceStateLogger.ts deleted file mode 100644 index 3adeb523d3..0000000000 --- a/src/workspace/workspaceStateLogger.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { TransitionTracker } from "../instrumentation/workspace"; - -import type { - Workspace, - WorkspaceBuild, - WorkspaceStatus, -} from "coder/site/src/api/typesGenerated"; - -import type { Logger } from "../logging/logger"; - -/** Sentinel for the "from" side before any state is observed. `"unknown"` is a - * real server-reported value, so avoid it. */ -const INITIAL_STATE = "none"; - -interface ObservedWorkspaceState { - readonly status: WorkspaceStatus; - readonly transition: WorkspaceBuild["transition"]; - readonly reason: WorkspaceBuild["reason"]; -} - -/** - * Logs workspace build transitions at `info` level so connection debugging has - * a record of state changes correlated by the session ID. Mirrors - * `WorkspaceStateTelemetry`: keyed on status, transition, and reason, so it logs - * once per workspace state change regardless of how many agents the workspace - * has. Construct one per workspace; `WorkspaceMonitor` is the sole call site. - */ -export class WorkspaceStateLogger { - private readonly tracker = new TransitionTracker( - (a, b) => - a.status === b.status && - a.transition === b.transition && - a.reason === b.reason, - ); - - public constructor( - private readonly logger: Logger, - private readonly workspaceName: string, - ) {} - - public observe(workspace: Workspace): void { - const { status, transition, reason } = workspace.latest_build; - const change = this.tracker.observe({ status, transition, reason }); - if (!change) { - return; - } - - this.logger.info(`Workspace ${this.workspaceName} state changed`, { - status: { from: change.from?.status ?? INITIAL_STATE, to: status }, - transition, - reason, - }); - } -} diff --git a/test/unit/instrumentation/workspace.test.ts b/test/unit/instrumentation/workspace.test.ts index ded1172604..d156e19f1b 100644 --- a/test/unit/instrumentation/workspace.test.ts +++ b/test/unit/instrumentation/workspace.test.ts @@ -2,19 +2,29 @@ import { describe, expect, it } from "vitest"; import { WorkspaceUpdateCancelledError } from "@/api/updateParameters"; import { + recordAgentState, + recordWorkspaceState, TransitionTracker, - WorkspaceAgentTelemetry, + WorkspaceAgentObserver, WorkspaceOperationTelemetry, - WorkspaceStateTelemetry, + WorkspaceStateObserver, } from "@/instrumentation/workspace"; import { agent as createAgent, + resource as createResource, workspace as createWorkspace, } from "@repo/mocks"; import { createTelemetryHarness } from "../../mocks/telemetry"; +import type { + Workspace, + WorkspaceAgent, + WorkspaceBuild, + WorkspaceStatus, +} from "coder/site/src/api/typesGenerated"; + import type { TelemetryService } from "@/telemetry/service"; const WORKSPACE_NAME = "testuser/test-workspace"; @@ -26,10 +36,20 @@ function setup(make: (svc: TelemetryService, name: string) => T) { const newOps = (svc: TelemetryService, name: string) => new WorkspaceOperationTelemetry(svc, name); -const newState = (svc: TelemetryService, name: string) => - new WorkspaceStateTelemetry(svc, name); -const newAgentTelemetry = (svc: TelemetryService, name: string) => - new WorkspaceAgentTelemetry(svc, name); + +function workspaceWith( + status: WorkspaceStatus, + agents: WorkspaceAgent[] = [], + build: Partial = {}, +): Workspace { + return createWorkspace({ + latest_build: { + status, + resources: [createResource({ agents })], + ...build, + }, + }); +} describe("WorkspaceOperationTelemetry", () => { it.each([ @@ -191,114 +211,271 @@ describe("WorkspaceOperationTelemetry", () => { }); }); -describe("WorkspaceStateTelemetry.observe", () => { - it("emits the first observation with from=none and no duration", () => { - const { sink, instance: state } = setup(newState); +describe("WorkspaceStateObserver", () => { + it("reports the first observation with from=undefined and no durations", () => { + const observer = new WorkspaceStateObserver(); - state.observe( - createWorkspace({ - latest_build: { - status: "running", - transition: "start", - reason: "initiator", - }, + const transition = observer.observe( + workspaceWith("running", [], { + transition: "start", + reason: "initiator", }), ); - const event = sink.expectOne("workspace.state_transitioned"); - expect(event.properties).toMatchObject({ - workspace_name: WORKSPACE_NAME, - from: "none", + expect(transition).toMatchObject({ + from: undefined, to: "running", - "build.transition": "start", - "build.reason": "initiator", + buildTransition: "start", + buildReason: "initiator", + durationMs: undefined, + buildDurationMs: undefined, }); - expect(event.measurements.observed_duration_ms).toBeUndefined(); }); - it("ignores duplicate observations of the same state", () => { - const { sink, instance: state } = setup(newState); - const ws = createWorkspace({ latest_build: { status: "running" } }); + it("returns undefined for a duplicate observation", () => { + const observer = new WorkspaceStateObserver(); + const ws = workspaceWith("running"); - state.observe(ws); - state.observe(ws); + observer.observe(ws); - expect(sink.eventsNamed("workspace.state_transitioned")).toHaveLength(1); + expect(observer.observe(ws)).toBeUndefined(); }); - it("records observed_duration_ms across transitions and observed_build_duration_ms once a build resolves", () => { - const { sink, instance: state } = setup(newState); + it("reports the prior status and a duration on a change", () => { + const observer = new WorkspaceStateObserver(); - state.observe(createWorkspace({ latest_build: { status: "stopped" } })); - state.observe(createWorkspace({ latest_build: { status: "starting" } })); - state.observe(createWorkspace({ latest_build: { status: "running" } })); + observer.observe(workspaceWith("starting")); + const transition = observer.observe(workspaceWith("running")); - const [first, second, third] = sink.eventsNamed( - "workspace.state_transitioned", - ); - expect(first.measurements.observed_duration_ms).toBeUndefined(); - expect(second.measurements.observed_duration_ms).toEqual( - expect.any(Number), - ); - expect(second.measurements.observed_build_duration_ms).toBeUndefined(); - expect(third.measurements.observed_build_duration_ms).toEqual( - expect.any(Number), + expect(transition).toMatchObject({ from: "starting", to: "running" }); + expect(transition?.durationMs).toEqual(expect.any(Number)); + }); + + it("reports a change when only transition or reason changes", () => { + const observer = new WorkspaceStateObserver(); + + observer.observe(workspaceWith("running", [], { transition: "start" })); + const transition = observer.observe( + workspaceWith("running", [], { transition: "stop" }), ); + + expect(transition).toMatchObject({ + from: "running", + to: "running", + buildTransition: "stop", + }); + }); + + it("sets buildDurationMs only when a provisioner run resolves", () => { + const observer = new WorkspaceStateObserver(); + + const first = observer.observe(workspaceWith("stopped")); + const second = observer.observe(workspaceWith("starting")); + const third = observer.observe(workspaceWith("running")); + + expect(first?.buildDurationMs).toBeUndefined(); + expect(second?.buildDurationMs).toBeUndefined(); + expect(third?.buildDurationMs).toEqual(expect.any(Number)); + }); + + it("reset() makes the next observation report from=undefined again", () => { + const observer = new WorkspaceStateObserver(); + + observer.observe(workspaceWith("running")); + observer.reset(); + + expect(observer.observe(workspaceWith("running"))?.from).toBeUndefined(); }); }); -describe("WorkspaceAgentTelemetry.observe", () => { - it("emits the first observation with from=none", () => { - const { sink, instance: agentTelemetry } = setup(newAgentTelemetry); +describe("WorkspaceAgentObserver", () => { + it("reports the first observation of each agent with from=undefined", () => { + const observer = new WorkspaceAgentObserver(); + + const { transitions, removed } = observer.observe( + workspaceWith("running", [ + createAgent({ + name: "main", + status: "connecting", + lifecycle_state: "created", + }), + ]), + ); + + expect(removed).toEqual([]); + expect(transitions).toHaveLength(1); + expect(transitions[0]).toMatchObject({ + agentName: "main", + status: { from: undefined, to: "connecting" }, + lifecycleState: { from: undefined, to: "created" }, + durationMs: undefined, + }); + }); + + it("dedupes an unchanged agent", () => { + const observer = new WorkspaceAgentObserver(); + const ws = workspaceWith("running", [ + createAgent({ status: "connected", lifecycle_state: "ready" }), + ]); + + observer.observe(ws); + + expect(observer.observe(ws).transitions).toEqual([]); + }); + + it("tracks each agent independently", () => { + const observer = new WorkspaceAgentObserver(); + + observer.observe( + workspaceWith("running", [ + createAgent({ id: "a1", name: "first", status: "connected" }), + createAgent({ id: "a2", name: "second", status: "connecting" }), + ]), + ); - agentTelemetry.observe( - createAgent({ status: "connecting", lifecycle_state: "created" }), + const { transitions } = observer.observe( + workspaceWith("running", [ + createAgent({ id: "a1", name: "first", status: "connected" }), + createAgent({ id: "a2", name: "second", status: "connected" }), + ]), ); - expect(sink.expectOne("workspace.agent.state_transitioned")).toMatchObject({ - properties: { - "status.from": "none", - "status.to": "connecting", - "lifecycle_state.from": "none", - "lifecycle_state.to": "created", - }, + expect(transitions).toHaveLength(1); + expect(transitions[0]).toMatchObject({ + agentName: "second", + status: { from: "connecting", to: "connected" }, }); }); - it("dedupes consecutive identical observations", () => { - const { sink, instance: agentTelemetry } = setup(newAgentTelemetry); - const a = createAgent({ status: "connected", lifecycle_state: "ready" }); + it("reports an agent that disappears since the previous observation", () => { + const observer = new WorkspaceAgentObserver(); + + observer.observe( + workspaceWith("running", [ + createAgent({ id: "a1", name: "first" }), + createAgent({ id: "a2", name: "second" }), + ]), + ); + + const { removed } = observer.observe( + workspaceWith("running", [createAgent({ id: "a1", name: "first" })]), + ); + + expect(removed).toEqual([{ name: "second" }]); + }); - agentTelemetry.observe(a); - agentTelemetry.observe(a); + it("treats a returning agent id as a fresh observation after removal", () => { + const observer = new WorkspaceAgentObserver(); - expect(sink.eventsNamed("workspace.agent.state_transitioned")).toHaveLength( - 1, + observer.observe( + workspaceWith("running", [ + createAgent({ id: "a1", name: "first", status: "connected" }), + ]), + ); + observer.observe(workspaceWith("starting", [])); + const { transitions } = observer.observe( + workspaceWith("running", [ + createAgent({ id: "a1", name: "first", status: "connecting" }), + ]), ); + + expect(transitions[0].status.from).toBeUndefined(); }); - it("reset() makes the next observation emit from=none again", () => { - const { sink, instance: agentTelemetry } = setup(newAgentTelemetry); + it("reset() forgets all agents", () => { + const observer = new WorkspaceAgentObserver(); + const ws = workspaceWith("running", [createAgent({ status: "connected" })]); - agentTelemetry.observe(createAgent({ status: "connected" })); - agentTelemetry.reset(); - agentTelemetry.observe(createAgent({ status: "connecting" })); + observer.observe(ws); + observer.reset(); - const events = sink.eventsNamed("workspace.agent.state_transitioned"); - expect(events).toHaveLength(2); - expect(events[1].properties["status.from"]).toBe("none"); + expect(observer.observe(ws).transitions[0].status.from).toBeUndefined(); }); +}); - it("includes observed_duration_ms between transitions", () => { - const { sink, instance: agentTelemetry } = setup(newAgentTelemetry); +describe("recordWorkspaceState", () => { + it("emits workspace.state_transitioned with flat dotted keys", () => { + const { sink, service } = createTelemetryHarness(); - agentTelemetry.observe(createAgent({ status: "connecting" })); - agentTelemetry.observe(createAgent({ status: "connected" })); + recordWorkspaceState(service, WORKSPACE_NAME, { + from: "starting", + to: "running", + buildTransition: "start", + buildReason: "initiator", + durationMs: 1200, + buildDurationMs: 3400, + }); - const events = sink.eventsNamed("workspace.agent.state_transitioned"); - expect(events[1].measurements.observed_duration_ms).toEqual( - expect.any(Number), - ); + const event = sink.expectOne("workspace.state_transitioned"); + expect(event.properties).toMatchObject({ + workspace_name: WORKSPACE_NAME, + from: "starting", + to: "running", + "build.transition": "start", + "build.reason": "initiator", + }); + expect(event.measurements).toMatchObject({ + observed_duration_ms: 1200, + observed_build_duration_ms: 3400, + }); + }); + + it("uses the sentinel for from and omits absent measurements", () => { + const { sink, service } = createTelemetryHarness(); + + recordWorkspaceState(service, WORKSPACE_NAME, { + from: undefined, + to: "running", + buildTransition: "start", + buildReason: "initiator", + durationMs: undefined, + buildDurationMs: undefined, + }); + + const event = sink.expectOne("workspace.state_transitioned"); + expect(event.properties.from).toBe("none"); + expect(event.measurements.observed_duration_ms).toBeUndefined(); + expect(event.measurements.observed_build_duration_ms).toBeUndefined(); + }); +}); + +describe("recordAgentState", () => { + it("emits workspace.agent.state_transitioned with flat dotted keys", () => { + const { sink, service } = createTelemetryHarness(); + + recordAgentState(service, WORKSPACE_NAME, { + agentName: "main", + status: { from: "connecting", to: "connected" }, + lifecycleState: { from: "starting", to: "ready" }, + durationMs: 800, + }); + + const event = sink.expectOne("workspace.agent.state_transitioned"); + expect(event.properties).toMatchObject({ + workspace_name: WORKSPACE_NAME, + agent_name: "main", + "status.from": "connecting", + "status.to": "connected", + "lifecycle_state.from": "starting", + "lifecycle_state.to": "ready", + }); + expect(event.measurements.observed_duration_ms).toBe(800); + }); + + it("uses the sentinel for absent from values and omits duration", () => { + const { sink, service } = createTelemetryHarness(); + + recordAgentState(service, WORKSPACE_NAME, { + agentName: "main", + status: { from: undefined, to: "connecting" }, + lifecycleState: { from: undefined, to: "created" }, + durationMs: undefined, + }); + + const event = sink.expectOne("workspace.agent.state_transitioned"); + expect(event.properties["status.from"]).toBe("none"); + expect(event.properties["lifecycle_state.from"]).toBe("none"); + expect(event.measurements.observed_duration_ms).toBeUndefined(); }); }); diff --git a/test/unit/remote/workspaceStateMachine.test.ts b/test/unit/remote/workspaceStateMachine.test.ts index dddc465f06..e437b67467 100644 --- a/test/unit/remote/workspaceStateMachine.test.ts +++ b/test/unit/remote/workspaceStateMachine.test.ts @@ -432,76 +432,6 @@ describe("WorkspaceStateMachine", () => { expect(event.measurements.durationMs).toEqual(expect.any(Number)); }, ); - - it("emits agent state transitions with observed duration", async () => { - const sink = new TestSink(); - const { sm, progress } = setup("start", createTestTelemetryService(sink)); - - await sm.processWorkspace( - runningWorkspace({ status: "connecting", lifecycle_state: "created" }), - progress, - ); - await sm.processWorkspace(runningWorkspace(), progress); - - const events = sink.eventsNamed("workspace.agent.state_transitioned"); - expect(events).toHaveLength(2); - expect(events[0].properties).toMatchObject({ - "status.from": "none", - "status.to": "connecting", - "lifecycle_state.from": "none", - "lifecycle_state.to": "created", - }); - expect(events[1].properties).toMatchObject({ - "status.from": "connecting", - "status.to": "connected", - "lifecycle_state.from": "created", - "lifecycle_state.to": "ready", - }); - expect(events[1].measurements.observed_duration_ms).toEqual( - expect.any(Number), - ); - }); - - it("resets agent telemetry on restart so the next transition emits from 'none'", async () => { - const sink = new TestSink(); - const { sm, progress } = setup("start", createTestTelemetryService(sink)); - - // The build log stream is closed when we return to running; give the - // mock something disposable so close() doesn't blow up. - vi.mocked(streamBuildLogs).mockResolvedValueOnce({ - close: vi.fn(), - } as never); - - // Establish a baseline: connected/ready. - await sm.processWorkspace(runningWorkspace(), progress); - - // Workspace enters a build state; resetAgent fires. - await sm.processWorkspace( - createWorkspace({ latest_build: { status: "stopping" } }), - progress, - ); - - // Next agent observation must restart from "none", not the prior baseline. - await sm.processWorkspace( - runningWorkspace({ status: "connecting", lifecycle_state: "created" }), - progress, - ); - - const events = sink.eventsNamed("workspace.agent.state_transitioned"); - expect(events).toHaveLength(2); - expect(events[0].properties).toMatchObject({ - "status.from": "none", - "status.to": "connected", - "lifecycle_state.from": "none", - "lifecycle_state.to": "ready", - }); - expect(events[1].properties).toMatchObject({ - "status.from": "none", - "status.to": "connecting", - "lifecycle_state.from": "none", - "lifecycle_state.to": "created", - }); - }); }); describe("agent selection", () => { diff --git a/test/unit/workspace/workspaceAgentLogger.test.ts b/test/unit/workspace/workspaceAgentLogger.test.ts deleted file mode 100644 index 9a042416e0..0000000000 --- a/test/unit/workspace/workspaceAgentLogger.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { WorkspaceAgentLogger } from "@/workspace/workspaceAgentLogger"; - -import { - agent as createAgent, - resource as createResource, - workspace as createWorkspace, -} from "@repo/mocks"; - -import { createMockLogger } from "../../mocks/testHelpers"; - -import type { - Workspace, - WorkspaceAgent, -} from "coder/site/src/api/typesGenerated"; - -function workspaceWith(agents: WorkspaceAgent[]): Workspace { - return createWorkspace({ - latest_build: { - status: "running", - resources: [createResource({ agents })], - }, - }); -} - -describe("WorkspaceAgentLogger", () => { - it("logs the initial agent state with a `none` origin", () => { - const logger = createMockLogger(); - const agentLogger = new WorkspaceAgentLogger(logger, "testuser/ws"); - - agentLogger.observe( - workspaceWith([ - createAgent({ - name: "main", - status: "connected", - lifecycle_state: "ready", - }), - ]), - ); - - expect(logger.info).toHaveBeenCalledTimes(1); - expect(logger.info).toHaveBeenCalledWith( - "Workspace testuser/ws agent main state changed", - { - status: { from: "none", to: "connected" }, - lifecycleState: { from: "none", to: "ready" }, - }, - ); - }); - - it("logs when the agent status or lifecycle changes", () => { - const logger = createMockLogger(); - const agentLogger = new WorkspaceAgentLogger(logger, "testuser/ws"); - - agentLogger.observe( - workspaceWith([ - createAgent({ status: "connecting", lifecycle_state: "starting" }), - ]), - ); - agentLogger.observe( - workspaceWith([ - createAgent({ status: "connected", lifecycle_state: "ready" }), - ]), - ); - - expect(logger.info).toHaveBeenCalledTimes(2); - expect(logger.info).toHaveBeenLastCalledWith( - "Workspace testuser/ws agent main state changed", - { - status: { from: "connecting", to: "connected" }, - lifecycleState: { from: "starting", to: "ready" }, - }, - ); - }); - - it("does not log when the agent state is unchanged", () => { - const logger = createMockLogger(); - const agentLogger = new WorkspaceAgentLogger(logger, "testuser/ws"); - const snapshot = workspaceWith([ - createAgent({ status: "connected", lifecycle_state: "ready" }), - ]); - - agentLogger.observe(snapshot); - agentLogger.observe(snapshot); - - expect(logger.info).toHaveBeenCalledTimes(1); - }); - - it("tracks each agent independently", () => { - const logger = createMockLogger(); - const agentLogger = new WorkspaceAgentLogger(logger, "testuser/ws"); - - agentLogger.observe( - workspaceWith([ - 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. - agentLogger.observe( - workspaceWith([ - createAgent({ id: "a1", name: "first", status: "connected" }), - createAgent({ id: "a2", name: "second", status: "connected" }), - ]), - ); - expect(logger.info).toHaveBeenCalledTimes(3); - }); -}); diff --git a/test/unit/workspace/workspaceMonitor.test.ts b/test/unit/workspace/workspaceMonitor.test.ts index d156f8952c..e3ed7c778d 100644 --- a/test/unit/workspace/workspaceMonitor.test.ts +++ b/test/unit/workspace/workspaceMonitor.test.ts @@ -3,7 +3,11 @@ import * as vscode from "vscode"; import { WorkspaceMonitor } from "@/workspace/workspaceMonitor"; -import { workspace as createWorkspace } from "@repo/mocks"; +import { + agent as createAgent, + resource as createResource, + workspace as createWorkspace, +} from "@repo/mocks"; import { createTestTelemetryService, @@ -50,6 +54,7 @@ describe("WorkspaceMonitor", () => { const config = new MockConfigurationProvider(); const statusBar = new MockStatusBarItem(); const contextManager = new MockContextManager(); + const logger = createMockLogger(); const client = { watchWorkspace: vi.fn().mockResolvedValue(stream), getTemplate: vi.fn().mockResolvedValue({ @@ -64,11 +69,19 @@ describe("WorkspaceMonitor", () => { client, createMockServiceContainer({ telemetry, - logger: createMockLogger(), + logger, contextManager, }), ); - return { monitor, client, stream, config, statusBar, contextManager }; + return { + monitor, + client, + stream, + config, + statusBar, + contextManager, + logger, + }; } describe("telemetry", () => { @@ -193,6 +206,137 @@ describe("WorkspaceMonitor", () => { }); }); + describe("state logging", () => { + it("logs the initial workspace state as observed with flat scalars", async () => { + const { logger } = await setup( + new MockEventStream(), + undefined, + createWorkspace({ + latest_build: { + status: "running", + transition: "start", + reason: "initiator", + }, + }), + ); + + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining("state observed"), + { + from: "none", + to: "running", + transition: "start", + reason: "initiator", + }, + ); + }); + + it("logs subsequent workspace changes as changed", async () => { + const { stream, logger } = await setup( + new MockEventStream(), + undefined, + createWorkspace({ latest_build: { status: "running" } }), + ); + + stream.pushMessage( + workspaceEvent({ latest_build: { status: "stopping" } }), + ); + + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining("state changed"), + expect.objectContaining({ from: "running", to: "stopping" }), + ); + }); + }); + + describe("agent state", () => { + const agentWorkspace = ( + agents: Array[0]>, + status = "running" as const, + ) => + createWorkspace({ + latest_build: { + status, + resources: [createResource({ agents: agents.map(createAgent) })], + }, + }); + + it("logs the initial agent state and records telemetry", async () => { + enableLocalTelemetry(); + const sink = new TestSink(); + const { logger } = await setup( + new MockEventStream(), + createTestTelemetryService(sink), + agentWorkspace([ + { name: "main", status: "connecting", lifecycle_state: "created" }, + ]), + ); + + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining("agent main state observed"), + { + statusFrom: "none", + statusTo: "connecting", + lifecycleFrom: "none", + lifecycleTo: "created", + }, + ); + expect( + sink.eventsNamed("workspace.agent.state_transitioned"), + ).toHaveLength(1); + }); + + it("logs and records each agent transition across all agents", async () => { + enableLocalTelemetry(); + const sink = new TestSink(); + const { stream } = await setup( + new MockEventStream(), + createTestTelemetryService(sink), + agentWorkspace([ + { id: "a1", name: "first", status: "connected" }, + { id: "a2", name: "second", status: "connecting" }, + ]), + ); + + stream.pushMessage({ + type: "data", + data: agentWorkspace([ + { id: "a1", name: "first", status: "connected" }, + { id: "a2", name: "second", status: "connected" }, + ]), + }); + + const events = sink.eventsNamed("workspace.agent.state_transitioned"); + // Two initial observations plus the one "second" transition. + expect(events).toHaveLength(3); + expect(events[2].properties).toMatchObject({ + agent_name: "second", + "status.from": "connecting", + "status.to": "connected", + }); + }); + + it("logs when an agent disappears", async () => { + const { stream, logger } = await setup( + new MockEventStream(), + undefined, + agentWorkspace([ + { id: "a1", name: "first" }, + { id: "a2", name: "second" }, + ]), + ); + + stream.pushMessage({ + type: "data", + data: agentWorkspace([{ id: "a1", name: "first" }]), + }); + + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining("agent second removed"), + ); + }); + }); + describe("websocket lifecycle", () => { it("fires onChange when a workspace message arrives", async () => { const { monitor, stream } = await setup(); diff --git a/test/unit/workspace/workspaceStateLogger.test.ts b/test/unit/workspace/workspaceStateLogger.test.ts deleted file mode 100644 index 4c005a79d1..0000000000 --- a/test/unit/workspace/workspaceStateLogger.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -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, - WorkspaceBuild, - WorkspaceStatus, -} from "coder/site/src/api/typesGenerated"; - -function workspaceWith( - status: WorkspaceStatus, - agents: WorkspaceAgent[] = [], - build: Partial = {}, -): Workspace { - return createWorkspace({ - latest_build: { - status, - resources: [createResource({ agents })], - ...build, - }, - }); -} - -// Defaults supplied by the workspace mock factory. -const DEFAULT_TRANSITION = "start"; -const DEFAULT_REASON = "initiator"; - -describe("WorkspaceStateLogger", () => { - it("logs the initial observed status with a `none` origin", () => { - const logger = createMockLogger(); - const stateLogger = new WorkspaceStateLogger(logger, "testuser/ws"); - - stateLogger.observe(workspaceWith("running")); - - expect(logger.info).toHaveBeenCalledTimes(1); - expect(logger.info).toHaveBeenCalledWith( - "Workspace testuser/ws state changed", - { - status: { from: "none", to: "running" }, - transition: DEFAULT_TRANSITION, - reason: DEFAULT_REASON, - }, - ); - }); - - it("logs once when the workspace status changes", () => { - const logger = createMockLogger(); - const stateLogger = new WorkspaceStateLogger(logger, "testuser/ws"); - - stateLogger.observe(workspaceWith("starting")); - stateLogger.observe(workspaceWith("running")); - - expect(logger.info).toHaveBeenCalledTimes(2); - expect(logger.info).toHaveBeenLastCalledWith( - "Workspace testuser/ws state changed", - { - status: { from: "starting", to: "running" }, - transition: DEFAULT_TRANSITION, - reason: DEFAULT_REASON, - }, - ); - }); - - it("logs when the transition or reason changes even if status is unchanged", () => { - const logger = createMockLogger(); - const stateLogger = new WorkspaceStateLogger(logger, "testuser/ws"); - - stateLogger.observe(workspaceWith("running", [], { transition: "start" })); - stateLogger.observe(workspaceWith("running", [], { transition: "stop" })); - - expect(logger.info).toHaveBeenCalledTimes(2); - expect(logger.info).toHaveBeenLastCalledWith( - "Workspace testuser/ws state changed", - { - status: { from: "running", to: "running" }, - transition: "stop", - reason: DEFAULT_REASON, - }, - ); - }); - - it("does not log when status, transition, and reason are unchanged", () => { - const logger = createMockLogger(); - const stateLogger = new WorkspaceStateLogger(logger, "testuser/ws"); - - stateLogger.observe(workspaceWith("running")); - stateLogger.observe(workspaceWith("running")); - - expect(logger.info).toHaveBeenCalledTimes(1); - }); - - it("logs a workspace change exactly once regardless of agent count", () => { - const logger = createMockLogger(); - const stateLogger = new WorkspaceStateLogger(logger, "testuser/ws"); - - stateLogger.observe( - workspaceWith("running", [ - createAgent({ id: "a1", name: "first" }), - createAgent({ id: "a2", name: "second" }), - ]), - ); - - expect(logger.info).toHaveBeenCalledTimes(1); - }); - - it("ignores agent-only changes", () => { - const logger = createMockLogger(); - const stateLogger = new WorkspaceStateLogger(logger, "testuser/ws"); - - stateLogger.observe( - workspaceWith("running", [createAgent({ status: "connecting" })]), - ); - stateLogger.observe( - workspaceWith("running", [createAgent({ status: "connected" })]), - ); - - expect(logger.info).toHaveBeenCalledTimes(1); - }); -}); From bce450d275d230715409dc248ab3cb8eee82e539 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 19 Aug 2026 00:40:42 +0000 Subject: [PATCH 06/24] docs(events): rescope workspace.agent.state_transitioned The event now covers every agent for the whole monitored session (deduped), not just the connected agent during connection setup, and transitions are sampled from the workspace event stream so intermediate hops may coalesce. --- src/instrumentation/EVENTS.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/instrumentation/EVENTS.md b/src/instrumentation/EVENTS.md index a3186665da..6ae2b9f4bc 100644 --- a/src/instrumentation/EVENTS.md +++ b/src/instrumentation/EVENTS.md @@ -474,8 +474,8 @@ success or termination). Emitted by `WorkspaceOperationTelemetry` (start and update), `WorkspaceOpenTelemetry` (open, picker, dev container), and -`WorkspaceStateTelemetry` / `WorkspaceAgentTelemetry` (the state-transition -logs). +`recordWorkspaceState` / `recordAgentState` (the state-transition events, from +transitions detected by `WorkspaceStateObserver` / `WorkspaceAgentObserver`). ### Spans @@ -550,6 +550,11 @@ Opening a workspace from any entry point. #### `workspace.agent.state_transitioned` +Emitted for every agent in the workspace, deduped per agent, for the whole +monitored session (not only the connected agent during connection setup). +Transitions are sampled from the workspace event stream, so intermediate hops +between samples may coalesce into a single transition. + | Attribute | Values | | -------------------------------------------- | ---------------------------------------------------- | | `workspace_name`, `agent_name` | names | From 295a6c806e0278cbc79a8b9cde0bf8032cd4cf15 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 19 Aug 2026 22:41:37 +0000 Subject: [PATCH 07/24] docs: trim comments to non-obvious info only --- src/instrumentation/workspace.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/instrumentation/workspace.ts b/src/instrumentation/workspace.ts index 97d78b38fb..d3e0ada143 100644 --- a/src/instrumentation/workspace.ts +++ b/src/instrumentation/workspace.ts @@ -78,7 +78,7 @@ interface ObservedAgentState { readonly observedAtMs: number; } -/** A detected workspace status change, reported by `WorkspaceStateObserver`. */ +/** Reported by `WorkspaceStateObserver`. */ export interface WorkspaceStateTransition { /** Previous status, or `undefined` on the first observation. */ readonly from: WorkspaceStatus | undefined; @@ -91,7 +91,7 @@ export interface WorkspaceStateTransition { readonly buildDurationMs: number | undefined; } -/** A detected agent status/lifecycle change, reported by `WorkspaceAgentObserver`. */ +/** Reported by `WorkspaceAgentObserver`. */ export interface AgentStateTransition { readonly agentName: string; readonly status: { @@ -106,22 +106,17 @@ export interface AgentStateTransition { readonly durationMs: number | undefined; } -/** An agent present on a prior observation and absent now (e.g. after a rebuild). */ export interface RemovedAgent { readonly name: string; } -/** The result of observing all agents in a workspace snapshot. */ export interface AgentObservation { readonly transitions: AgentStateTransition[]; readonly removed: RemovedAgent[]; } /** - * Detects workspace status changes as a workspace progresses through statuses, - * reporting a transition object plus timing (including build duration when a - * provisioner run resolves). Stateful but effect-free: it holds no logger or - * telemetry references. Construct one per workspace. + * Construct one per workspace. */ export class WorkspaceStateObserver { private readonly tracker = new TransitionTracker( @@ -181,9 +176,6 @@ export class WorkspaceStateObserver { } /** - * Detects agent status/lifecycle changes for every agent in a workspace, - * keyed by agent ID so each is tracked independently, and reports agents that - * have disappeared since the previous observation. Stateful but effect-free. * Construct one per workspace. */ export class WorkspaceAgentObserver { From f1449214573e616194e4cbdc26fdcc402bd92d3a Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 19 Aug 2026 15:47:53 -0700 Subject: [PATCH 08/24] docs: rm redundant comments --- src/workspace/workspaceMonitor.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/workspace/workspaceMonitor.ts b/src/workspace/workspaceMonitor.ts index 5cdf2e7ea7..09743ef027 100644 --- a/src/workspace/workspaceMonitor.ts +++ b/src/workspace/workspaceMonitor.ts @@ -148,7 +148,6 @@ export class WorkspaceMonitor implements vscode.Disposable { this.updateStatusBar(workspace); } - /** Detect a workspace status change, then log it inline and record telemetry. */ private observeState(workspace: Workspace) { const transition = this.stateObserver.observe(workspace); if (!transition) { @@ -165,7 +164,6 @@ export class WorkspaceMonitor implements vscode.Disposable { recordWorkspaceState(this.telemetry, this.name, transition); } - /** Detect agent status/lifecycle changes and removals, logging and recording each. */ private observeAgents(workspace: Workspace) { const { transitions, removed } = this.agentObserver.observe(workspace); for (const transition of transitions) { From bfb77060b7930ddd928b87ad370452d3acf609f8 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 19 Aug 2026 23:32:56 +0000 Subject: [PATCH 09/24] refactor(workspace): move state observers out of instrumentation Per CONVENTIONS.md, src/instrumentation is telemetry-only. The pure detection code has no telemetry or logger imports, so move TransitionTracker, WorkspaceStateObserver, WorkspaceAgentObserver, and the transition types to src/workspace/observers.ts. The record* functions stay in instrumentation and import the transition types from there. Observer/tracker tests move to test/unit/workspace/observers.test.ts. --- src/instrumentation/workspace.ts | 229 +---------------- src/workspace/observers.ts | 224 ++++++++++++++++ src/workspace/workspaceMonitor.ts | 4 +- test/unit/instrumentation/workspace.test.ts | 267 ------------------- test/unit/workspace/observers.test.ts | 271 ++++++++++++++++++++ 5 files changed, 502 insertions(+), 493 deletions(-) create mode 100644 src/workspace/observers.ts create mode 100644 test/unit/workspace/observers.test.ts diff --git a/src/instrumentation/workspace.ts b/src/instrumentation/workspace.ts index d3e0ada143..e3f8749d84 100644 --- a/src/instrumentation/workspace.ts +++ b/src/instrumentation/workspace.ts @@ -1,239 +1,20 @@ -import { extractAgents } from "../api/api-helper"; import { WorkspaceUpdateCancelledError } from "../api/updateParameters"; -import type { - Workspace, - WorkspaceAgentLifecycle, - WorkspaceAgentStatus, - WorkspaceBuild, - WorkspaceBuildParameter, - WorkspaceStatus, -} from "coder/site/src/api/typesGenerated"; +import type { WorkspaceBuildParameter } from "coder/site/src/api/typesGenerated"; import type { TelemetryReporter } from "../telemetry/reporter"; import type { Span } from "../telemetry/span"; +import type { + AgentStateTransition, + WorkspaceStateTransition, +} from "../workspace/observers"; /** Sentinel for `from*` before any state is observed. `"unknown"` is a real server-reported value, so avoid it. */ export const INITIAL_STATE = "none"; -/** Statuses where a provisioner job is actively running. */ -const PROVISIONING_STATUSES: ReadonlySet = new Set([ - "pending", - "starting", - "stopping", - "canceling", - "deleting", -]); - export type WorkspacePromptAction = "start" | "update"; export type WorkspaceUpdatePrompt = "parameters" | "confirmation"; -/** - * Tracks the last observed value per key and reports the previous value each - * time it changes. Shared by the workspace/agent observers and the corresponding - * loggers so transition detection lives in one place. - * - * A single tracked entity (e.g. a workspace) can omit the key; callers that - * track many entities (e.g. agents keyed by ID) pass a distinct key each time. - */ -export class TransitionTracker { - private readonly previous = new Map(); - - public constructor(private readonly equals: (a: T, b: T) => boolean) {} - - /** - * Record `next` for `key`. Returns `{ from }` when it differs from the last - * recorded value (`from` is `undefined` on the first observation), or - * `undefined` when unchanged. - */ - public observe(next: T, key = ""): { from: T | undefined } | undefined { - const prior = this.previous.get(key); - if (prior !== undefined && this.equals(prior, next)) { - return undefined; - } - this.previous.set(key, next); - return { from: prior }; - } - - /** Forget a single key, or all keys when `key` is omitted. */ - public reset(key?: string): void { - if (key === undefined) { - this.previous.clear(); - } else { - this.previous.delete(key); - } - } -} - -interface ObservedWorkspaceState { - readonly status: WorkspaceStatus; - readonly buildTransition: WorkspaceBuild["transition"]; - readonly buildReason: WorkspaceBuild["reason"]; - readonly observedAtMs: number; -} - -interface ObservedAgentState { - readonly status: WorkspaceAgentStatus; - readonly lifecycleState: WorkspaceAgentLifecycle; - readonly observedAtMs: number; -} - -/** Reported by `WorkspaceStateObserver`. */ -export interface WorkspaceStateTransition { - /** Previous status, or `undefined` on the first observation. */ - readonly from: WorkspaceStatus | undefined; - readonly to: WorkspaceStatus; - readonly buildTransition: WorkspaceBuild["transition"]; - readonly buildReason: WorkspaceBuild["reason"]; - /** Time spent in the previous state; `undefined` on the first observation. */ - readonly durationMs: number | undefined; - /** Set only on the observation where a provisioner run resolves. */ - readonly buildDurationMs: number | undefined; -} - -/** Reported by `WorkspaceAgentObserver`. */ -export interface AgentStateTransition { - readonly agentName: string; - readonly status: { - readonly from: WorkspaceAgentStatus | undefined; - readonly to: WorkspaceAgentStatus; - }; - readonly lifecycleState: { - readonly from: WorkspaceAgentLifecycle | undefined; - readonly to: WorkspaceAgentLifecycle; - }; - /** Time since the previous observation of this agent; `undefined` on the first. */ - readonly durationMs: number | undefined; -} - -export interface RemovedAgent { - readonly name: string; -} - -export interface AgentObservation { - readonly transitions: AgentStateTransition[]; - readonly removed: RemovedAgent[]; -} - -/** - * Construct one per workspace. - */ -export class WorkspaceStateObserver { - private readonly tracker = new TransitionTracker( - (a, b) => - a.status === b.status && - a.buildTransition === b.buildTransition && - a.buildReason === b.buildReason, - ); - /** Set on first observation of a provisioning status; cleared when the build resolves. */ - private buildStartedAtMs: number | undefined; - - public observe(workspace: Workspace): WorkspaceStateTransition | undefined { - const { - status, - transition: buildTransition, - reason: buildReason, - } = workspace.latest_build; - const now = performance.now(); - const change = this.tracker.observe({ - status, - buildTransition, - buildReason, - observedAtMs: now, - }); - if (!change) { - return undefined; - } - const previous = change.from; - - const wasProvisioning = - previous && PROVISIONING_STATUSES.has(previous.status); - const isProvisioning = PROVISIONING_STATUSES.has(status); - let buildDurationMs: number | undefined; - if (isProvisioning) { - this.buildStartedAtMs ??= now; - } else { - if (wasProvisioning && this.buildStartedAtMs !== undefined) { - buildDurationMs = now - this.buildStartedAtMs; - } - this.buildStartedAtMs = undefined; - } - - return { - from: previous?.status, - to: status, - buildTransition, - buildReason, - durationMs: previous ? now - previous.observedAtMs : undefined, - buildDurationMs, - }; - } - - public reset(): void { - this.tracker.reset(); - this.buildStartedAtMs = undefined; - } -} - -/** - * Construct one per workspace. - */ -export class WorkspaceAgentObserver { - private readonly tracker = new TransitionTracker( - (a, b) => a.status === b.status && a.lifecycleState === b.lifecycleState, - ); - /** Last-seen agent name per ID, so removals can be reported by name. */ - private readonly names = new Map(); - - public observe(workspace: Workspace): AgentObservation { - const now = performance.now(); - const transitions: AgentStateTransition[] = []; - const seen = new Set(); - - for (const agent of extractAgents(workspace.latest_build.resources)) { - seen.add(agent.id); - this.names.set(agent.id, agent.name); - const change = this.tracker.observe( - { - status: agent.status, - lifecycleState: agent.lifecycle_state, - observedAtMs: now, - }, - agent.id, - ); - if (!change) { - continue; - } - const previous = change.from; - transitions.push({ - agentName: agent.name, - status: { from: previous?.status, to: agent.status }, - lifecycleState: { - from: previous?.lifecycleState, - to: agent.lifecycle_state, - }, - durationMs: previous ? now - previous.observedAtMs : undefined, - }); - } - - const removed: RemovedAgent[] = []; - for (const [id, name] of this.names) { - if (!seen.has(id)) { - removed.push({ name }); - this.names.delete(id); - this.tracker.reset(id); - } - } - - return { transitions, removed }; - } - - public reset(): void { - this.tracker.reset(); - this.names.clear(); - } -} - /** * Emits `workspace.state_transitioned` for a detected workspace transition. * Telemetry only; pair with `WorkspaceStateObserver`. diff --git a/src/workspace/observers.ts b/src/workspace/observers.ts new file mode 100644 index 0000000000..39f69ab44a --- /dev/null +++ b/src/workspace/observers.ts @@ -0,0 +1,224 @@ +import { extractAgents } from "../api/api-helper"; + +import type { + Workspace, + WorkspaceAgentLifecycle, + WorkspaceAgentStatus, + WorkspaceBuild, + WorkspaceStatus, +} from "coder/site/src/api/typesGenerated"; + +/** Statuses where a provisioner job is actively running. */ +const PROVISIONING_STATUSES: ReadonlySet = new Set([ + "pending", + "starting", + "stopping", + "canceling", + "deleting", +]); + +/** + * Tracks the last observed value per key and reports the previous value each + * time it changes. Shared by the workspace/agent observers so transition + * detection lives in one place. + * + * A single tracked entity (e.g. a workspace) can omit the key; callers that + * track many entities (e.g. agents keyed by ID) pass a distinct key each time. + */ +export class TransitionTracker { + private readonly previous = new Map(); + + public constructor(private readonly equals: (a: T, b: T) => boolean) {} + + /** + * Record `next` for `key`. Returns `{ from }` when it differs from the last + * recorded value (`from` is `undefined` on the first observation), or + * `undefined` when unchanged. + */ + public observe(next: T, key = ""): { from: T | undefined } | undefined { + const prior = this.previous.get(key); + if (prior !== undefined && this.equals(prior, next)) { + return undefined; + } + this.previous.set(key, next); + return { from: prior }; + } + + /** Forget a single key, or all keys when `key` is omitted. */ + public reset(key?: string): void { + if (key === undefined) { + this.previous.clear(); + } else { + this.previous.delete(key); + } + } +} + +interface ObservedWorkspaceState { + readonly status: WorkspaceStatus; + readonly buildTransition: WorkspaceBuild["transition"]; + readonly buildReason: WorkspaceBuild["reason"]; + readonly observedAtMs: number; +} + +interface ObservedAgentState { + readonly status: WorkspaceAgentStatus; + readonly lifecycleState: WorkspaceAgentLifecycle; + readonly observedAtMs: number; +} + +/** Reported by `WorkspaceStateObserver`. */ +export interface WorkspaceStateTransition { + /** Previous status, or `undefined` on the first observation. */ + readonly from: WorkspaceStatus | undefined; + readonly to: WorkspaceStatus; + readonly buildTransition: WorkspaceBuild["transition"]; + readonly buildReason: WorkspaceBuild["reason"]; + /** Time spent in the previous state; `undefined` on the first observation. */ + readonly durationMs: number | undefined; + /** Set only on the observation where a provisioner run resolves. */ + readonly buildDurationMs: number | undefined; +} + +/** Reported by `WorkspaceAgentObserver`. */ +export interface AgentStateTransition { + readonly agentName: string; + readonly status: { + readonly from: WorkspaceAgentStatus | undefined; + readonly to: WorkspaceAgentStatus; + }; + readonly lifecycleState: { + readonly from: WorkspaceAgentLifecycle | undefined; + readonly to: WorkspaceAgentLifecycle; + }; + /** Time since the previous observation of this agent; `undefined` on the first. */ + readonly durationMs: number | undefined; +} + +export interface RemovedAgent { + readonly name: string; +} + +export interface AgentObservation { + readonly transitions: AgentStateTransition[]; + readonly removed: RemovedAgent[]; +} + +/** + * Construct one per workspace. + */ +export class WorkspaceStateObserver { + private readonly tracker = new TransitionTracker( + (a, b) => + a.status === b.status && + a.buildTransition === b.buildTransition && + a.buildReason === b.buildReason, + ); + /** Set on first observation of a provisioning status; cleared when the build resolves. */ + private buildStartedAtMs: number | undefined; + + public observe(workspace: Workspace): WorkspaceStateTransition | undefined { + const { + status, + transition: buildTransition, + reason: buildReason, + } = workspace.latest_build; + const now = performance.now(); + const change = this.tracker.observe({ + status, + buildTransition, + buildReason, + observedAtMs: now, + }); + if (!change) { + return undefined; + } + const previous = change.from; + + const wasProvisioning = + previous && PROVISIONING_STATUSES.has(previous.status); + const isProvisioning = PROVISIONING_STATUSES.has(status); + let buildDurationMs: number | undefined; + if (isProvisioning) { + this.buildStartedAtMs ??= now; + } else { + if (wasProvisioning && this.buildStartedAtMs !== undefined) { + buildDurationMs = now - this.buildStartedAtMs; + } + this.buildStartedAtMs = undefined; + } + + return { + from: previous?.status, + to: status, + buildTransition, + buildReason, + durationMs: previous ? now - previous.observedAtMs : undefined, + buildDurationMs, + }; + } + + public reset(): void { + this.tracker.reset(); + this.buildStartedAtMs = undefined; + } +} + +/** + * Construct one per workspace. + */ +export class WorkspaceAgentObserver { + private readonly tracker = new TransitionTracker( + (a, b) => a.status === b.status && a.lifecycleState === b.lifecycleState, + ); + /** Last-seen agent name per ID, so removals can be reported by name. */ + private readonly names = new Map(); + + public observe(workspace: Workspace): AgentObservation { + const now = performance.now(); + const transitions: AgentStateTransition[] = []; + const seen = new Set(); + + for (const agent of extractAgents(workspace.latest_build.resources)) { + seen.add(agent.id); + this.names.set(agent.id, agent.name); + const change = this.tracker.observe( + { + status: agent.status, + lifecycleState: agent.lifecycle_state, + observedAtMs: now, + }, + agent.id, + ); + if (!change) { + continue; + } + const previous = change.from; + transitions.push({ + agentName: agent.name, + status: { from: previous?.status, to: agent.status }, + lifecycleState: { + from: previous?.lifecycleState, + to: agent.lifecycle_state, + }, + durationMs: previous ? now - previous.observedAtMs : undefined, + }); + } + + const removed: RemovedAgent[] = []; + for (const [id, name] of this.names) { + if (!seen.has(id)) { + removed.push({ name }); + this.names.delete(id); + this.tracker.reset(id); + } + } + + return { transitions, removed }; + } + + public reset(): void { + this.tracker.reset(); + this.names.clear(); + } +} diff --git a/src/workspace/workspaceMonitor.ts b/src/workspace/workspaceMonitor.ts index 09743ef027..ce1a0d3dab 100644 --- a/src/workspace/workspaceMonitor.ts +++ b/src/workspace/workspaceMonitor.ts @@ -10,8 +10,6 @@ import { INITIAL_STATE, recordAgentState, recordWorkspaceState, - WorkspaceAgentObserver, - WorkspaceStateObserver, } from "../instrumentation/workspace"; import { areNotificationsDisabled, @@ -20,6 +18,8 @@ import { import { createStatusBarItem } from "../util/statusBar"; import { vscodeProposed } from "../vscodeProposed"; +import { WorkspaceAgentObserver, WorkspaceStateObserver } from "./observers"; + import type { CoderApi } from "../api/coderApi"; import type { ServiceContainer } from "../core/container"; import type { ContextManager } from "../core/contextManager"; diff --git a/test/unit/instrumentation/workspace.test.ts b/test/unit/instrumentation/workspace.test.ts index d156e19f1b..ee9d26d2cf 100644 --- a/test/unit/instrumentation/workspace.test.ts +++ b/test/unit/instrumentation/workspace.test.ts @@ -4,27 +4,11 @@ import { WorkspaceUpdateCancelledError } from "@/api/updateParameters"; import { recordAgentState, recordWorkspaceState, - TransitionTracker, - WorkspaceAgentObserver, WorkspaceOperationTelemetry, - WorkspaceStateObserver, } from "@/instrumentation/workspace"; -import { - agent as createAgent, - resource as createResource, - workspace as createWorkspace, -} from "@repo/mocks"; - import { createTelemetryHarness } from "../../mocks/telemetry"; -import type { - Workspace, - WorkspaceAgent, - WorkspaceBuild, - WorkspaceStatus, -} from "coder/site/src/api/typesGenerated"; - import type { TelemetryService } from "@/telemetry/service"; const WORKSPACE_NAME = "testuser/test-workspace"; @@ -37,20 +21,6 @@ function setup(make: (svc: TelemetryService, name: string) => T) { const newOps = (svc: TelemetryService, name: string) => new WorkspaceOperationTelemetry(svc, name); -function workspaceWith( - status: WorkspaceStatus, - agents: WorkspaceAgent[] = [], - build: Partial = {}, -): Workspace { - return createWorkspace({ - latest_build: { - status, - resources: [createResource({ agents })], - ...build, - }, - }); -} - describe("WorkspaceOperationTelemetry", () => { it.each([ { @@ -211,188 +181,6 @@ describe("WorkspaceOperationTelemetry", () => { }); }); -describe("WorkspaceStateObserver", () => { - it("reports the first observation with from=undefined and no durations", () => { - const observer = new WorkspaceStateObserver(); - - const transition = observer.observe( - workspaceWith("running", [], { - transition: "start", - reason: "initiator", - }), - ); - - expect(transition).toMatchObject({ - from: undefined, - to: "running", - buildTransition: "start", - buildReason: "initiator", - durationMs: undefined, - buildDurationMs: undefined, - }); - }); - - it("returns undefined for a duplicate observation", () => { - const observer = new WorkspaceStateObserver(); - const ws = workspaceWith("running"); - - observer.observe(ws); - - expect(observer.observe(ws)).toBeUndefined(); - }); - - it("reports the prior status and a duration on a change", () => { - const observer = new WorkspaceStateObserver(); - - observer.observe(workspaceWith("starting")); - const transition = observer.observe(workspaceWith("running")); - - expect(transition).toMatchObject({ from: "starting", to: "running" }); - expect(transition?.durationMs).toEqual(expect.any(Number)); - }); - - it("reports a change when only transition or reason changes", () => { - const observer = new WorkspaceStateObserver(); - - observer.observe(workspaceWith("running", [], { transition: "start" })); - const transition = observer.observe( - workspaceWith("running", [], { transition: "stop" }), - ); - - expect(transition).toMatchObject({ - from: "running", - to: "running", - buildTransition: "stop", - }); - }); - - it("sets buildDurationMs only when a provisioner run resolves", () => { - const observer = new WorkspaceStateObserver(); - - const first = observer.observe(workspaceWith("stopped")); - const second = observer.observe(workspaceWith("starting")); - const third = observer.observe(workspaceWith("running")); - - expect(first?.buildDurationMs).toBeUndefined(); - expect(second?.buildDurationMs).toBeUndefined(); - expect(third?.buildDurationMs).toEqual(expect.any(Number)); - }); - - it("reset() makes the next observation report from=undefined again", () => { - const observer = new WorkspaceStateObserver(); - - observer.observe(workspaceWith("running")); - observer.reset(); - - expect(observer.observe(workspaceWith("running"))?.from).toBeUndefined(); - }); -}); - -describe("WorkspaceAgentObserver", () => { - it("reports the first observation of each agent with from=undefined", () => { - const observer = new WorkspaceAgentObserver(); - - const { transitions, removed } = observer.observe( - workspaceWith("running", [ - createAgent({ - name: "main", - status: "connecting", - lifecycle_state: "created", - }), - ]), - ); - - expect(removed).toEqual([]); - expect(transitions).toHaveLength(1); - expect(transitions[0]).toMatchObject({ - agentName: "main", - status: { from: undefined, to: "connecting" }, - lifecycleState: { from: undefined, to: "created" }, - durationMs: undefined, - }); - }); - - it("dedupes an unchanged agent", () => { - const observer = new WorkspaceAgentObserver(); - const ws = workspaceWith("running", [ - createAgent({ status: "connected", lifecycle_state: "ready" }), - ]); - - observer.observe(ws); - - expect(observer.observe(ws).transitions).toEqual([]); - }); - - it("tracks each agent independently", () => { - const observer = new WorkspaceAgentObserver(); - - observer.observe( - workspaceWith("running", [ - createAgent({ id: "a1", name: "first", status: "connected" }), - createAgent({ id: "a2", name: "second", status: "connecting" }), - ]), - ); - - const { transitions } = observer.observe( - workspaceWith("running", [ - createAgent({ id: "a1", name: "first", status: "connected" }), - createAgent({ id: "a2", name: "second", status: "connected" }), - ]), - ); - - expect(transitions).toHaveLength(1); - expect(transitions[0]).toMatchObject({ - agentName: "second", - status: { from: "connecting", to: "connected" }, - }); - }); - - it("reports an agent that disappears since the previous observation", () => { - const observer = new WorkspaceAgentObserver(); - - observer.observe( - workspaceWith("running", [ - createAgent({ id: "a1", name: "first" }), - createAgent({ id: "a2", name: "second" }), - ]), - ); - - const { removed } = observer.observe( - workspaceWith("running", [createAgent({ id: "a1", name: "first" })]), - ); - - expect(removed).toEqual([{ name: "second" }]); - }); - - it("treats a returning agent id as a fresh observation after removal", () => { - const observer = new WorkspaceAgentObserver(); - - observer.observe( - workspaceWith("running", [ - createAgent({ id: "a1", name: "first", status: "connected" }), - ]), - ); - observer.observe(workspaceWith("starting", [])); - const { transitions } = observer.observe( - workspaceWith("running", [ - createAgent({ id: "a1", name: "first", status: "connecting" }), - ]), - ); - - expect(transitions[0].status.from).toBeUndefined(); - }); - - it("reset() forgets all agents", () => { - const observer = new WorkspaceAgentObserver(); - const ws = workspaceWith("running", [createAgent({ status: "connected" })]); - - observer.observe(ws); - observer.reset(); - - expect(observer.observe(ws).transitions[0].status.from).toBeUndefined(); - }); -}); - describe("recordWorkspaceState", () => { it("emits workspace.state_transitioned with flat dotted keys", () => { const { sink, service } = createTelemetryHarness(); @@ -478,58 +266,3 @@ describe("recordAgentState", () => { expect(event.measurements.observed_duration_ms).toBeUndefined(); }); }); - -describe("TransitionTracker", () => { - const equals = (a: string, b: string) => a === b; - - it("reports `from: undefined` on the first observation", () => { - const tracker = new TransitionTracker(equals); - - expect(tracker.observe("a")).toEqual({ from: undefined }); - }); - - it("returns undefined when the value is unchanged", () => { - const tracker = new TransitionTracker(equals); - - tracker.observe("a"); - - expect(tracker.observe("a")).toBeUndefined(); - }); - - it("returns the prior value when the value changes", () => { - const tracker = new TransitionTracker(equals); - - tracker.observe("a"); - - expect(tracker.observe("b")).toEqual({ from: "a" }); - }); - - it("tracks keys independently", () => { - const tracker = new TransitionTracker(equals); - - expect(tracker.observe("a", "k1")).toEqual({ from: undefined }); - expect(tracker.observe("b", "k2")).toEqual({ from: undefined }); - expect(tracker.observe("a", "k1")).toBeUndefined(); - expect(tracker.observe("c", "k2")).toEqual({ from: "b" }); - }); - - it("forgets a single key on reset", () => { - const tracker = new TransitionTracker(equals); - - tracker.observe("a", "k1"); - tracker.reset("k1"); - - expect(tracker.observe("a", "k1")).toEqual({ from: undefined }); - }); - - it("forgets all keys on reset()", () => { - const tracker = new TransitionTracker(equals); - - tracker.observe("a", "k1"); - tracker.observe("b", "k2"); - tracker.reset(); - - expect(tracker.observe("a", "k1")).toEqual({ from: undefined }); - expect(tracker.observe("b", "k2")).toEqual({ from: undefined }); - }); -}); diff --git a/test/unit/workspace/observers.test.ts b/test/unit/workspace/observers.test.ts new file mode 100644 index 0000000000..1aed1d6822 --- /dev/null +++ b/test/unit/workspace/observers.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it } from "vitest"; + +import { + TransitionTracker, + WorkspaceAgentObserver, + WorkspaceStateObserver, +} from "@/workspace/observers"; + +import { + agent as createAgent, + resource as createResource, + workspace as createWorkspace, +} from "@repo/mocks"; + +import type { + Workspace, + WorkspaceAgent, + WorkspaceBuild, + WorkspaceStatus, +} from "coder/site/src/api/typesGenerated"; + +function workspaceWith( + status: WorkspaceStatus, + agents: WorkspaceAgent[] = [], + build: Partial = {}, +): Workspace { + return createWorkspace({ + latest_build: { + status, + resources: [createResource({ agents })], + ...build, + }, + }); +} + +describe("WorkspaceStateObserver", () => { + it("reports the first observation with from=undefined and no durations", () => { + const observer = new WorkspaceStateObserver(); + + const transition = observer.observe( + workspaceWith("running", [], { + transition: "start", + reason: "initiator", + }), + ); + + expect(transition).toMatchObject({ + from: undefined, + to: "running", + buildTransition: "start", + buildReason: "initiator", + durationMs: undefined, + buildDurationMs: undefined, + }); + }); + + it("returns undefined for a duplicate observation", () => { + const observer = new WorkspaceStateObserver(); + const ws = workspaceWith("running"); + + observer.observe(ws); + + expect(observer.observe(ws)).toBeUndefined(); + }); + + it("reports the prior status and a duration on a change", () => { + const observer = new WorkspaceStateObserver(); + + observer.observe(workspaceWith("starting")); + const transition = observer.observe(workspaceWith("running")); + + expect(transition).toMatchObject({ from: "starting", to: "running" }); + expect(transition?.durationMs).toEqual(expect.any(Number)); + }); + + it("reports a change when only transition or reason changes", () => { + const observer = new WorkspaceStateObserver(); + + observer.observe(workspaceWith("running", [], { transition: "start" })); + const transition = observer.observe( + workspaceWith("running", [], { transition: "stop" }), + ); + + expect(transition).toMatchObject({ + from: "running", + to: "running", + buildTransition: "stop", + }); + }); + + it("sets buildDurationMs only when a provisioner run resolves", () => { + const observer = new WorkspaceStateObserver(); + + const first = observer.observe(workspaceWith("stopped")); + const second = observer.observe(workspaceWith("starting")); + const third = observer.observe(workspaceWith("running")); + + expect(first?.buildDurationMs).toBeUndefined(); + expect(second?.buildDurationMs).toBeUndefined(); + expect(third?.buildDurationMs).toEqual(expect.any(Number)); + }); + + it("reset() makes the next observation report from=undefined again", () => { + const observer = new WorkspaceStateObserver(); + + observer.observe(workspaceWith("running")); + observer.reset(); + + expect(observer.observe(workspaceWith("running"))?.from).toBeUndefined(); + }); +}); + +describe("WorkspaceAgentObserver", () => { + it("reports the first observation of each agent with from=undefined", () => { + const observer = new WorkspaceAgentObserver(); + + const { transitions, removed } = observer.observe( + workspaceWith("running", [ + createAgent({ + name: "main", + status: "connecting", + lifecycle_state: "created", + }), + ]), + ); + + expect(removed).toEqual([]); + expect(transitions).toHaveLength(1); + expect(transitions[0]).toMatchObject({ + agentName: "main", + status: { from: undefined, to: "connecting" }, + lifecycleState: { from: undefined, to: "created" }, + durationMs: undefined, + }); + }); + + it("dedupes an unchanged agent", () => { + const observer = new WorkspaceAgentObserver(); + const ws = workspaceWith("running", [ + createAgent({ status: "connected", lifecycle_state: "ready" }), + ]); + + observer.observe(ws); + + expect(observer.observe(ws).transitions).toEqual([]); + }); + + it("tracks each agent independently", () => { + const observer = new WorkspaceAgentObserver(); + + observer.observe( + workspaceWith("running", [ + createAgent({ id: "a1", name: "first", status: "connected" }), + createAgent({ id: "a2", name: "second", status: "connecting" }), + ]), + ); + + const { transitions } = observer.observe( + workspaceWith("running", [ + createAgent({ id: "a1", name: "first", status: "connected" }), + createAgent({ id: "a2", name: "second", status: "connected" }), + ]), + ); + + expect(transitions).toHaveLength(1); + expect(transitions[0]).toMatchObject({ + agentName: "second", + status: { from: "connecting", to: "connected" }, + }); + }); + + it("reports an agent that disappears since the previous observation", () => { + const observer = new WorkspaceAgentObserver(); + + observer.observe( + workspaceWith("running", [ + createAgent({ id: "a1", name: "first" }), + createAgent({ id: "a2", name: "second" }), + ]), + ); + + const { removed } = observer.observe( + workspaceWith("running", [createAgent({ id: "a1", name: "first" })]), + ); + + expect(removed).toEqual([{ name: "second" }]); + }); + + it("treats a returning agent id as a fresh observation after removal", () => { + const observer = new WorkspaceAgentObserver(); + + observer.observe( + workspaceWith("running", [ + createAgent({ id: "a1", name: "first", status: "connected" }), + ]), + ); + observer.observe(workspaceWith("starting", [])); + const { transitions } = observer.observe( + workspaceWith("running", [ + createAgent({ id: "a1", name: "first", status: "connecting" }), + ]), + ); + + expect(transitions[0].status.from).toBeUndefined(); + }); + + it("reset() forgets all agents", () => { + const observer = new WorkspaceAgentObserver(); + const ws = workspaceWith("running", [createAgent({ status: "connected" })]); + + observer.observe(ws); + observer.reset(); + + expect(observer.observe(ws).transitions[0].status.from).toBeUndefined(); + }); +}); + +describe("TransitionTracker", () => { + const equals = (a: string, b: string) => a === b; + + it("reports `from: undefined` on the first observation", () => { + const tracker = new TransitionTracker(equals); + + expect(tracker.observe("a")).toEqual({ from: undefined }); + }); + + it("returns undefined when the value is unchanged", () => { + const tracker = new TransitionTracker(equals); + + tracker.observe("a"); + + expect(tracker.observe("a")).toBeUndefined(); + }); + + it("returns the prior value when the value changes", () => { + const tracker = new TransitionTracker(equals); + + tracker.observe("a"); + + expect(tracker.observe("b")).toEqual({ from: "a" }); + }); + + it("tracks keys independently", () => { + const tracker = new TransitionTracker(equals); + + expect(tracker.observe("a", "k1")).toEqual({ from: undefined }); + expect(tracker.observe("b", "k2")).toEqual({ from: undefined }); + expect(tracker.observe("a", "k1")).toBeUndefined(); + expect(tracker.observe("c", "k2")).toEqual({ from: "b" }); + }); + + it("forgets a single key on reset", () => { + const tracker = new TransitionTracker(equals); + + tracker.observe("a", "k1"); + tracker.reset("k1"); + + expect(tracker.observe("a", "k1")).toEqual({ from: undefined }); + }); + + it("forgets all keys on reset()", () => { + const tracker = new TransitionTracker(equals); + + tracker.observe("a", "k1"); + tracker.observe("b", "k2"); + tracker.reset(); + + expect(tracker.observe("a", "k1")).toEqual({ from: undefined }); + expect(tracker.observe("b", "k2")).toEqual({ from: undefined }); + }); +}); From 8400c8beb5888d505dd46dc6a44c81d034dfa9d6 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 19 Aug 2026 23:41:07 +0000 Subject: [PATCH 10/24] docs(events): lift the coalescing note to cover both state events --- src/instrumentation/EVENTS.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/instrumentation/EVENTS.md b/src/instrumentation/EVENTS.md index 6ae2b9f4bc..ef0814813d 100644 --- a/src/instrumentation/EVENTS.md +++ b/src/instrumentation/EVENTS.md @@ -537,6 +537,9 @@ Opening a workspace from any entry point. ### Logs +Both state-transition events are sampled from the workspace event stream, so +intermediate hops between samples may coalesce into a single transition. + #### `workspace.state_transitioned` | Attribute | Values | @@ -552,8 +555,6 @@ Opening a workspace from any entry point. Emitted for every agent in the workspace, deduped per agent, for the whole monitored session (not only the connected agent during connection setup). -Transitions are sampled from the workspace event stream, so intermediate hops -between samples may coalesce into a single transition. | Attribute | Values | | -------------------------------------------- | ---------------------------------------------------- | From 7919d7c257dad9f12234fda414dceaa3f532bcaf Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 20 Aug 2026 00:47:20 +0000 Subject: [PATCH 11/24] refactor(workspace): inline transition tracking into each observer Drop the generic TransitionTracker. It was only used by the two observers, and the shared wrapper forced the awkward "nothing changed" / "first observation" double-check. Each observer now keeps its own previous-state memory and compares inline, shaping its result directly. --- src/workspace/observers.ts | 91 +++++++-------------------- test/unit/workspace/observers.test.ts | 56 ----------------- 2 files changed, 24 insertions(+), 123 deletions(-) diff --git a/src/workspace/observers.ts b/src/workspace/observers.ts index 39f69ab44a..59abb9db0c 100644 --- a/src/workspace/observers.ts +++ b/src/workspace/observers.ts @@ -17,43 +17,6 @@ const PROVISIONING_STATUSES: ReadonlySet = new Set([ "deleting", ]); -/** - * Tracks the last observed value per key and reports the previous value each - * time it changes. Shared by the workspace/agent observers so transition - * detection lives in one place. - * - * A single tracked entity (e.g. a workspace) can omit the key; callers that - * track many entities (e.g. agents keyed by ID) pass a distinct key each time. - */ -export class TransitionTracker { - private readonly previous = new Map(); - - public constructor(private readonly equals: (a: T, b: T) => boolean) {} - - /** - * Record `next` for `key`. Returns `{ from }` when it differs from the last - * recorded value (`from` is `undefined` on the first observation), or - * `undefined` when unchanged. - */ - public observe(next: T, key = ""): { from: T | undefined } | undefined { - const prior = this.previous.get(key); - if (prior !== undefined && this.equals(prior, next)) { - return undefined; - } - this.previous.set(key, next); - return { from: prior }; - } - - /** Forget a single key, or all keys when `key` is omitted. */ - public reset(key?: string): void { - if (key === undefined) { - this.previous.clear(); - } else { - this.previous.delete(key); - } - } -} - interface ObservedWorkspaceState { readonly status: WorkspaceStatus; readonly buildTransition: WorkspaceBuild["transition"]; @@ -108,12 +71,7 @@ export interface AgentObservation { * Construct one per workspace. */ export class WorkspaceStateObserver { - private readonly tracker = new TransitionTracker( - (a, b) => - a.status === b.status && - a.buildTransition === b.buildTransition && - a.buildReason === b.buildReason, - ); + private previous: ObservedWorkspaceState | undefined; /** Set on first observation of a provisioning status; cleared when the build resolves. */ private buildStartedAtMs: number | undefined; @@ -124,16 +82,16 @@ export class WorkspaceStateObserver { reason: buildReason, } = workspace.latest_build; const now = performance.now(); - const change = this.tracker.observe({ - status, - buildTransition, - buildReason, - observedAtMs: now, - }); - if (!change) { + const previous = this.previous; + + if ( + previous?.status === status && + previous?.buildTransition === buildTransition && + previous?.buildReason === buildReason + ) { return undefined; } - const previous = change.from; + this.previous = { status, buildTransition, buildReason, observedAtMs: now }; const wasProvisioning = previous && PROVISIONING_STATUSES.has(previous.status); @@ -159,7 +117,7 @@ export class WorkspaceStateObserver { } public reset(): void { - this.tracker.reset(); + this.previous = undefined; this.buildStartedAtMs = undefined; } } @@ -168,9 +126,8 @@ export class WorkspaceStateObserver { * Construct one per workspace. */ export class WorkspaceAgentObserver { - private readonly tracker = new TransitionTracker( - (a, b) => a.status === b.status && a.lifecycleState === b.lifecycleState, - ); + /** Previous observed state per agent ID, tracked independently. */ + private readonly previous = new Map(); /** Last-seen agent name per ID, so removals can be reported by name. */ private readonly names = new Map(); @@ -182,18 +139,18 @@ export class WorkspaceAgentObserver { for (const agent of extractAgents(workspace.latest_build.resources)) { seen.add(agent.id); this.names.set(agent.id, agent.name); - const change = this.tracker.observe( - { - status: agent.status, - lifecycleState: agent.lifecycle_state, - observedAtMs: now, - }, - agent.id, - ); - if (!change) { + const previous = this.previous.get(agent.id); + if ( + previous?.status === agent.status && + previous?.lifecycleState === agent.lifecycle_state + ) { continue; } - const previous = change.from; + this.previous.set(agent.id, { + status: agent.status, + lifecycleState: agent.lifecycle_state, + observedAtMs: now, + }); transitions.push({ agentName: agent.name, status: { from: previous?.status, to: agent.status }, @@ -210,7 +167,7 @@ export class WorkspaceAgentObserver { if (!seen.has(id)) { removed.push({ name }); this.names.delete(id); - this.tracker.reset(id); + this.previous.delete(id); } } @@ -218,7 +175,7 @@ export class WorkspaceAgentObserver { } public reset(): void { - this.tracker.reset(); + this.previous.clear(); this.names.clear(); } } diff --git a/test/unit/workspace/observers.test.ts b/test/unit/workspace/observers.test.ts index 1aed1d6822..6e463cc0cd 100644 --- a/test/unit/workspace/observers.test.ts +++ b/test/unit/workspace/observers.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { - TransitionTracker, WorkspaceAgentObserver, WorkspaceStateObserver, } from "@/workspace/observers"; @@ -214,58 +213,3 @@ describe("WorkspaceAgentObserver", () => { expect(observer.observe(ws).transitions[0].status.from).toBeUndefined(); }); }); - -describe("TransitionTracker", () => { - const equals = (a: string, b: string) => a === b; - - it("reports `from: undefined` on the first observation", () => { - const tracker = new TransitionTracker(equals); - - expect(tracker.observe("a")).toEqual({ from: undefined }); - }); - - it("returns undefined when the value is unchanged", () => { - const tracker = new TransitionTracker(equals); - - tracker.observe("a"); - - expect(tracker.observe("a")).toBeUndefined(); - }); - - it("returns the prior value when the value changes", () => { - const tracker = new TransitionTracker(equals); - - tracker.observe("a"); - - expect(tracker.observe("b")).toEqual({ from: "a" }); - }); - - it("tracks keys independently", () => { - const tracker = new TransitionTracker(equals); - - expect(tracker.observe("a", "k1")).toEqual({ from: undefined }); - expect(tracker.observe("b", "k2")).toEqual({ from: undefined }); - expect(tracker.observe("a", "k1")).toBeUndefined(); - expect(tracker.observe("c", "k2")).toEqual({ from: "b" }); - }); - - it("forgets a single key on reset", () => { - const tracker = new TransitionTracker(equals); - - tracker.observe("a", "k1"); - tracker.reset("k1"); - - expect(tracker.observe("a", "k1")).toEqual({ from: undefined }); - }); - - it("forgets all keys on reset()", () => { - const tracker = new TransitionTracker(equals); - - tracker.observe("a", "k1"); - tracker.observe("b", "k2"); - tracker.reset(); - - expect(tracker.observe("a", "k1")).toEqual({ from: undefined }); - expect(tracker.observe("b", "k2")).toEqual({ from: undefined }); - }); -}); From 3a5ce546f5dfae494d82101ea12fe0b8fd8548f3 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 20 Aug 2026 13:06:04 -0700 Subject: [PATCH 12/24] chore: rm unused reset methods --- src/workspace/observers.ts | 10 ---------- test/unit/workspace/observers.test.ts | 19 ------------------- 2 files changed, 29 deletions(-) diff --git a/src/workspace/observers.ts b/src/workspace/observers.ts index 59abb9db0c..5a21dc90b7 100644 --- a/src/workspace/observers.ts +++ b/src/workspace/observers.ts @@ -115,11 +115,6 @@ export class WorkspaceStateObserver { buildDurationMs, }; } - - public reset(): void { - this.previous = undefined; - this.buildStartedAtMs = undefined; - } } /** @@ -173,9 +168,4 @@ export class WorkspaceAgentObserver { return { transitions, removed }; } - - public reset(): void { - this.previous.clear(); - this.names.clear(); - } } diff --git a/test/unit/workspace/observers.test.ts b/test/unit/workspace/observers.test.ts index 6e463cc0cd..e07a724922 100644 --- a/test/unit/workspace/observers.test.ts +++ b/test/unit/workspace/observers.test.ts @@ -98,15 +98,6 @@ describe("WorkspaceStateObserver", () => { expect(second?.buildDurationMs).toBeUndefined(); expect(third?.buildDurationMs).toEqual(expect.any(Number)); }); - - it("reset() makes the next observation report from=undefined again", () => { - const observer = new WorkspaceStateObserver(); - - observer.observe(workspaceWith("running")); - observer.reset(); - - expect(observer.observe(workspaceWith("running"))?.from).toBeUndefined(); - }); }); describe("WorkspaceAgentObserver", () => { @@ -202,14 +193,4 @@ describe("WorkspaceAgentObserver", () => { expect(transitions[0].status.from).toBeUndefined(); }); - - it("reset() forgets all agents", () => { - const observer = new WorkspaceAgentObserver(); - const ws = workspaceWith("running", [createAgent({ status: "connected" })]); - - observer.observe(ws); - observer.reset(); - - expect(observer.observe(ws).transitions[0].status.from).toBeUndefined(); - }); }); From 18bcfa219183eb745acede75b475988cbf5b3ad5 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 20 Aug 2026 13:24:50 -0700 Subject: [PATCH 13/24] refactor: flatten status/lifecycleState's from/to properties into AgentStateTransition --- src/workspace/observers.ts | 21 ++++++++------------- test/unit/workspace/observers.test.ts | 13 ++++++++----- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src/workspace/observers.ts b/src/workspace/observers.ts index 5a21dc90b7..524a044a8c 100644 --- a/src/workspace/observers.ts +++ b/src/workspace/observers.ts @@ -46,14 +46,10 @@ export interface WorkspaceStateTransition { /** Reported by `WorkspaceAgentObserver`. */ export interface AgentStateTransition { readonly agentName: string; - readonly status: { - readonly from: WorkspaceAgentStatus | undefined; - readonly to: WorkspaceAgentStatus; - }; - readonly lifecycleState: { - readonly from: WorkspaceAgentLifecycle | undefined; - readonly to: WorkspaceAgentLifecycle; - }; + readonly statusFrom: WorkspaceAgentStatus | undefined; + readonly statusTo: WorkspaceAgentStatus; + readonly lifecycleFrom: WorkspaceAgentLifecycle | undefined; + readonly lifecycleTo: WorkspaceAgentLifecycle; /** Time since the previous observation of this agent; `undefined` on the first. */ readonly durationMs: number | undefined; } @@ -148,11 +144,10 @@ export class WorkspaceAgentObserver { }); transitions.push({ agentName: agent.name, - status: { from: previous?.status, to: agent.status }, - lifecycleState: { - from: previous?.lifecycleState, - to: agent.lifecycle_state, - }, + statusFrom: previous?.status, + statusTo: agent.status, + lifecycleFrom: previous?.lifecycleState, + lifecycleTo: agent.lifecycle_state, durationMs: previous ? now - previous.observedAtMs : undefined, }); } diff --git a/test/unit/workspace/observers.test.ts b/test/unit/workspace/observers.test.ts index e07a724922..455e003ef3 100644 --- a/test/unit/workspace/observers.test.ts +++ b/test/unit/workspace/observers.test.ts @@ -101,7 +101,7 @@ describe("WorkspaceStateObserver", () => { }); describe("WorkspaceAgentObserver", () => { - it("reports the first observation of each agent with from=undefined", () => { + it("reports the first observation of each agent with statusFrom=undefined", () => { const observer = new WorkspaceAgentObserver(); const { transitions, removed } = observer.observe( @@ -118,8 +118,10 @@ describe("WorkspaceAgentObserver", () => { expect(transitions).toHaveLength(1); expect(transitions[0]).toMatchObject({ agentName: "main", - status: { from: undefined, to: "connecting" }, - lifecycleState: { from: undefined, to: "created" }, + statusFrom: undefined, + statusTo: "connecting", + lifecycleFrom: undefined, + lifecycleTo: "created", durationMs: undefined, }); }); @@ -155,7 +157,8 @@ describe("WorkspaceAgentObserver", () => { expect(transitions).toHaveLength(1); expect(transitions[0]).toMatchObject({ agentName: "second", - status: { from: "connecting", to: "connected" }, + statusFrom: "connecting", + statusTo: "connected", }); }); @@ -191,6 +194,6 @@ describe("WorkspaceAgentObserver", () => { ]), ); - expect(transitions[0].status.from).toBeUndefined(); + expect(transitions[0].statusFrom).toBeUndefined(); }); }); From 980b0993d41adf36467873b9aa93f006744915ab Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 20 Aug 2026 13:49:59 -0700 Subject: [PATCH 14/24] Revert "refactor: flatten status/lifecycleState's from/to properties into AgentStateTransition" This reverts commit 18bcfa219183eb745acede75b475988cbf5b3ad5. --- src/workspace/observers.ts | 21 +++++++++++++-------- test/unit/workspace/observers.test.ts | 13 +++++-------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/workspace/observers.ts b/src/workspace/observers.ts index 524a044a8c..5a21dc90b7 100644 --- a/src/workspace/observers.ts +++ b/src/workspace/observers.ts @@ -46,10 +46,14 @@ export interface WorkspaceStateTransition { /** Reported by `WorkspaceAgentObserver`. */ export interface AgentStateTransition { readonly agentName: string; - readonly statusFrom: WorkspaceAgentStatus | undefined; - readonly statusTo: WorkspaceAgentStatus; - readonly lifecycleFrom: WorkspaceAgentLifecycle | undefined; - readonly lifecycleTo: WorkspaceAgentLifecycle; + readonly status: { + readonly from: WorkspaceAgentStatus | undefined; + readonly to: WorkspaceAgentStatus; + }; + readonly lifecycleState: { + readonly from: WorkspaceAgentLifecycle | undefined; + readonly to: WorkspaceAgentLifecycle; + }; /** Time since the previous observation of this agent; `undefined` on the first. */ readonly durationMs: number | undefined; } @@ -144,10 +148,11 @@ export class WorkspaceAgentObserver { }); transitions.push({ agentName: agent.name, - statusFrom: previous?.status, - statusTo: agent.status, - lifecycleFrom: previous?.lifecycleState, - lifecycleTo: agent.lifecycle_state, + status: { from: previous?.status, to: agent.status }, + lifecycleState: { + from: previous?.lifecycleState, + to: agent.lifecycle_state, + }, durationMs: previous ? now - previous.observedAtMs : undefined, }); } diff --git a/test/unit/workspace/observers.test.ts b/test/unit/workspace/observers.test.ts index 455e003ef3..e07a724922 100644 --- a/test/unit/workspace/observers.test.ts +++ b/test/unit/workspace/observers.test.ts @@ -101,7 +101,7 @@ describe("WorkspaceStateObserver", () => { }); describe("WorkspaceAgentObserver", () => { - it("reports the first observation of each agent with statusFrom=undefined", () => { + it("reports the first observation of each agent with from=undefined", () => { const observer = new WorkspaceAgentObserver(); const { transitions, removed } = observer.observe( @@ -118,10 +118,8 @@ describe("WorkspaceAgentObserver", () => { expect(transitions).toHaveLength(1); expect(transitions[0]).toMatchObject({ agentName: "main", - statusFrom: undefined, - statusTo: "connecting", - lifecycleFrom: undefined, - lifecycleTo: "created", + status: { from: undefined, to: "connecting" }, + lifecycleState: { from: undefined, to: "created" }, durationMs: undefined, }); }); @@ -157,8 +155,7 @@ describe("WorkspaceAgentObserver", () => { expect(transitions).toHaveLength(1); expect(transitions[0]).toMatchObject({ agentName: "second", - statusFrom: "connecting", - statusTo: "connected", + status: { from: "connecting", to: "connected" }, }); }); @@ -194,6 +191,6 @@ describe("WorkspaceAgentObserver", () => { ]), ); - expect(transitions[0].statusFrom).toBeUndefined(); + expect(transitions[0].status.from).toBeUndefined(); }); }); From 4162366c6b4f243e21953b4b012ef2380e21cd32 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 20 Aug 2026 13:52:21 -0700 Subject: [PATCH 15/24] refactor: flatten status/lifecycle from/to properties into AgentStateTransition without breaking tests --- src/instrumentation/workspace.ts | 8 ++++---- src/workspace/observers.ts | 21 ++++++++------------- src/workspace/workspaceMonitor.ts | 10 +++++----- test/unit/instrumentation/workspace.test.ts | 12 ++++++++---- test/unit/workspace/observers.test.ts | 13 ++++++++----- 5 files changed, 33 insertions(+), 31 deletions(-) diff --git a/src/instrumentation/workspace.ts b/src/instrumentation/workspace.ts index e3f8749d84..a093a3aed3 100644 --- a/src/instrumentation/workspace.ts +++ b/src/instrumentation/workspace.ts @@ -59,10 +59,10 @@ export function recordAgentState( { workspace_name: workspaceName, agent_name: transition.agentName, - "status.from": transition.status.from ?? INITIAL_STATE, - "status.to": transition.status.to, - "lifecycle_state.from": transition.lifecycleState.from ?? INITIAL_STATE, - "lifecycle_state.to": transition.lifecycleState.to, + "status.from": transition.statusFrom ?? INITIAL_STATE, + "status.to": transition.statusTo, + "lifecycle_state.from": transition.lifecycleFrom ?? INITIAL_STATE, + "lifecycle_state.to": transition.lifecycleTo, }, transition.durationMs !== undefined ? { observed_duration_ms: transition.durationMs } diff --git a/src/workspace/observers.ts b/src/workspace/observers.ts index 5a21dc90b7..524a044a8c 100644 --- a/src/workspace/observers.ts +++ b/src/workspace/observers.ts @@ -46,14 +46,10 @@ export interface WorkspaceStateTransition { /** Reported by `WorkspaceAgentObserver`. */ export interface AgentStateTransition { readonly agentName: string; - readonly status: { - readonly from: WorkspaceAgentStatus | undefined; - readonly to: WorkspaceAgentStatus; - }; - readonly lifecycleState: { - readonly from: WorkspaceAgentLifecycle | undefined; - readonly to: WorkspaceAgentLifecycle; - }; + readonly statusFrom: WorkspaceAgentStatus | undefined; + readonly statusTo: WorkspaceAgentStatus; + readonly lifecycleFrom: WorkspaceAgentLifecycle | undefined; + readonly lifecycleTo: WorkspaceAgentLifecycle; /** Time since the previous observation of this agent; `undefined` on the first. */ readonly durationMs: number | undefined; } @@ -148,11 +144,10 @@ export class WorkspaceAgentObserver { }); transitions.push({ agentName: agent.name, - status: { from: previous?.status, to: agent.status }, - lifecycleState: { - from: previous?.lifecycleState, - to: agent.lifecycle_state, - }, + statusFrom: previous?.status, + statusTo: agent.status, + lifecycleFrom: previous?.lifecycleState, + lifecycleTo: agent.lifecycle_state, durationMs: previous ? now - previous.observedAtMs : undefined, }); } diff --git a/src/workspace/workspaceMonitor.ts b/src/workspace/workspaceMonitor.ts index ce1a0d3dab..91915a4b00 100644 --- a/src/workspace/workspaceMonitor.ts +++ b/src/workspace/workspaceMonitor.ts @@ -168,16 +168,16 @@ export class WorkspaceMonitor implements vscode.Disposable { const { transitions, removed } = this.agentObserver.observe(workspace); for (const transition of transitions) { const verb = - transition.status.from === undefined + transition.statusFrom === undefined ? "state observed" : "state changed"; this.logger.info( `Workspace ${this.name} agent ${transition.agentName} ${verb}`, { - statusFrom: transition.status.from ?? INITIAL_STATE, - statusTo: transition.status.to, - lifecycleFrom: transition.lifecycleState.from ?? INITIAL_STATE, - lifecycleTo: transition.lifecycleState.to, + statusFrom: transition.statusFrom ?? INITIAL_STATE, + statusTo: transition.statusTo, + lifecycleFrom: transition.lifecycleFrom ?? INITIAL_STATE, + lifecycleTo: transition.lifecycleTo, }, ); recordAgentState(this.telemetry, this.name, transition); diff --git a/test/unit/instrumentation/workspace.test.ts b/test/unit/instrumentation/workspace.test.ts index ee9d26d2cf..87fee54b86 100644 --- a/test/unit/instrumentation/workspace.test.ts +++ b/test/unit/instrumentation/workspace.test.ts @@ -233,8 +233,10 @@ describe("recordAgentState", () => { recordAgentState(service, WORKSPACE_NAME, { agentName: "main", - status: { from: "connecting", to: "connected" }, - lifecycleState: { from: "starting", to: "ready" }, + statusFrom: "connecting", + statusTo: "connected", + lifecycleFrom: "starting", + lifecycleTo: "ready", durationMs: 800, }); @@ -255,8 +257,10 @@ describe("recordAgentState", () => { recordAgentState(service, WORKSPACE_NAME, { agentName: "main", - status: { from: undefined, to: "connecting" }, - lifecycleState: { from: undefined, to: "created" }, + statusFrom: undefined, + statusTo: "connecting", + lifecycleFrom: undefined, + lifecycleTo: "created", durationMs: undefined, }); diff --git a/test/unit/workspace/observers.test.ts b/test/unit/workspace/observers.test.ts index e07a724922..455e003ef3 100644 --- a/test/unit/workspace/observers.test.ts +++ b/test/unit/workspace/observers.test.ts @@ -101,7 +101,7 @@ describe("WorkspaceStateObserver", () => { }); describe("WorkspaceAgentObserver", () => { - it("reports the first observation of each agent with from=undefined", () => { + it("reports the first observation of each agent with statusFrom=undefined", () => { const observer = new WorkspaceAgentObserver(); const { transitions, removed } = observer.observe( @@ -118,8 +118,10 @@ describe("WorkspaceAgentObserver", () => { expect(transitions).toHaveLength(1); expect(transitions[0]).toMatchObject({ agentName: "main", - status: { from: undefined, to: "connecting" }, - lifecycleState: { from: undefined, to: "created" }, + statusFrom: undefined, + statusTo: "connecting", + lifecycleFrom: undefined, + lifecycleTo: "created", durationMs: undefined, }); }); @@ -155,7 +157,8 @@ describe("WorkspaceAgentObserver", () => { expect(transitions).toHaveLength(1); expect(transitions[0]).toMatchObject({ agentName: "second", - status: { from: "connecting", to: "connected" }, + statusFrom: "connecting", + statusTo: "connected", }); }); @@ -191,6 +194,6 @@ describe("WorkspaceAgentObserver", () => { ]), ); - expect(transitions[0].status.from).toBeUndefined(); + expect(transitions[0].statusFrom).toBeUndefined(); }); }); From 8bf51f5b3a13f33ff38d233f8a248ecdcf285a18 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 20 Aug 2026 13:58:51 -0700 Subject: [PATCH 16/24] refactor: move INITIAL_STATE from workspace.ts to observers.ts --- src/instrumentation/workspace.ts | 12 +++++------- src/workspace/observers.ts | 3 +++ src/workspace/workspaceMonitor.ts | 7 +++++-- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/instrumentation/workspace.ts b/src/instrumentation/workspace.ts index a093a3aed3..d25783e9c2 100644 --- a/src/instrumentation/workspace.ts +++ b/src/instrumentation/workspace.ts @@ -1,16 +1,14 @@ import { WorkspaceUpdateCancelledError } from "../api/updateParameters"; +import { + INITIAL_STATE, + type AgentStateTransition, + type WorkspaceStateTransition, +} from "../workspace/observers"; import type { WorkspaceBuildParameter } from "coder/site/src/api/typesGenerated"; import type { TelemetryReporter } from "../telemetry/reporter"; import type { Span } from "../telemetry/span"; -import type { - AgentStateTransition, - WorkspaceStateTransition, -} from "../workspace/observers"; - -/** Sentinel for `from*` before any state is observed. `"unknown"` is a real server-reported value, so avoid it. */ -export const INITIAL_STATE = "none"; export type WorkspacePromptAction = "start" | "update"; export type WorkspaceUpdatePrompt = "parameters" | "confirmation"; diff --git a/src/workspace/observers.ts b/src/workspace/observers.ts index 524a044a8c..c87e86b175 100644 --- a/src/workspace/observers.ts +++ b/src/workspace/observers.ts @@ -30,6 +30,9 @@ interface ObservedAgentState { readonly observedAtMs: number; } +/** Sentinel for `from*` before any state is observed. `"unknown"` is a real server-reported value, so avoid it. */ +export const INITIAL_STATE = "none"; + /** Reported by `WorkspaceStateObserver`. */ export interface WorkspaceStateTransition { /** Previous status, or `undefined` on the first observation. */ diff --git a/src/workspace/workspaceMonitor.ts b/src/workspace/workspaceMonitor.ts index 91915a4b00..4f29af1c00 100644 --- a/src/workspace/workspaceMonitor.ts +++ b/src/workspace/workspaceMonitor.ts @@ -7,7 +7,6 @@ import * as vscode from "vscode"; import { createWorkspaceIdentifier, errToStr } from "../api/api-helper"; import { - INITIAL_STATE, recordAgentState, recordWorkspaceState, } from "../instrumentation/workspace"; @@ -18,7 +17,11 @@ import { import { createStatusBarItem } from "../util/statusBar"; import { vscodeProposed } from "../vscodeProposed"; -import { WorkspaceAgentObserver, WorkspaceStateObserver } from "./observers"; +import { + INITIAL_STATE, + WorkspaceAgentObserver, + WorkspaceStateObserver, +} from "./observers"; import type { CoderApi } from "../api/coderApi"; import type { ServiceContainer } from "../core/container"; From 797069b1ebe309ce015670fb95f96dee1bc53e1c Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 20 Aug 2026 14:01:25 -0700 Subject: [PATCH 17/24] refactor: extract stateVerb function --- src/workspace/workspaceMonitor.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/workspace/workspaceMonitor.ts b/src/workspace/workspaceMonitor.ts index 4f29af1c00..d02922e93b 100644 --- a/src/workspace/workspaceMonitor.ts +++ b/src/workspace/workspaceMonitor.ts @@ -30,6 +30,9 @@ import type { Logger } from "../logging/logger"; import type { TelemetryReporter } from "../telemetry/reporter"; import type { UnidirectionalStream } from "../websocket/eventStreamConnection"; +const stateVerb = (from: string | undefined) => + from === undefined ? "state observed" : "state changed"; + /** * Monitor a single workspace using a WebSocket for events like shutdown and deletion. * Notify the user about relevant changes and update contexts as needed. The @@ -156,8 +159,7 @@ export class WorkspaceMonitor implements vscode.Disposable { if (!transition) { return; } - const verb = - transition.from === undefined ? "state observed" : "state changed"; + const verb = stateVerb(transition.from); this.logger.info(`Workspace ${this.name} ${verb}`, { from: transition.from ?? INITIAL_STATE, to: transition.to, @@ -170,10 +172,7 @@ export class WorkspaceMonitor implements vscode.Disposable { private observeAgents(workspace: Workspace) { const { transitions, removed } = this.agentObserver.observe(workspace); for (const transition of transitions) { - const verb = - transition.statusFrom === undefined - ? "state observed" - : "state changed"; + const verb = stateVerb(transition.statusFrom); this.logger.info( `Workspace ${this.name} agent ${transition.agentName} ${verb}`, { From 82ae2ddbc2202e62bf978134cd6864050a3656ad Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 20 Aug 2026 14:17:49 -0700 Subject: [PATCH 18/24] refactor: track name in ObservedAgentState --- src/workspace/observers.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/workspace/observers.ts b/src/workspace/observers.ts index c87e86b175..c751330ebd 100644 --- a/src/workspace/observers.ts +++ b/src/workspace/observers.ts @@ -25,6 +25,7 @@ interface ObservedWorkspaceState { } interface ObservedAgentState { + readonly name: string; readonly status: WorkspaceAgentStatus; readonly lifecycleState: WorkspaceAgentLifecycle; readonly observedAtMs: number; @@ -122,8 +123,6 @@ export class WorkspaceStateObserver { export class WorkspaceAgentObserver { /** Previous observed state per agent ID, tracked independently. */ private readonly previous = new Map(); - /** Last-seen agent name per ID, so removals can be reported by name. */ - private readonly names = new Map(); public observe(workspace: Workspace): AgentObservation { const now = performance.now(); @@ -132,7 +131,6 @@ export class WorkspaceAgentObserver { for (const agent of extractAgents(workspace.latest_build.resources)) { seen.add(agent.id); - this.names.set(agent.id, agent.name); const previous = this.previous.get(agent.id); if ( previous?.status === agent.status && @@ -141,6 +139,7 @@ export class WorkspaceAgentObserver { continue; } this.previous.set(agent.id, { + name: agent.name, status: agent.status, lifecycleState: agent.lifecycle_state, observedAtMs: now, @@ -156,10 +155,9 @@ export class WorkspaceAgentObserver { } const removed: RemovedAgent[] = []; - for (const [id, name] of this.names) { + for (const [id, { name }] of this.previous) { if (!seen.has(id)) { removed.push({ name }); - this.names.delete(id); this.previous.delete(id); } } From fb6ce762e7a1ed4bb6e25ff297bf9f7717c492e7 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 20 Aug 2026 21:28:43 +0000 Subject: [PATCH 19/24] refactor(workspace): report removed agents as a name array Drop the single-field RemovedAgent wrapper. AgentObservation.removed is now string[], so the monitor iterates names directly and the observer test asserts ["second"]. --- src/workspace/observers.ts | 11 ++++------- src/workspace/workspaceMonitor.ts | 4 ++-- test/unit/workspace/observers.test.ts | 2 +- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/workspace/observers.ts b/src/workspace/observers.ts index c751330ebd..82ea906ed9 100644 --- a/src/workspace/observers.ts +++ b/src/workspace/observers.ts @@ -58,13 +58,10 @@ export interface AgentStateTransition { readonly durationMs: number | undefined; } -export interface RemovedAgent { - readonly name: string; -} - export interface AgentObservation { readonly transitions: AgentStateTransition[]; - readonly removed: RemovedAgent[]; + /** Names of agents present on a prior observation and absent now. */ + readonly removed: string[]; } /** @@ -154,10 +151,10 @@ export class WorkspaceAgentObserver { }); } - const removed: RemovedAgent[] = []; + const removed: string[] = []; for (const [id, { name }] of this.previous) { if (!seen.has(id)) { - removed.push({ name }); + removed.push(name); this.previous.delete(id); } } diff --git a/src/workspace/workspaceMonitor.ts b/src/workspace/workspaceMonitor.ts index d02922e93b..debcf6c545 100644 --- a/src/workspace/workspaceMonitor.ts +++ b/src/workspace/workspaceMonitor.ts @@ -184,8 +184,8 @@ export class WorkspaceMonitor implements vscode.Disposable { ); recordAgentState(this.telemetry, this.name, transition); } - for (const agent of removed) { - this.logger.info(`Workspace ${this.name} agent ${agent.name} removed`); + for (const name of removed) { + this.logger.info(`Workspace ${this.name} agent ${name} removed`); } } diff --git a/test/unit/workspace/observers.test.ts b/test/unit/workspace/observers.test.ts index 455e003ef3..574208548c 100644 --- a/test/unit/workspace/observers.test.ts +++ b/test/unit/workspace/observers.test.ts @@ -176,7 +176,7 @@ describe("WorkspaceAgentObserver", () => { workspaceWith("running", [createAgent({ id: "a1", name: "first" })]), ); - expect(removed).toEqual([{ name: "second" }]); + expect(removed).toEqual(["second"]); }); it("treats a returning agent id as a fresh observation after removal", () => { From cffeadb6c5545cf1aacd263e2a019f7ee9bb6381 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 20 Aug 2026 21:40:07 +0000 Subject: [PATCH 20/24] refactor(workspace): simplify build-duration bookkeeping buildStartedAtMs is set only while provisioning and cleared otherwise, so wasProvisioning is exactly buildStartedAtMs !== undefined. Drop both locals and fold the check into the else branch. --- src/workspace/observers.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/workspace/observers.ts b/src/workspace/observers.ts index 82ea906ed9..5b06641d0f 100644 --- a/src/workspace/observers.ts +++ b/src/workspace/observers.ts @@ -90,16 +90,11 @@ export class WorkspaceStateObserver { } this.previous = { status, buildTransition, buildReason, observedAtMs: now }; - const wasProvisioning = - previous && PROVISIONING_STATUSES.has(previous.status); - const isProvisioning = PROVISIONING_STATUSES.has(status); let buildDurationMs: number | undefined; - if (isProvisioning) { + if (PROVISIONING_STATUSES.has(status)) { this.buildStartedAtMs ??= now; - } else { - if (wasProvisioning && this.buildStartedAtMs !== undefined) { - buildDurationMs = now - this.buildStartedAtMs; - } + } else if (this.buildStartedAtMs !== undefined) { + buildDurationMs = now - this.buildStartedAtMs; this.buildStartedAtMs = undefined; } From 951628b36f2cd886df3d5a59db60a569c64916ef Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 20 Aug 2026 21:41:19 +0000 Subject: [PATCH 21/24] test(workspace): trim monitor tests to observer wiring Dedup, build-duration resolution, and duration measurements are observer behavior already covered in observers.test.ts. Keep the monitor telemetry test to wiring only: the initial state records once and a later change records again. --- test/unit/workspace/workspaceMonitor.test.ts | 108 ++----------------- 1 file changed, 7 insertions(+), 101 deletions(-) diff --git a/test/unit/workspace/workspaceMonitor.test.ts b/test/unit/workspace/workspaceMonitor.test.ts index e3ed7c778d..60f5c4305f 100644 --- a/test/unit/workspace/workspaceMonitor.test.ts +++ b/test/unit/workspace/workspaceMonitor.test.ts @@ -85,16 +85,10 @@ describe("WorkspaceMonitor", () => { } describe("telemetry", () => { - const buildSinkContext = () => { + it("records the initial state, then again on a change", async () => { enableLocalTelemetry(); - return { - stream: new MockEventStream(), - sink: new TestSink(), - }; - }; - - it("emits initial state plus subsequent transitions with duration", async () => { - const { stream, sink } = buildSinkContext(); + const sink = new TestSink(); + const stream = new MockEventStream(); await setup( stream, @@ -102,13 +96,7 @@ describe("WorkspaceMonitor", () => { createWorkspace({ latest_build: { status: "running" } }), ); stream.pushMessage( - workspaceEvent({ - latest_build: { - status: "stopping", - transition: "stop", - reason: "autostop", - }, - }), + workspaceEvent({ latest_build: { status: "stopping" } }), ); const events = sink.eventsNamed("workspace.state_transitioned"); @@ -117,93 +105,11 @@ describe("WorkspaceMonitor", () => { from: "none", to: "running", }); - expect(events[0].measurements.observed_duration_ms).toBeUndefined(); - expect(events[1]).toMatchObject({ - properties: { - from: "running", - to: "stopping", - "build.transition": "stop", - "build.reason": "autostop", - }, - measurements: { observed_duration_ms: expect.any(Number) }, + expect(events[1].properties).toMatchObject({ + from: "running", + to: "stopping", }); }); - - it("dedupes on (status, build transition, build reason); re-emits when only reason changes", async () => { - const { stream, sink } = buildSinkContext(); - - await setup( - stream, - createTestTelemetryService(sink), - createWorkspace({ - latest_build: { - status: "stopping", - transition: "stop", - reason: "autostop", - }, - }), - ); - // Same status with a different reason: must not dedupe. - stream.pushMessage( - workspaceEvent({ - latest_build: { - status: "stopping", - transition: "stop", - reason: "initiator", - }, - }), - ); - // Identical to the previous: deduped. - stream.pushMessage( - workspaceEvent({ - latest_build: { - status: "stopping", - transition: "stop", - reason: "initiator", - }, - }), - ); - - const reasons = sink - .eventsNamed("workspace.state_transitioned") - .map((e) => e.properties["build.reason"]); - expect(reasons).toEqual(["autostop", "initiator"]); - }); - - it("emits observed_build_duration_ms on the event that resolves a build run", async () => { - const { stream, sink } = buildSinkContext(); - - await setup( - stream, - createTestTelemetryService(sink), - createWorkspace({ latest_build: { status: "pending" } }), - ); - stream.pushMessage( - workspaceEvent({ latest_build: { status: "starting" } }), - ); - stream.pushMessage( - workspaceEvent({ latest_build: { status: "running" } }), - ); - stream.pushMessage( - workspaceEvent({ latest_build: { status: "stopping" } }), - ); - - const events = sink.eventsNamed("workspace.state_transitioned"); - // pending and starting are intermediate; only running carries observed_build_duration_ms. - expect(events.map((e) => e.properties.to)).toEqual([ - "pending", - "starting", - "running", - "stopping", - ]); - expect(events[0].measurements.observed_build_duration_ms).toBeUndefined(); - expect(events[1].measurements.observed_build_duration_ms).toBeUndefined(); - expect(events[2].measurements.observed_build_duration_ms).toEqual( - expect.any(Number), - ); - // Next build cycle resets; stopping doesn't carry the previous duration. - expect(events[3].measurements.observed_build_duration_ms).toBeUndefined(); - }); }); describe("state logging", () => { From 83e1767e645a7d777145bec227a7a977e49a5541 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 20 Aug 2026 21:41:42 +0000 Subject: [PATCH 22/24] test(workspace): cover agent lifecycle-only transitions Mirror the workspace observer's transition/reason-only case: same status, new lifecycle_state reports one transition. --- test/unit/workspace/observers.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/test/unit/workspace/observers.test.ts b/test/unit/workspace/observers.test.ts index 574208548c..4c55490e35 100644 --- a/test/unit/workspace/observers.test.ts +++ b/test/unit/workspace/observers.test.ts @@ -137,6 +137,29 @@ describe("WorkspaceAgentObserver", () => { expect(observer.observe(ws).transitions).toEqual([]); }); + it("reports a change when only the lifecycle state changes", () => { + const observer = new WorkspaceAgentObserver(); + + observer.observe( + workspaceWith("running", [ + createAgent({ status: "connected", lifecycle_state: "starting" }), + ]), + ); + const { transitions } = observer.observe( + workspaceWith("running", [ + createAgent({ status: "connected", lifecycle_state: "ready" }), + ]), + ); + + expect(transitions).toHaveLength(1); + expect(transitions[0]).toMatchObject({ + statusFrom: "connected", + statusTo: "connected", + lifecycleFrom: "starting", + lifecycleTo: "ready", + }); + }); + it("tracks each agent independently", () => { const observer = new WorkspaceAgentObserver(); From 399ba9756c4d1cf4f12edaad8515ecdfc0d57f3c Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 20 Aug 2026 21:45:08 +0000 Subject: [PATCH 23/24] test(workspace): share the workspaceWith builder observers.test.ts and workspaceMonitor.test.ts had the same workspace builder. Move it to test/mocks/testHelpers, typed to take WorkspaceAgent[], and use it from both. --- test/mocks/testHelpers.ts | 26 ++++++++++++ test/unit/workspace/observers.test.ts | 29 ++----------- test/unit/workspace/workspaceMonitor.test.ts | 43 +++++++++----------- 3 files changed, 48 insertions(+), 50 deletions(-) diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index e886d0a120..914cc45f4c 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -11,6 +11,11 @@ import * as vscode from "vscode"; import { SessionStore, type SessionData } from "@/deployment/sessionStore"; +import { + resource as createResource, + workspace as createWorkspace, +} from "@repo/mocks"; + import { createTestTelemetryService } from "./telemetry"; import { window as vscodeWindow } from "./vscode.runtime"; @@ -18,6 +23,9 @@ import type { Experiment, User, Workspace, + WorkspaceAgent, + WorkspaceBuild, + WorkspaceStatus, } from "coder/site/src/api/typesGenerated"; import type { WebSocketEventType } from "coder/site/src/utils/OneWayWebSocket"; import type { IncomingMessage } from "node:http"; @@ -923,6 +931,24 @@ export class MockOAuthInterceptor { readonly dispose = vi.fn(); } +/** + * Build a workspace in `status` with the given agents on its latest build. + * `build` overrides other `latest_build` fields. + */ +export function workspaceWith( + status: WorkspaceStatus, + agents: WorkspaceAgent[] = [], + build: Partial = {}, +): Workspace { + return createWorkspace({ + latest_build: { + status, + resources: [createResource({ agents })], + ...build, + }, + }); +} + /** * Create a mock User for testing. */ diff --git a/test/unit/workspace/observers.test.ts b/test/unit/workspace/observers.test.ts index 4c55490e35..bae54e9338 100644 --- a/test/unit/workspace/observers.test.ts +++ b/test/unit/workspace/observers.test.ts @@ -5,32 +5,9 @@ import { WorkspaceStateObserver, } from "@/workspace/observers"; -import { - agent as createAgent, - resource as createResource, - workspace as createWorkspace, -} from "@repo/mocks"; - -import type { - Workspace, - WorkspaceAgent, - WorkspaceBuild, - WorkspaceStatus, -} from "coder/site/src/api/typesGenerated"; - -function workspaceWith( - status: WorkspaceStatus, - agents: WorkspaceAgent[] = [], - build: Partial = {}, -): Workspace { - return createWorkspace({ - latest_build: { - status, - resources: [createResource({ agents })], - ...build, - }, - }); -} +import { agent as createAgent } from "@repo/mocks"; + +import { workspaceWith } from "../../mocks/testHelpers"; describe("WorkspaceStateObserver", () => { it("reports the first observation with from=undefined and no durations", () => { diff --git a/test/unit/workspace/workspaceMonitor.test.ts b/test/unit/workspace/workspaceMonitor.test.ts index 60f5c4305f..587e40455c 100644 --- a/test/unit/workspace/workspaceMonitor.test.ts +++ b/test/unit/workspace/workspaceMonitor.test.ts @@ -5,7 +5,6 @@ import { WorkspaceMonitor } from "@/workspace/workspaceMonitor"; import { agent as createAgent, - resource as createResource, workspace as createWorkspace, } from "@repo/mocks"; @@ -21,6 +20,7 @@ import { MockEventStream, MockStatusBarItem, createMockLogger, + workspaceWith, } from "../../mocks/testHelpers"; import type { @@ -156,25 +156,18 @@ describe("WorkspaceMonitor", () => { }); describe("agent state", () => { - const agentWorkspace = ( - agents: Array[0]>, - status = "running" as const, - ) => - createWorkspace({ - latest_build: { - status, - resources: [createResource({ agents: agents.map(createAgent) })], - }, - }); - it("logs the initial agent state and records telemetry", async () => { enableLocalTelemetry(); const sink = new TestSink(); const { logger } = await setup( new MockEventStream(), createTestTelemetryService(sink), - agentWorkspace([ - { name: "main", status: "connecting", lifecycle_state: "created" }, + workspaceWith("running", [ + createAgent({ + name: "main", + status: "connecting", + lifecycle_state: "created", + }), ]), ); @@ -198,17 +191,17 @@ describe("WorkspaceMonitor", () => { const { stream } = await setup( new MockEventStream(), createTestTelemetryService(sink), - agentWorkspace([ - { id: "a1", name: "first", status: "connected" }, - { id: "a2", name: "second", status: "connecting" }, + workspaceWith("running", [ + createAgent({ id: "a1", name: "first", status: "connected" }), + createAgent({ id: "a2", name: "second", status: "connecting" }), ]), ); stream.pushMessage({ type: "data", - data: agentWorkspace([ - { id: "a1", name: "first", status: "connected" }, - { id: "a2", name: "second", status: "connected" }, + data: workspaceWith("running", [ + createAgent({ id: "a1", name: "first", status: "connected" }), + createAgent({ id: "a2", name: "second", status: "connected" }), ]), }); @@ -226,15 +219,17 @@ describe("WorkspaceMonitor", () => { const { stream, logger } = await setup( new MockEventStream(), undefined, - agentWorkspace([ - { id: "a1", name: "first" }, - { id: "a2", name: "second" }, + workspaceWith("running", [ + createAgent({ id: "a1", name: "first" }), + createAgent({ id: "a2", name: "second" }), ]), ); stream.pushMessage({ type: "data", - data: agentWorkspace([{ id: "a1", name: "first" }]), + data: workspaceWith("running", [ + createAgent({ id: "a1", name: "first" }), + ]), }); expect(logger.info).toHaveBeenCalledWith( From 2fac67782a788f4abb045a74a9ec10ee6f06640d Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 20 Aug 2026 21:46:12 +0000 Subject: [PATCH 24/24] test(workspace): reuse workspaceEvent for agent stream messages The agent tests hand-rolled { type: "data", data }. Widen workspaceEvent to accept a full Workspace and use it at both call sites. --- test/unit/workspace/workspaceMonitor.test.ts | 28 ++++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/test/unit/workspace/workspaceMonitor.test.ts b/test/unit/workspace/workspaceMonitor.test.ts index 587e40455c..29fd685848 100644 --- a/test/unit/workspace/workspaceMonitor.test.ts +++ b/test/unit/workspace/workspaceMonitor.test.ts @@ -32,7 +32,7 @@ import type { CoderApi } from "@/api/coderApi"; import type { TelemetryService } from "@/telemetry/service"; function workspaceEvent( - overrides?: Parameters[0], + overrides?: Parameters[0] | Workspace, ): ServerSentEvent { return { type: "data", data: createWorkspace(overrides) }; } @@ -197,13 +197,14 @@ describe("WorkspaceMonitor", () => { ]), ); - stream.pushMessage({ - type: "data", - data: workspaceWith("running", [ - createAgent({ id: "a1", name: "first", status: "connected" }), - createAgent({ id: "a2", name: "second", status: "connected" }), - ]), - }); + stream.pushMessage( + workspaceEvent( + workspaceWith("running", [ + createAgent({ id: "a1", name: "first", status: "connected" }), + createAgent({ id: "a2", name: "second", status: "connected" }), + ]), + ), + ); const events = sink.eventsNamed("workspace.agent.state_transitioned"); // Two initial observations plus the one "second" transition. @@ -225,12 +226,11 @@ describe("WorkspaceMonitor", () => { ]), ); - stream.pushMessage({ - type: "data", - data: workspaceWith("running", [ - createAgent({ id: "a1", name: "first" }), - ]), - }); + stream.pushMessage( + workspaceEvent( + workspaceWith("running", [createAgent({ id: "a1", name: "first" })]), + ), + ); expect(logger.info).toHaveBeenCalledWith( expect.stringContaining("agent second removed"),