diff --git a/src/api/workspace.ts b/src/api/workspace.ts index 3c23e28f..b1bf7831 100644 --- a/src/api/workspace.ts +++ b/src/api/workspace.ts @@ -1,8 +1,8 @@ import { type Api } from "coder/site/src/api/api"; import { - type WorkspaceAgentLog, type ProvisionerJobLog, type Workspace, + type WorkspaceAgentLog, } from "coder/site/src/api/typesGenerated"; import { spawn } from "node:child_process"; import * as vscode from "vscode"; @@ -115,16 +115,18 @@ export async function startWorkspace(ctx: CliContext): Promise { /** * Update a workspace to the latest template version. * - * Uses `coder update` when the CLI supports it (>= 2.24). - * Falls back to the REST API: stop, wait, then updateWorkspaceVersion. + * Uses an interactive VS Code task so the CLI can handle parameter prompts. */ export async function updateWorkspace(ctx: CliContext): Promise { - if (ctx.featureSet.cliUpdate) { - await runCliCommand(ctx, ["update"]); - return ctx.restClient.getWorkspace(ctx.workspace.id); + if (!ctx.featureSet.cliUpdate) { + return updateWorkspaceVersion(ctx); } - // REST API fallback for older CLIs. + await runCliCommandInTask(ctx, ["update"]); + return ctx.restClient.getWorkspace(ctx.workspace.id); +} + +async function updateWorkspaceVersion(ctx: CliContext): Promise { if (ctx.workspace.latest_build.status === "running") { ctx.write("Stopping workspace for update...\r\n"); const stopBuild = await ctx.restClient.stopWorkspace(ctx.workspace.id); @@ -139,6 +141,71 @@ export async function updateWorkspace(ctx: CliContext): Promise { return ctx.restClient.getWorkspace(ctx.workspace.id); } +function runCliCommandInTask(ctx: CliContext, args: string[]): Promise { + return new Promise((resolve, reject) => { + const workspaceIdentifier = createWorkspaceIdentifier(ctx.workspace); + const fullArgs = [ + ...getGlobalShellFlags(vscode.workspace.getConfiguration(), ctx.auth), + ...args, + workspaceIdentifier, + ]; + const cmd = `${escapeCommandArg(ctx.binPath)} ${fullArgs.join(" ")}`; + const task = new vscode.Task( + { type: "coder", command: args[0] }, + vscode.TaskScope.Workspace, + `Coder: ${args[0]} ${workspaceIdentifier}`, + "coder", + new vscode.ShellExecution(cmd), + [], + ); + task.presentationOptions = { + reveal: vscode.TaskRevealKind.Always, + panel: vscode.TaskPanelKind.Dedicated, + focus: true, + clear: true, + }; + + let execution: vscode.TaskExecution | undefined; + let settled = false; + + function finish(exitCode: number | undefined) { + if (settled) return; + settled = true; + disposable.dispose(); + if (exitCode === 0) { + resolve(); + } else { + reject( + new Error(`"${fullArgs.join(" ")}" exited with code ${exitCode}`), + ); + } + } + + function fail(error: unknown) { + if (settled) return; + settled = true; + disposable.dispose(); + reject(error instanceof Error ? error : new Error(String(error))); + } + + const disposable = vscode.tasks.onDidEndTaskProcess((event) => { + if (execution) { + if (event.execution !== execution) { + return; + } + } else if (event.execution.task !== task) { + return; + } + + finish(event.exitCode); + }); + + vscode.tasks.executeTask(task).then((taskExecution) => { + execution = taskExecution; + }, fail); + }); +} + /** * Streams build logs in real-time via a callback. * Returns the websocket for lifecycle management. diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 0f8714d5..898191f5 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -406,7 +406,7 @@ export class Remote { ); if (isReady) { subscription.dispose(); - resolve(w); + resolve(stateMachine.getWorkspace() ?? w); return; } } catch (error: unknown) { diff --git a/src/remote/workspaceStateMachine.ts b/src/remote/workspaceStateMachine.ts index b533ffb8..24653981 100644 --- a/src/remote/workspaceStateMachine.ts +++ b/src/remote/workspaceStateMachine.ts @@ -35,6 +35,7 @@ export class WorkspaceStateMachine implements vscode.Disposable { private readonly agentLogStream = new LazyStream(); private agent: { id: string; name: string } | undefined; + private workspace: Workspace | undefined; constructor( private readonly parts: AuthorityParts, @@ -56,13 +57,19 @@ export class WorkspaceStateMachine implements vscode.Disposable { workspace: Workspace, progress: vscode.Progress<{ message?: string }>, ): Promise { + this.workspace = workspace; const workspaceName = createWorkspaceIdentifier(workspace); switch (workspace.latest_build.status) { case "running": this.buildLogStream.close(); if (this.startupMode === "update") { - await this.triggerUpdate(workspace, workspaceName, progress); + workspace = await this.triggerUpdate( + workspace, + workspaceName, + progress, + ); + this.workspace = workspace; // Agent IDs may have changed after an update. this.agent = undefined; } @@ -84,7 +91,15 @@ export class WorkspaceStateMachine implements vscode.Disposable { } if (this.startupMode === "update") { - await this.triggerUpdate(workspace, workspaceName, progress); + workspace = await this.triggerUpdate( + workspace, + workspaceName, + progress, + ); + this.workspace = workspace; + if (workspace.latest_build.status === "running") { + break; + } } else { await this.triggerStart(workspace, workspaceName, progress); } @@ -252,16 +267,19 @@ export class WorkspaceStateMachine implements vscode.Disposable { workspace: Workspace, workspaceName: string, progress: vscode.Progress<{ message?: string }>, - ): Promise { + ): Promise { progress.report({ message: `updating ${workspaceName}...` }); this.logger.info(`Updating ${workspaceName}`, { mode: this.startupMode, status: workspace.latest_build.status, }); - await updateWorkspace(this.buildCliContext(workspace)); + const updatedWorkspace = await updateWorkspace( + this.buildCliContext(workspace), + ); // Downgrade so subsequent transitions don't re-trigger the update. this.startupMode = "start"; this.logger.info(`${workspaceName} update initiated`); + return updatedWorkspace; } private async confirmStartOrUpdate( @@ -286,6 +304,10 @@ export class WorkspaceStateMachine implements vscode.Disposable { return this.agent?.id; } + public getWorkspace(): Workspace | undefined { + return this.workspace; + } + dispose(): void { this.buildLogStream.close(); this.agentLogStream.close(); diff --git a/test/mocks/vscode.runtime.ts b/test/mocks/vscode.runtime.ts index 8def001e..88f349fb 100644 --- a/test/mocks/vscode.runtime.ts +++ b/test/mocks/vscode.runtime.ts @@ -47,6 +47,9 @@ export const InputBoxValidationSeverity = E({ Warning: 2, Error: 3, }); +export const TaskScope = E({ Global: 1, Workspace: 2 }); +export const TaskRevealKind = E({ Always: 1, Silent: 2, Never: 3 }); +export const TaskPanelKind = E({ Shared: 1, Dedicated: 2, New: 3 }); export class MarkdownString { value: string; @@ -65,6 +68,25 @@ export class ThemeColor { } } +export class ShellExecution { + commandLine: string | undefined; + constructor(commandLine: string) { + this.commandLine = commandLine; + } +} + +export class Task { + presentationOptions: unknown; + constructor( + public definition: unknown, + public scope: unknown, + public name: string, + public source: string, + public execution?: unknown, + public problemMatchers: string[] = [], + ) {} +} + export class Uri { constructor( public scheme: string, @@ -114,6 +136,7 @@ export class EventEmitter { const onDidChangeConfiguration = new EventEmitter(); const onDidChangeWorkspaceFolders = new EventEmitter(); const onDidChangeActiveColorTheme = new EventEmitter(); +const onDidEndTaskProcess = new EventEmitter(); export const window = { activeColorTheme: { kind: ColorThemeKind.Dark }, @@ -148,6 +171,12 @@ export const commands = { executeCommand: vi.fn(), }; +export const tasks = { + executeTask: vi.fn(), + onDidEndTaskProcess: onDidEndTaskProcess.event, + __fireDidEndTaskProcess: (e: unknown) => onDidEndTaskProcess.fire(e), +}; + export const workspace = { getConfiguration: vi.fn(), // your helpers override this workspaceFolders: [] as unknown[], @@ -197,12 +226,18 @@ const vscode = { ExtensionMode, UIKind, InputBoxValidationSeverity, + TaskScope, + TaskRevealKind, + TaskPanelKind, + ShellExecution, + Task, Uri, EventEmitter, MarkdownString, ThemeColor, window, commands, + tasks, workspace, env, extensions, diff --git a/test/unit/api/workspace.test.ts b/test/unit/api/workspace.test.ts index 182ee390..0419ba94 100644 --- a/test/unit/api/workspace.test.ts +++ b/test/unit/api/workspace.test.ts @@ -1,8 +1,38 @@ import { describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; -import { LazyStream } from "@/api/workspace"; +import { LazyStream, updateWorkspace } from "@/api/workspace"; +import { type FeatureSet } from "@/featureSet"; import { type UnidirectionalStream } from "@/websocket/eventStreamConnection"; +import { workspace as createWorkspace } from "../../mocks/workspace"; + +import type { Api } from "coder/site/src/api/api"; +import type { + ProvisionerJob, + Workspace, + WorkspaceBuild, +} from "coder/site/src/api/typesGenerated"; + +type UpdateWorkspaceContext = Parameters[0]; +interface UpdateRestClient { + getWorkspace: (workspaceId: string) => Promise; + stopWorkspace: (workspaceId: string) => Promise; + updateWorkspaceVersion: (workspace: Workspace) => Promise; + waitForBuild: (build: WorkspaceBuild) => Promise; +} + +const featureSet: FeatureSet = { + vscodessh: true, + proxyLogDirectory: true, + wildcardSSH: true, + buildReason: true, + cliUpdate: true, + keyringAuth: true, + keyringTokenRead: true, + supportBundle: true, +}; + function mockStream(): UnidirectionalStream { return { url: "ws://test", @@ -28,6 +58,96 @@ function deferredFactory() { }; } +function createBuild( + workspace: Workspace, + overrides: Partial = {}, +): WorkspaceBuild { + return { + ...workspace.latest_build, + ...overrides, + }; +} + +function succeededJob(workspace: Workspace): ProvisionerJob { + return { ...workspace.latest_build.job, status: "succeeded" }; +} + +function setupUpdateWorkspace({ + workspace = createWorkspace({ + outdated: true, + latest_build: { status: "running", transition: "start" }, + }), + finalWorkspace = createWorkspace({ + outdated: false, + latest_build: { status: "running" }, + }), + featureSetOverrides = {}, +}: { + workspace?: Workspace; + finalWorkspace?: Workspace; + featureSetOverrides?: Partial; +} = {}) { + vi.mocked(vscode.tasks.executeTask).mockReset(); + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn((_key: string, defaultValue: unknown) => defaultValue), + } as never); + + const stopBuild = createBuild(workspace, { + id: "stop-build", + build_number: 2, + transition: "stop", + status: "stopped", + }); + const startBuild = createBuild(workspace, { + id: "start-build", + build_number: 3, + transition: "start", + status: "running", + }); + const restClient: UpdateRestClient = { + getWorkspace: vi + .fn<(workspaceId: string) => Promise>() + .mockResolvedValue(finalWorkspace), + stopWorkspace: vi + .fn<(workspaceId: string) => Promise>() + .mockResolvedValue(stopBuild), + updateWorkspaceVersion: vi + .fn<(workspace: Workspace) => Promise>() + .mockResolvedValue(startBuild), + waitForBuild: vi + .fn<(build: WorkspaceBuild) => Promise>() + .mockResolvedValue(succeededJob(workspace)), + }; + const write = vi.fn<(data: string) => void>(); + const ctx: UpdateWorkspaceContext = { + restClient: restClient as Api, + auth: { mode: "url", url: "https://test.coder.com" }, + binPath: "/usr/bin/coder", + workspace, + write, + featureSet: { ...featureSet, ...featureSetOverrides }, + }; + return { ctx, restClient, startBuild, stopBuild, write, finalWorkspace }; +} + +function setupTaskExecution(exitCode = 0) { + const mockedTasks = vscode.tasks as typeof vscode.tasks & { + __fireDidEndTaskProcess: (event: unknown) => void; + }; + const execution = { + task: undefined as unknown as vscode.Task, + terminate: vi.fn(), + }; + vi.mocked(vscode.tasks.executeTask).mockImplementation((task) => { + execution.task = task; + queueMicrotask(() => { + mockedTasks.__fireDidEndTaskProcess({ execution, exitCode }); + }); + return Promise.resolve(execution); + }); + return execution; +} + describe("LazyStream", () => { it("opens once and ignores subsequent calls", async () => { const factory: StreamFactory = vi.fn().mockResolvedValue(mockStream()); @@ -86,3 +206,95 @@ describe("LazyStream", () => { expect(factory2).toHaveBeenCalledOnce(); }); }); + +describe("updateWorkspace", () => { + it("runs coder update in an interactive task", async () => { + const { ctx, restClient, finalWorkspace } = setupUpdateWorkspace(); + setupTaskExecution(); + + await expect(updateWorkspace(ctx)).resolves.toBe(finalWorkspace); + + expect(vscode.tasks.executeTask).toHaveBeenCalledOnce(); + const task = vi.mocked(vscode.tasks.executeTask).mock.calls[0][0]; + expect(task.name).toBe("Coder: update testuser/test-workspace"); + expect(task.presentationOptions).toMatchObject({ + reveal: vscode.TaskRevealKind.Always, + panel: vscode.TaskPanelKind.Dedicated, + focus: true, + clear: true, + }); + expect(task.execution).toMatchObject({ + commandLine: + '"/usr/bin/coder" --url "https://test.coder.com" update testuser/test-workspace', + }); + expect(restClient.stopWorkspace).not.toHaveBeenCalled(); + expect(restClient.updateWorkspaceVersion).not.toHaveBeenCalled(); + expect(restClient.getWorkspace).toHaveBeenCalledWith(ctx.workspace.id); + }); + + it("rejects when the update task exits non-zero", async () => { + const { ctx, restClient } = setupUpdateWorkspace(); + setupTaskExecution(1); + + await expect(updateWorkspace(ctx)).rejects.toThrow("exited with code 1"); + + expect(restClient.getWorkspace).not.toHaveBeenCalled(); + }); + + it("falls back to the API update path when coder update is unsupported", async () => { + const workspace = createWorkspace({ + outdated: true, + latest_build: { status: "running", transition: "start" }, + }); + const { ctx, restClient, finalWorkspace, startBuild, stopBuild, write } = + setupUpdateWorkspace({ + workspace, + featureSetOverrides: { cliUpdate: false }, + }); + + await expect(updateWorkspace(ctx)).resolves.toBe(finalWorkspace); + + expect(vscode.tasks.executeTask).not.toHaveBeenCalled(); + expect(write).toHaveBeenCalledWith("Stopping workspace for update...\r\n"); + expect(restClient.stopWorkspace).toHaveBeenCalledWith(workspace.id); + expect(restClient.waitForBuild).toHaveBeenCalledWith(stopBuild); + expect(write).toHaveBeenCalledWith( + "Starting workspace with updated template...\r\n", + ); + expect(restClient.updateWorkspaceVersion).toHaveBeenCalledWith(workspace); + expect(restClient.getWorkspace).toHaveBeenCalledWith(workspace.id); + expect(startBuild.transition).toBe("start"); + }); + + it("does not stop before API fallback update when the workspace is not running", async () => { + const workspace = createWorkspace({ + outdated: true, + latest_build: { status: "stopped", transition: "stop" }, + }); + const { ctx, restClient } = setupUpdateWorkspace({ + workspace, + featureSetOverrides: { cliUpdate: false }, + }); + + await updateWorkspace(ctx); + + expect(restClient.stopWorkspace).not.toHaveBeenCalled(); + expect(restClient.waitForBuild).not.toHaveBeenCalled(); + expect(restClient.updateWorkspaceVersion).toHaveBeenCalledWith(workspace); + }); + + it("throws before update when the API fallback stop is canceled", async () => { + const { ctx, restClient } = setupUpdateWorkspace({ + featureSetOverrides: { cliUpdate: false }, + }); + vi.mocked(restClient.waitForBuild).mockResolvedValueOnce({ + ...ctx.workspace.latest_build.job, + status: "canceled", + }); + + await expect(updateWorkspace(ctx)).rejects.toThrow( + "Workspace update canceled during stop", + ); + expect(restClient.updateWorkspaceVersion).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/remote/workspaceStateMachine.test.ts b/test/unit/remote/workspaceStateMachine.test.ts index 24840124..8b7b390b 100644 --- a/test/unit/remote/workspaceStateMachine.test.ts +++ b/test/unit/remote/workspaceStateMachine.test.ts @@ -93,6 +93,9 @@ describe("WorkspaceStateMachine", () => { beforeEach(() => { vi.clearAllMocks(); MockTerminalOutputChannel.lastInstance = undefined; + vi.mocked(updateWorkspace).mockImplementation((ctx) => + Promise.resolve(ctx.workspace), + ); vi.mocked(maybeAskAgent).mockImplementation((agents) => Promise.resolve(agents.length > 0 ? agents[0] : undefined), ); @@ -181,6 +184,16 @@ describe("WorkspaceStateMachine", () => { expect(updateWorkspace).toHaveBeenCalledOnce(); }); + it("falls through to the agent check after an update completes", async () => { + vi.mocked(updateWorkspace).mockResolvedValueOnce(runningWorkspace()); + const { sm, progress } = setup("update"); + const ws = createWorkspace({ latest_build: { status: "stopped" } }); + + expect(await sm.processWorkspace(ws, progress)).toBe(true); + expect(updateWorkspace).toHaveBeenCalledOnce(); + expect(sm.getWorkspace()?.latest_build.status).toBe("running"); + }); + it("prompts user when mode is 'none' and user picks 'Start'", async () => { const { sm, progress, userInteraction } = setup("none"); userInteraction.setResponse(CONFIRM_MESSAGE, "Start");