diff --git a/CHANGELOG.md b/CHANGELOG.md index 22e719ed22..bba8c57551 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,18 @@ - The experimental Workspaces panel lists workspace agents, metadata and app status. Updated times expose the full date and time on hover or keyboard focus. +- The experimental Workspaces panel can start, stop, restart, update, retry + and delete workspaces, and open their sharing settings in the dashboard. + Each action checks the workspace's current status first, explains when it no + longer applies, and halts if the Coder session changes. Restart keeps the + workspace's template version. Updates on Coder 2.36+ do not require a local + CLI. A spinner marks a workspace while its action runs, and its other actions + are disabled until it finishes. + +### Fixed + +- Updating a workspace halts if the confirmed operation or template version + changes while the update is being prepared. ## [v1.17.2](https://github.com/coder/vscode-coder/releases/tag/v1.17.2) 2026-10-28 diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 711137cb6b..59926f2e52 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -32,4 +32,5 @@ export type { // Workspaces types and API export * from "./workspaces/types"; +export * from "./workspaces/actions"; export { WorkspacesApi } from "./workspaces/api"; diff --git a/packages/shared/src/workspaces/actions.ts b/packages/shared/src/workspaces/actions.ts new file mode 100644 index 0000000000..e54e8046b4 --- /dev/null +++ b/packages/shared/src/workspaces/actions.ts @@ -0,0 +1,66 @@ +import type { + Workspace, + WorkspaceStatus, +} from "coder/site/src/api/typesGenerated"; + +export type WorkspaceAction = + | "start" + | "stop" + | "restart" + | "update" + | "delete"; + +const BUSY_STATUSES: ReadonlySet = new Set([ + "pending", + "starting", + "stopping", + "canceling", + "deleting", +]); + +export function isWorkspaceBusy(workspace: Workspace): boolean { + return BUSY_STATUSES.has(workspace.latest_build.status); +} + +/** Actions allowed now, in display order, as in the dashboard's `abilitiesByWorkspaceStatus`. */ +export function workspaceActions( + workspace: Workspace, +): readonly WorkspaceAction[] { + if ( + isWorkspaceBusy(workspace) || + workspace.latest_build.status === "deleted" + ) { + return []; + } + // `coder start` reactivates a dormant workspace before starting it. + if (workspace.dormant_at) { + return ["start", "delete"]; + } + if (workspace.latest_build.has_external_agent) { + return ["delete"]; + } + const { status, transition } = workspace.latest_build; + // A policy that forces the active version leaves only an update. + const updateOnly = + workspace.outdated && + (workspace.template_require_active_version || + workspace.automatic_updates === "always"); + const actions: WorkspaceAction[] = workspace.outdated ? ["update"] : []; + if (status === "running") { + actions.push("stop"); + if (!updateOnly) { + actions.push("restart"); + } + } else if (status === "stopped" && !updateOnly) { + actions.push("start"); + } else if (status === "canceled") { + actions.push("start", "stop"); + } else if ( + status === "failed" && + (transition === "start" || transition === "stop") + ) { + actions.push(transition); + } + actions.push("delete"); + return actions; +} diff --git a/packages/shared/src/workspaces/api.ts b/packages/shared/src/workspaces/api.ts index f6219c58f4..bf083ab44f 100644 --- a/packages/shared/src/workspaces/api.ts +++ b/packages/shared/src/workspaces/api.ts @@ -12,6 +12,7 @@ import type { SetFilterParams, ViewInDashboardParams, WatchAgentsParams, + WorkspaceActionParams, WorkspacesState, } from "./types"; @@ -25,6 +26,8 @@ export const WorkspacesApi = { login: defineCommand("login"), openWorkspace: defineCommand("openWorkspace"), viewInDashboard: defineCommand("viewInDashboard"), + runWorkspaceAction: + defineCommand("runWorkspaceAction"), refresh: defineCommand("refresh"), setFilter: defineCommand("setFilter"), /** Watch metadata for these agents only, so idle rows cost nothing */ diff --git a/packages/shared/src/workspaces/types.ts b/packages/shared/src/workspaces/types.ts index 907e7248cd..857209fd99 100644 --- a/packages/shared/src/workspaces/types.ts +++ b/packages/shared/src/workspaces/types.ts @@ -4,13 +4,15 @@ import type { WorkspaceAgentMetadata, } from "coder/site/src/api/typesGenerated"; +import type { WorkspaceAction } from "./actions"; + // Re-export SDK types for convenience export type { Workspace, WorkspaceAgent, WorkspaceAgentMetadata }; export type WorkspaceFilter = "mine" | "shared" | "all"; /** A workspace page in the dashboard, opened in the browser. */ -export type DashboardPage = "workspace" | "settings"; +export type DashboardPage = "workspace" | "settings" | "sharing"; /** What the panel may offer for the current session. */ export interface WorkspacesCapabilities { @@ -54,6 +56,8 @@ export interface WorkspacesState { readonly listedAt?: number; /** Listed workspace IDs associated with Agent chats. */ readonly agentWorkspaceIds?: readonly string[]; + /** Keyed by workspace ID: the action running there, prompts included. */ + readonly pendingActions?: Readonly>; } export interface OpenWorkspaceParams { @@ -67,6 +71,11 @@ export interface ViewInDashboardParams { readonly page: DashboardPage; } +export interface WorkspaceActionParams { + readonly workspaceId: string; + readonly action: WorkspaceAction; +} + export interface SetFilterParams { readonly filter: WorkspaceFilter; } diff --git a/packages/workspaces/src/components/WorkspaceActions.tsx b/packages/workspaces/src/components/WorkspaceActions.tsx index 5e3f8a106e..83f1f89831 100644 --- a/packages/workspaces/src/components/WorkspaceActions.tsx +++ b/packages/workspaces/src/components/WorkspaceActions.tsx @@ -1,3 +1,9 @@ +import { + workspaceActions, + type Workspace, + type WorkspaceAction, + type WorkspaceAgent, +} from "@repo/shared"; import { DropdownMenu, DropdownMenuContent, @@ -6,33 +12,118 @@ import { IconButton, MenuItem, MenuSeparator, + type CodiconName, } from "@repo/ui"; -import type { Workspace } from "@repo/shared"; - import type { WorkspaceCommands } from "./WorkspacesPanel"; +interface MenuEntry { + readonly label: string; + readonly icon: CodiconName; +} + +const ACTION_ENTRIES: Readonly< + Record, MenuEntry> +> = { + update: { label: "Update", icon: "arrow-circle-up" }, + start: { label: "Start", icon: "play" }, + stop: { label: "Stop", icon: "primitive-square" }, + restart: { label: "Restart", icon: "debug-restart" }, +}; + +const RETRY_ENTRY: MenuEntry = { label: "Retry", icon: "debug-restart" }; + +function WorkspaceMenu({ + workspace, + pendingAction, + api, +}: { + workspace: Workspace; + pendingAction?: WorkspaceAction; + api: WorkspaceCommands; +}) { + const workspaceId = workspace.id; + const actions = workspaceActions(workspace); + const busy = pendingAction !== undefined; + // A failed build is retried by repeating its transition. + const failed = workspace.latest_build.status === "failed"; + const lifecycle = actions.filter((action) => action !== "delete"); + return ( + <> + {lifecycle.map((action) => { + const { label, icon } = + failed && action !== "update" ? RETRY_ENTRY : ACTION_ENTRIES[action]; + return ( + api.runWorkspaceAction({ workspaceId, action })} + > + + {label} + + ); + })} + {lifecycle.length > 0 && } + api.viewInDashboard({ workspaceId, page: "sharing" })} + > + + Share + + api.viewInDashboard({ workspaceId, page: "settings" })} + > + + Settings + + + + + Ping + + + + Speed Test + + + + Support Bundle + + {actions.includes("delete") && ( + <> + + + api.runWorkspaceAction({ workspaceId, action: "delete" }) + } + > + + Delete... + + + )} + + ); +} + +/** Row actions for a workspace, or for `agent` when given. */ export function WorkspaceActions({ - workspaceId, - name, - agentId, - status, + workspace, + agent, + pendingAction, api, }: { - workspaceId: string; - name: string; - agentId?: string; - status?: Workspace["latest_build"]["status"]; - api: Pick; + workspace: Workspace; + agent?: WorkspaceAgent; + /** Disables the workspace's lifecycle actions while one runs. */ + pendingAction?: WorkspaceAction; + api: WorkspaceCommands; }) { - const lifecycleActions = - status === "running" - ? ["Stop", "Restart"] - : status === "stopped" - ? ["Start"] - : status === "failed" || status === "canceled" - ? ["Retry"] - : []; + const workspaceId = workspace.id; + const name = (agent ? agent.name : workspace.name) || "Unknown"; return ( <> - api.openWorkspace({ workspaceId, ...(agentId ? { agentId } : {}) }) + api.openWorkspace({ + workspaceId, + ...(agent ? { agentId: agent.id } : {}), + }) } /> api.viewInDashboard({ workspaceId, page: "workspace" })} /> - {!agentId && ( + {!agent && ( - {lifecycleActions.map((label) => ( - - - {label} - - ))} - {lifecycleActions.length > 0 && } - - - Share - - - api.viewInDashboard({ workspaceId, page: "settings" }) - } - > - - Settings - - - - - Ping - - - - Speed Test - - - - Support Bundle - - - - - Delete... - + )} diff --git a/packages/workspaces/src/components/WorkspaceRow.tsx b/packages/workspaces/src/components/WorkspaceRow.tsx index 038fe392ec..2af39ca7ef 100644 --- a/packages/workspaces/src/components/WorkspaceRow.tsx +++ b/packages/workspaces/src/components/WorkspaceRow.tsx @@ -1,9 +1,17 @@ -import { StatusPill } from "@repo/ui"; +import { Spinner, StatusPill } from "@repo/ui"; import { RowHover } from "./RowHover"; import { formatStatus, StatusDot } from "./StatusDot"; -import type { Workspace } from "@repo/shared"; +import type { Workspace, WorkspaceAction } from "@repo/shared"; + +const PENDING_LABELS: Readonly> = { + start: "Starting", + stop: "Stopping", + restart: "Restarting", + update: "Updating", + delete: "Deleting", +}; /** Health is only meaningful for the build that is running. */ function workspaceStatus(workspace: Workspace): string { @@ -16,13 +24,17 @@ function workspaceStatus(workspace: Workspace): string { export function WorkspaceHover({ workspace, showOwner, + pendingAction, }: { workspace: Workspace; showOwner: boolean; + pendingAction?: WorkspaceAction; }) { const template = workspace.template_display_name || workspace.template_name || "Unknown"; - const status = formatStatus(workspaceStatus(workspace)); + const status = pendingAction + ? PENDING_LABELS[pendingAction] + : formatStatus(workspaceStatus(workspace)); return ( - + {pendingAction ? ( + + ) : ( + + )} {workspace.name || "Unknown"} {hasAgentChat && ( diff --git a/packages/workspaces/src/components/WorkspacesPanel.stories.tsx b/packages/workspaces/src/components/WorkspacesPanel.stories.tsx index 2afd7f690c..269ab1c28d 100644 --- a/packages/workspaces/src/components/WorkspacesPanel.stories.tsx +++ b/packages/workspaces/src/components/WorkspacesPanel.stories.tsx @@ -18,6 +18,7 @@ const WORKSPACES: readonly Workspace[] = [ id: "ws-frontend", name: "frontend", owner_name: "alice", + outdated: true, agents: [ agent({ id: "agent-main", @@ -161,6 +162,7 @@ const api = { login: fn(), openWorkspace: fn(), viewInDashboard: fn(), + runWorkspaceAction: fn(), refresh: fn(), setFilter: fn(), watchAgents: fn(), @@ -222,6 +224,11 @@ export const MenuOpen: Story = { }, }; +export const PendingActionMenuOpen: Story = { + ...MenuOpen, + args: { state: { ...READY, pendingActions: { "ws-frontend": "update" } } }, +}; + export const FilterOpen: Story = { play: async ({ canvasElement }) => { await userEvent.click( diff --git a/packages/workspaces/src/components/WorkspacesPanel.tsx b/packages/workspaces/src/components/WorkspacesPanel.tsx index 7ba23a60f3..ed7d7f552a 100644 --- a/packages/workspaces/src/components/WorkspacesPanel.tsx +++ b/packages/workspaces/src/components/WorkspacesPanel.tsx @@ -37,6 +37,7 @@ export type WorkspaceCommands = Pick< | "login" | "openWorkspace" | "viewInDashboard" + | "runWorkspaceAction" | "refresh" | "setFilter" | "watchAgents" @@ -86,12 +87,7 @@ export function WorkspacesPanel({ tooltip: , label: , action: ( - + ), children: [ ...appStatusNodes(agent), @@ -116,22 +112,29 @@ export function WorkspacesPanel({ .map((agent) => agentNode(agent, expandedIds.includes(workspace.id))); const name = workspace.name || "Unknown"; const hasAgentChat = agentWorkspaces.has(workspace.id); + const pendingAction = state?.pendingActions?.[workspace.id]; return { id: workspace.id, textValue: `${name}${hasAgentChat ? " Agent" : ""}${showOwner ? ` ${workspace.owner_name || "Unknown"}` : ""}`, - tooltip: , + tooltip: ( + + ), label: ( ), action: ( ), diff --git a/src/api/coderApi.ts b/src/api/coderApi.ts index 80fe8d84a4..65c12b5069 100644 --- a/src/api/coderApi.ts +++ b/src/api/coderApi.ts @@ -1,4 +1,5 @@ import { + AxiosError, isAxiosError, type AxiosHeaders, type AxiosInstance, @@ -79,6 +80,24 @@ import type { } from "../websocket/eventStreamConnection"; const coderSessionTokenHeader = "Coder-Session-Token"; +const TRANSIENT_NETWORK_ERRORS = new Set([ + AxiosError.ERR_NETWORK, + AxiosError.ECONNABORTED, + AxiosError.ETIMEDOUT, + "ECONNRESET", + "ECONNREFUSED", + "EPIPE", + "EAI_AGAIN", +]); + +function isTransientBuildReadError(error: unknown): boolean { + if (!isAxiosError(error) || error.code === AxiosError.ERR_CANCELED) { + return false; + } + return error.response + ? error.response.status >= 500 && error.response.status < 600 + : TRANSIENT_NETWORK_ERRORS.has(error.code ?? ""); +} /** W3C baggage header used to propagate the session ID to the server. */ const BAGGAGE_HEADER = "baggage"; @@ -176,20 +195,42 @@ export class CoderApi extends Api implements vscode.Disposable { */ override waitForBuild = async ( build: WorkspaceBuild, + options?: { check?: () => void; retryTransientErrors?: boolean }, ): Promise => { + const check = () => options?.check?.(); + const sleep = () => new Promise((resolve) => setTimeout(resolve, 1000)); + let retries = 0; while (true) { - const { job } = await this.getWorkspaceBuildByNumber( - build.workspace_owner_name, - build.workspace_name, - build.build_number, - ); + check(); + let job: ProvisionerJob; + try { + ({ job } = await this.getWorkspaceBuildByNumber( + build.workspace_owner_name, + build.workspace_name, + build.build_number, + )); + } catch (error) { + check(); + if ( + !options?.retryTransientErrors || + retries >= 3 || + !isTransientBuildReadError(error) + ) { + throw error; + } + retries++; + await sleep(); + continue; + } + check(); + retries = 0; if (job.status === "failed") { throw new Error(`Build ${build.build_number} failed`); } if (job.status === "succeeded" || job.status === "canceled") { return job; } - await new Promise((resolve) => setTimeout(resolve, 1000)); + await sleep(); } }; diff --git a/src/api/updateParameters.ts b/src/api/updateParameters.ts index 3956ba4cfa..580292d576 100644 --- a/src/api/updateParameters.ts +++ b/src/api/updateParameters.ts @@ -17,10 +17,10 @@ interface PromptSpec { preselect?: string[]; } -/** Thrown when the user dismisses a parameter prompt. */ +/** Thrown when an update is dismissed or no longer matches its confirmation. */ export class WorkspaceUpdateCancelledError extends Error { - constructor() { - super("Workspace update cancelled"); + constructor(public readonly reason?: string) { + super(reason ?? "Workspace update cancelled"); this.name = "WorkspaceUpdateCancelledError"; } } diff --git a/src/api/workspace.ts b/src/api/workspace.ts index f1796473c5..63e25f0858 100644 --- a/src/api/workspace.ts +++ b/src/api/workspace.ts @@ -9,14 +9,15 @@ import { } from "../settings/cli"; import { createWorkspaceIdentifier } from "./models"; +import { WorkspaceUpdateCancelledError } from "./updateParameters"; -import type { Api } from "coder/site/src/api/api"; import type { CreateWorkspaceBuildOnSuccessRequest, ProvisionerJobLog, Workspace, WorkspaceAgentLog, WorkspaceBuildParameter, + WorkspaceStatus, } from "coder/site/src/api/typesGenerated"; import type { CliFeatureSet, ServerFeatureSet } from "../featureSet"; @@ -26,6 +27,12 @@ import type { CoderApi } from "./coderApi"; const BUILD_REASON = "vscode_connection"; +const STARTABLE_STATUSES: ReadonlySet = new Set([ + "stopped", + "failed", + "canceled", +]); + /** Opens a stream once; subsequent open() calls are no-ops until closed. */ export class LazyStream { private stream: UnidirectionalStream | null = null; @@ -57,8 +64,7 @@ export class LazyStream { } type BuildApi = Pick< - Api, - | "getTemplate" + CoderApi, | "getWorkspace" | "postWorkspaceBuild" | "startWorkspace" @@ -66,14 +72,19 @@ type BuildApi = Pick< | "waitForBuild" >; -interface CliContext { +interface WorkspaceContext { restClient: BuildApi; - auth: CliAuth; - binPath: string; workspace: Workspace; write: (data: string) => void; - cliFeatures: CliFeatureSet; serverFeatures: ServerFeatureSet; + /** Reject work after its initiating session changes. */ + check?: () => void; +} + +interface CliContext extends WorkspaceContext { + auth: CliAuth; + binPath: string; + cliFeatures: CliFeatureSet; } /** Streams CLI output via `ctx.write`; rejects with stderr on non-zero exit. */ @@ -85,6 +96,7 @@ function runCliCommand(ctx: CliContext, args: string[]): Promise { "--", createWorkspaceIdentifier(ctx.workspace), ]; + ctx.check?.(); const proc = spawn(ctx.binPath, fullArgs, { env: getCliProcessEnv() }); // Unexpected prompts EOF instead of hanging forever. proc.stdin.end(); @@ -118,11 +130,11 @@ function runCliCommand(ctx: CliContext, args: string[]): Promise { } /** - * Start a stopped or failed workspace using `coder start`. + * Start a stopped, failed or canceled workspace using `coder start`. * No-ops if the workspace is already running. */ export async function startWorkspace(ctx: CliContext): Promise { - if (!["stopped", "failed"].includes(ctx.workspace.latest_build.status)) { + if (!STARTABLE_STATUSES.has(ctx.workspace.latest_build.status)) { return ctx.workspace; } @@ -132,6 +144,7 @@ export async function startWorkspace(ctx: CliContext): Promise { } await runCliCommand(ctx, args); + ctx.check?.(); return ctx.restClient.getWorkspace(ctx.workspace.id); } @@ -142,14 +155,13 @@ export async function startWorkspace(ctx: CliContext): Promise { * builds: `coder update`, or the REST API on CLIs before 2.24. */ export async function updateWorkspace( - ctx: CliContext, + ctx: WorkspaceContext | CliContext, parameters: WorkspaceBuildParameter[], ): Promise { if (ctx.serverFeatures.onSuccessBuild) { return updateWorkspaceInOneBuild(ctx, parameters); } - - if (!ctx.cliFeatures.cliUpdate) { + if (!("cliFeatures" in ctx) || !ctx.cliFeatures.cliUpdate) { return updateWorkspaceViaApi(ctx, parameters); } @@ -158,6 +170,7 @@ export async function updateWorkspace( `${p.name}=${p.value}`, ]); await runCliCommand(ctx, ["update", ...paramArgs]); + ctx.check?.(); return ctx.restClient.getWorkspace(ctx.workspace.id); } @@ -166,16 +179,19 @@ export async function updateWorkspace( * returned workspace carries the stop build; the server starts it after. */ async function updateWorkspaceInOneBuild( - ctx: CliContext, + ctx: WorkspaceContext, parameters: WorkspaceBuildParameter[], ): Promise { // The build may have changed while parameters were collected. + ctx.check?.(); const workspace = await ctx.restClient.getWorkspace(ctx.workspace.id); + ctx.check?.(); + assertSameUpdate(ctx.workspace, workspace); + const running = workspace.latest_build.status === "running"; const start: CreateWorkspaceBuildOnSuccessRequest = { transition: "start", rich_parameter_values: parameters, }; - const running = workspace.latest_build.status === "running"; ctx.write( `${running ? "Restarting" : "Starting"} workspace with the updated template...\r\n`, @@ -195,30 +211,126 @@ async function updateWorkspaceInOneBuild( return { ...workspace, latest_build: build }; } +/** The update no longer does what was confirmed, so it must be confirmed again. */ +export function updateChangedError( + workspaceName: string, +): WorkspaceUpdateCancelledError { + return new WorkspaceUpdateCancelledError( + `${workspaceName} changed while preparing its update. Run Update again to confirm the current operation.`, + ); +} + +/** + * Rejects an update of `current` that would no longer do what was confirmed + * for `confirmed`: the same start or restart, to the same version. + * `label` preserves the original selection's name in a refusal. + */ +export function assertSameUpdate( + confirmed: Workspace, + current: Workspace, + label?: string, +): void { + const running = current.latest_build.status === "running"; + if ( + running !== (confirmed.latest_build.status === "running") || + (!running && !STARTABLE_STATUSES.has(current.latest_build.status)) || + current.template_active_version_id !== confirmed.template_active_version_id + ) { + throw updateChangedError(label ?? createWorkspaceIdentifier(current)); + } +} + async function updateWorkspaceViaApi( - ctx: CliContext, + ctx: WorkspaceContext, parameters: WorkspaceBuildParameter[], ): Promise { - if (ctx.workspace.latest_build.status === "running") { + ctx.check?.(); + const running = ctx.workspace.latest_build.status === "running"; + let expectedBuildId = ctx.workspace.latest_build.id; + if (running) { ctx.write("Stopping workspace for update...\r\n"); const stopBuild = await ctx.restClient.stopWorkspace(ctx.workspace.id); - const stoppedJob = await ctx.restClient.waitForBuild(stopBuild); + expectedBuildId = stopBuild.id; + const stoppedJob = await ctx.restClient.waitForBuild(stopBuild, { + check: ctx.check, + }); + ctx.check?.(); if (stoppedJob?.status !== "succeeded") { throw new Error("Workspace update stop build did not succeed"); } } + const latest = await ctx.restClient.getWorkspace(ctx.workspace.id); + ctx.check?.(); + if ( + latest.latest_build.id !== expectedBuildId || + (running && latest.latest_build.status !== "stopped") || + !STARTABLE_STATUSES.has(latest.latest_build.status) || + latest.template_active_version_id !== + ctx.workspace.template_active_version_id + ) { + throw updateChangedError(createWorkspaceIdentifier(latest)); + } ctx.write("Starting workspace with updated template...\r\n"); - const template = await ctx.restClient.getTemplate(ctx.workspace.template_id); await ctx.restClient.startWorkspace( ctx.workspace.id, - template.active_version_id, + ctx.workspace.template_active_version_id, undefined, parameters, ); + ctx.check?.(); return ctx.restClient.getWorkspace(ctx.workspace.id); } +/** Restart on the last build's version, without overwriting an intervening build. */ +export async function restartWorkspace( + restClient: Pick< + BuildApi, + "postWorkspaceBuild" | "waitForBuild" | "getWorkspace" + >, + workspace: Workspace, + check?: () => void, +): Promise { + const workspaceId = createWorkspaceIdentifier(workspace); + const versionId = workspace.latest_build.template_version_id; + check?.(); + const stopBuild = await restClient.postWorkspaceBuild(workspace.id, { + transition: "stop", + }); + const stopped = await restClient.waitForBuild(stopBuild, { + check, + retryTransientErrors: true, + }); + check?.(); + if (stopped?.status !== "succeeded") { + throw new Error( + `Failed to stop ${workspaceId}, so it was not started again`, + ); + } + const latest = await restClient.getWorkspace(workspace.id); + check?.(); + if ( + latest.latest_build.id !== stopBuild.id || + latest.latest_build.status !== "stopped" + ) { + throw new Error( + `${workspaceId} changed after stopping. Restart was halted to avoid overwriting another operation.`, + ); + } + try { + await restClient.postWorkspaceBuild(workspace.id, { + transition: "start", + template_version_id: versionId, + }); + } catch (error) { + check?.(); + throw new Error( + `The stop completed for ${workspaceId}, but its start request failed: ${errToStr(error)}. Check its status before retrying.`, + { cause: error }, + ); + } +} + /** * Streams build logs in real-time via a callback. * Returns the websocket for lifecycle management. diff --git a/src/commands.ts b/src/commands.ts index 19b8ec3041..31808d1916 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -4,18 +4,40 @@ import * as path from "node:path"; import * as semver from "semver"; import * as vscode from "vscode"; +import { + workspaceActions, + type DashboardPage, + type WorkspaceAction, +} from "@repo/shared"; + import { extractAgents, createWorkspaceIdentifier, workspaceStatusLabel, } from "./api/models"; +import { + collectUpdateParameters, + WorkspaceUpdateCancelledError, +} from "./api/updateParameters"; +import { + assertSameUpdate, + restartWorkspace, + startWorkspace, + updateChangedError, + updateWorkspace, +} from "./api/workspace"; import * as cliExec from "./cli/cliExec"; import { runDiagnosticCli } from "./command/diagnosticFlow"; import { openInBrowser, toSafeHost } from "./common/url"; import { raceWithAbort } from "./error/abort"; import { CertificateError } from "./error/certificateError"; import { toError } from "./error/normalize"; -import { type CliFeatureSet, cliFeatureSet } from "./featureSet"; +import { + type CliFeatureSet, + cliFeatureSet, + type ServerFeatureSet, + serverFeatureSet, +} from "./featureSet"; import { AuthTelemetry, type AuthLoginOutcome, @@ -76,8 +98,6 @@ import type { WorkspaceAgent, } from "coder/site/src/api/typesGenerated"; -import type { DashboardPage } from "@repo/shared"; - import type { CoderApi } from "./api/coderApi"; import type { CliManager } from "./cli/cliManager"; import type { ServiceContainer } from "./container"; @@ -149,6 +169,15 @@ class SupportBundleUnsupportedCliError extends Error { } const UPDATE_AND_RESTART_ACTION = "Update and Restart"; +const UPDATE_AND_START_ACTION = "Update and Start"; +const RESTART_WARNING = + "stops any running processes and may result in the loss of unsaved work."; + +const DASHBOARD_PATHS: Readonly> = { + workspace: "", + settings: "/settings", + sharing: "/settings/sharing", +}; const openDefaults = { openRecent: false, @@ -1014,7 +1043,7 @@ export class Commands { const workspaceId = createWorkspaceIdentifier(workspace); await openInBrowser( this.requireExtensionBaseUrl(), - `/@${workspaceId}${page === "settings" ? "/settings" : ""}`, + `/@${workspaceId}${DASHBOARD_PATHS[page]}`, ); } @@ -1323,10 +1352,87 @@ export class Commands { } /** - * Update the current workspace. If there is no active workspace connection, - * this is a no-op. + * Update a workspace, or the connected one when none is given. The + * connected one updates through a reload so the remote setup follows it. */ - public async updateWorkspace(): Promise { + public async updateWorkspace(workspace?: Workspace): Promise { + if (workspace && workspace.id !== this.workspace?.id) { + return this.withWorkspaceAction((check) => + this.updateListedWorkspace(workspace, check), + ); + } + return this.updateConnectedWorkspace(); + } + + private async updateListedWorkspace( + workspace: Workspace, + check: () => void, + ): Promise { + const workspaceId = createWorkspaceIdentifier(workspace); + const telemetry = this.operationTelemetry(workspace); + const running = workspace.latest_build.status === "running"; + const confirmed = await telemetry.traceConfirmationPrompt(() => + this.confirmModal( + "Update Workspace", + running + ? `Update ${workspaceId} to the latest version?\n\nUpdating will restart your workspace which ${RESTART_WARNING}` + : `Update ${workspaceId} to the latest version?\n\nThe workspace will start on the latest version.`, + running ? UPDATE_AND_RESTART_ACTION : UPDATE_AND_START_ACTION, + ), + ); + if (!confirmed) { + return; + } + try { + const latest = await this.refreshForAction(workspace, "update", check); + if (!latest) { + return; + } + // Refuse before prompting once a confirmed start became a restart, or the reverse. + if (running !== (latest.latest_build.status === "running")) { + throw updateChangedError(workspaceId); + } + const parameters = await telemetry.traceParametersPrompt(() => + collectUpdateParameters(this.extensionClient, latest), + ); + check(); + const serverFeatures = await this.fetchServerFeatures(); + check(); + const ctx = this.buildWorkspaceContext(latest, serverFeatures, check); + const cli = serverFeatures.onSuccessBuild + ? undefined + : await this.resolveCliEnv(this.extensionClient, check); + const current = await this.refreshForAction(latest, "update", check); + if (!current) { + return; + } + assertSameUpdate(latest, current, workspaceId); + await telemetry.traceUpdate(() => + updateWorkspace( + cli + ? { + ...ctx, + workspace: current, + auth: cli.auth, + binPath: cli.binary, + cliFeatures: cli.featureSet, + } + : { ...ctx, workspace: current }, + parameters, + ), + ); + } catch (error) { + if (error instanceof WorkspaceUpdateCancelledError) { + if (error.reason) { + void vscode.window.showInformationMessage(error.reason); + } + return; + } + throw error; + } + } + + private async updateConnectedWorkspace(): Promise { if (!this.workspace || !this.remoteWorkspaceClient) { return; } @@ -1340,22 +1446,15 @@ export class Commands { return; } const workspaceName = createWorkspaceIdentifier(this.workspace); - const operationTelemetry = new WorkspaceOperationTelemetry( - this.telemetryService, - workspaceName, - ); - const action = await operationTelemetry.traceConfirmationPrompt(async () => - vscodeProposed.window.showWarningMessage( + const operationTelemetry = this.operationTelemetry(this.workspace); + const confirmed = await operationTelemetry.traceConfirmationPrompt(() => + this.confirmModal( "Update Workspace", - { - useCustom: true, - modal: true, - detail: `Update ${workspaceName} to the latest version?\n\nUpdating will restart your workspace which stops any running processes and may result in the loss of unsaved work.`, - }, + `Update ${workspaceName} to the latest version?\n\nUpdating will restart your workspace which ${RESTART_WARNING}`, UPDATE_AND_RESTART_ACTION, ), ); - if (action !== UPDATE_AND_RESTART_ACTION) { + if (!confirmed) { return; } @@ -1394,6 +1493,206 @@ export class Commands { ); } + /** + * `coder start` applies the template's version policy, cleans up a failed + * start and reactivates a dormant workspace. Waits for the build. + */ + public async startWorkspace(workspace: Workspace): Promise { + return this.withWorkspaceAction(async (check) => { + const latest = await this.refreshForAction(workspace, "start", check); + if (!latest) { + return; + } + const ctx = await this.buildCliContext(latest, check); + const current = await this.refreshForAction(latest, "start", check); + if (!current) { + return; + } + await this.operationTelemetry(current).traceStart(() => + startWorkspace({ ...ctx, workspace: current }), + ); + }); + } + + public async stopWorkspace(workspace: Workspace): Promise { + return this.withWorkspaceAction(async (check) => { + if (!(await this.confirmDisconnect(workspace, "Stop"))) { + return; + } + const latest = await this.refreshForAction(workspace, "stop", check); + if (!latest) { + return; + } + await this.operationTelemetry(latest).traceStop(() => { + check(); + return this.extensionClient.stopWorkspace(latest.id); + }); + }); + } + + public async restartWorkspace(workspace: Workspace): Promise { + return this.withWorkspaceAction(async (check) => { + if (!(await this.confirmDisconnect(workspace, "Restart"))) { + return; + } + const latest = await this.refreshForAction(workspace, "restart", check); + if (!latest) { + return; + } + await this.operationTelemetry(latest).traceRestart(() => + restartWorkspace(this.extensionClient, latest, check), + ); + }); + } + + public async deleteWorkspace(workspace: Workspace): Promise { + return this.withWorkspaceAction(async (check) => { + const confirmed = await this.confirmModal( + `Delete ${createWorkspaceIdentifier(workspace)}?`, + "This permanently deletes the workspace and all of its data.", + "Delete", + ); + if (!confirmed) { + return; + } + const latest = await this.refreshForAction(workspace, "delete", check); + if (!latest) { + return; + } + await this.operationTelemetry(latest).traceDelete(() => { + check(); + return this.extensionClient.deleteWorkspace(latest.id); + }); + }); + } + + /** Keep every dispatch in a lifecycle operation on its initiating session. */ + private async withWorkspaceAction( + run: (check: () => void) => Promise, + ): Promise { + const session = this.deploymentManager.session.current; + const check = () => { + if (this.deploymentManager.session.current !== session) { + throw new DOMException("The Coder session changed", "AbortError"); + } + }; + try { + await run(check); + } catch (error) { + if (this.deploymentManager.session.current !== session) { + void vscode.window.showInformationMessage( + "The Coder session changed. The workspace action was halted; check its status before retrying.", + ); + return; + } + throw error; + } + } + + /** The caller's copy may be stale, so re-read it and explain a refusal. */ + private async refreshForAction( + workspace: Workspace, + action: WorkspaceAction, + check: () => void, + ): Promise { + check(); + const latest = await this.extensionClient.getWorkspace(workspace.id); + check(); + if (workspaceActions(latest).includes(action)) { + return latest; + } + const workspaceId = createWorkspaceIdentifier(latest); + const status = workspaceStatusLabel( + latest.latest_build.status, + ).toLowerCase(); + void vscode.window.showInformationMessage( + action === "update" && !latest.outdated + ? `${workspaceId} is already up to date.` + : `Cannot ${action} ${workspaceId} while it is ${status}.`, + ); + return undefined; + } + + /** Stopping the workspace this window is connected to ends the session. */ + private async confirmDisconnect( + workspace: Workspace, + action: "Stop" | "Restart", + ): Promise { + if (workspace.id !== this.workspace?.id) { + return true; + } + const workspaceId = createWorkspaceIdentifier(workspace); + return this.confirmModal( + `${action} ${workspaceId}?`, + `This window is connected to ${workspaceId}. ${action === "Stop" ? "Stopping" : "Restarting"} it ${RESTART_WARNING}`, + action, + ); + } + + /** True when the user chose `button` in a modal warning. */ + private async confirmModal( + title: string, + detail: string, + button: string, + ): Promise { + const choice = await vscodeProposed.window.showWarningMessage( + title, + { useCustom: true, modal: true, detail }, + button, + ); + return choice === button; + } + + private operationTelemetry( + workspace: Workspace, + ): WorkspaceOperationTelemetry { + return new WorkspaceOperationTelemetry( + this.telemetryService, + createWorkspaceIdentifier(workspace), + ); + } + + private async fetchServerFeatures(): Promise { + const { version } = await this.extensionClient.getBuildInfo(); + return serverFeatureSet(semver.parse(version)); + } + + /** CLI output goes to the log. */ + private async buildCliContext(workspace: Workspace, check: () => void) { + check(); + const [env, features] = await Promise.all([ + this.resolveCliEnv(this.extensionClient, check), + this.fetchServerFeatures(), + ]); + return { + ...this.buildWorkspaceContext(workspace, features, check), + auth: env.auth, + binPath: env.binary, + cliFeatures: env.featureSet, + }; + } + + private buildWorkspaceContext( + workspace: Workspace, + serverFeatures: ServerFeatureSet, + check: () => void, + ) { + const workspaceId = createWorkspaceIdentifier(workspace); + return { + restClient: this.extensionClient, + workspace, + check, + write: (data: string) => { + for (const line of data.split(/\r?\n|\r/)) { + if (line.trim()) { + this.logger.info(`[${workspaceId}] ${line}`); + } + } + }, + serverFeatures, + }; + } + /** * Resolve the API client and workspace identifier from a sidebar item, * the currently connected workspace, or by prompting the user to pick one. @@ -1474,16 +1773,19 @@ export class Commands { /** Resolve a CliEnv, preferring a locally cached binary over a network fetch. */ private async resolveCliEnv( client: CoderApi, + check?: () => void, ): Promise { const baseUrl = client.getAxiosInstance().defaults.baseURL; if (!baseUrl) { throw new Error("You are not logged in"); } const safeHost = toSafeHost(baseUrl); - const binary = - (await this.cliManager.locateBinary(baseUrl)) ?? - (await this.cliManager.fetchBinary(client)); + const cached = await this.cliManager.locateBinary(baseUrl); + check?.(); + const binary = cached ?? (await this.cliManager.fetchBinary(client)); + check?.(); const version = semver.parse(await cliExec.version(binary)); + check?.(); const featureSet = cliFeatureSet(version); const configDir = this.pathResolver.getGlobalConfigDir(safeHost); const configs = vscode.workspace.getConfiguration(); diff --git a/src/instrumentation/EVENTS.md b/src/instrumentation/EVENTS.md index 7277fcd8df..ab629214a3 100644 --- a/src/instrumentation/EVENTS.md +++ b/src/instrumentation/EVENTS.md @@ -6,18 +6,18 @@ new ones live in `CONVENTIONS.md`. ## Event index -| Category | Events | -| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [Activation](#activation) | `activation`, `activation.deployment_init` | -| [Auth](#auth) | `auth.login`, `auth.logout`, `auth.login_prompted`, `auth.token_refresh.completed`, `auth.unauthorized_intercepted`, `auth.session_lookup`, `auth.credential.store`, `auth.credential.clear`, `auth.token_refresh.deduped` | -| [CLI](#cli) | `cli.resolve`, `cli.download`, `cli.configure` | -| [Commands](#commands) | `command.invoked`, `command.diagnostic.completed` | -| [Deployment](#deployment) | `deployment.suspended`, `deployment.recovered`, `deployment.cross_window.detected`, `deployment.auth_config.recovery_failed` | -| [Remote setup](#remote-setup) | `remote.setup` | -| [SSH](#ssh) | `ssh.process.discovered`, `ssh.process.lost`, `ssh.process.recovered`, `ssh.process.replaced`, `ssh.process.disposed`, `ssh.network.sampled` | -| [HTTP](#http) | `http.requests` | -| [WebSocket connections](#websocket-connections) | `connection.state_transitioned`, `connection.opened`, `connection.dropped`, `connection.reconnect_resolved`, `connection.unreachable` | -| [Workspace](#workspace) | `workspace.start.triggered`, `workspace.update.triggered`, `workspace.start.prompted`, `workspace.update.prompted`, `workspace.open`, `workspace.picker.prompted`, `workspace.dev_container.open`, `workspace.state_transitioned`, `workspace.agent.state_transitioned` | +| Category | Events | +| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Activation](#activation) | `activation`, `activation.deployment_init` | +| [Auth](#auth) | `auth.login`, `auth.logout`, `auth.login_prompted`, `auth.token_refresh.completed`, `auth.unauthorized_intercepted`, `auth.session_lookup`, `auth.credential.store`, `auth.credential.clear`, `auth.token_refresh.deduped` | +| [CLI](#cli) | `cli.resolve`, `cli.download`, `cli.configure` | +| [Commands](#commands) | `command.invoked`, `command.diagnostic.completed` | +| [Deployment](#deployment) | `deployment.suspended`, `deployment.recovered`, `deployment.cross_window.detected`, `deployment.auth_config.recovery_failed` | +| [Remote setup](#remote-setup) | `remote.setup` | +| [SSH](#ssh) | `ssh.process.discovered`, `ssh.process.lost`, `ssh.process.recovered`, `ssh.process.replaced`, `ssh.process.disposed`, `ssh.network.sampled` | +| [HTTP](#http) | `http.requests` | +| [WebSocket connections](#websocket-connections) | `connection.state_transitioned`, `connection.opened`, `connection.dropped`, `connection.reconnect_resolved`, `connection.unreachable` | +| [Workspace](#workspace) | `workspace.start.triggered`, `workspace.stop.triggered`, `workspace.restart.triggered`, `workspace.update.triggered`, `workspace.delete.triggered`, `workspace.start.prompted`, `workspace.update.prompted`, `workspace.open`, `workspace.picker.prompted`, `workspace.dev_container.open`, `workspace.state_transitioned`, `workspace.agent.state_transitioned` | Signal kinds, which each category groups its events by: @@ -484,14 +484,14 @@ resets on a successful open, so a later outage emits again. ## Workspace -Emitted by `WorkspaceOperationTelemetry` (start and update), +Emitted by `WorkspaceOperationTelemetry` (start, stop, restart, update, delete), `WorkspaceOpenTelemetry` (open, picker, dev container), and `recordWorkspaceState` / `recordAgentState` (the state-transition events, from transitions detected by `WorkspaceStateObserver` / `WorkspaceAgentObserver`). ### Spans -#### `workspace.start.triggered` / `workspace.update.triggered` +#### `workspace.{start,stop,restart,update,delete}.triggered` | Attribute | Values | | ---------------- | ------ | diff --git a/src/instrumentation/workspace.ts b/src/instrumentation/workspace.ts index e04cf8f3a4..37aec1b6d8 100644 --- a/src/instrumentation/workspace.ts +++ b/src/instrumentation/workspace.ts @@ -69,7 +69,7 @@ export function recordAgentState( } /** - * Wraps user-initiated workspace operations (start, update) as traced spans. + * Wraps user-initiated workspace operations as traced spans. * Stateless; safe to construct per call site. */ export class WorkspaceOperationTelemetry { @@ -90,6 +90,24 @@ export class WorkspaceOperationTelemetry { }); } + public traceStop(fn: () => Promise): Promise { + return this.telemetry.trace("workspace.stop.triggered", fn, { + workspace_name: this.workspaceName, + }); + } + + public traceRestart(fn: () => Promise): Promise { + return this.telemetry.trace("workspace.restart.triggered", fn, { + workspace_name: this.workspaceName, + }); + } + + public traceDelete(fn: () => Promise): Promise { + return this.telemetry.trace("workspace.delete.triggered", fn, { + workspace_name: this.workspaceName, + }); + } + public async traceStartPrompt( outdated: boolean, fn: () => Promise, diff --git a/src/webviews/workspaces/workspaceStore.ts b/src/webviews/workspaces/workspaceStore.ts index b136093b0f..26bb2a88c8 100644 --- a/src/webviews/workspaces/workspaceStore.ts +++ b/src/webviews/workspaces/workspaceStore.ts @@ -2,6 +2,15 @@ import { isAxiosError } from "axios"; import { isDeepStrictEqual } from "node:util"; import * as vscode from "vscode"; +import { + isWorkspaceBusy, + type Workspace, + type WorkspaceAction, + type WorkspaceFilter, + type WorkspaceListStatus, + type WorkspacesState, +} from "@repo/shared"; + import { HttpStatusCode } from "../../api/httpStatusCode"; import { extractAllAgents } from "../../api/models"; import { errToStr } from "../../error/normalize"; @@ -15,13 +24,6 @@ import { import { Poller, type NextRun, type RetryOptions } from "./poller"; -import type { - Workspace, - WorkspaceFilter, - WorkspaceListStatus, - WorkspacesState, -} from "@repo/shared"; - import type { AgentMetadataClient } from "../../api/agentMetadata"; import type { CoderApi } from "../../api/coderApi"; import type { SessionState } from "../../deployment/sessionStore"; @@ -29,6 +31,7 @@ import type { Logger } from "../../logging/logger"; interface WorkspacesClient extends AgentMetadataClient { readonly experimental: Pick; + getWorkspace(workspaceId: string): Promise; getWorkspaces( request: Parameters[0], ): Promise<{ readonly workspaces: readonly Workspace[] }>; @@ -38,6 +41,7 @@ const RETRY: RetryOptions = { initialDelayMs: 5_000, maxDelayMs: 60_000 }; const LOADING: WorkspaceListStatus = { kind: "loading" }; const READY: WorkspaceListStatus = { kind: "ready" }; const MINUTE_MS = 60_000; +const FOLLOW_INTERVAL_MS = 5_000; function listedAgentIds( requested: readonly string[], @@ -73,6 +77,10 @@ export class WorkspaceStore implements vscode.Disposable { /** Agents the webview is showing, listed or not. */ private requestedAgents: readonly string[] = []; private lastEmitted: WorkspacesState | undefined; + /** Re-read workspaces acted on, since their filter may not poll. */ + private readonly followers = new Map(); + /** Actions running, which may not have queued their build yet. */ + private readonly acting = new Map(); private visible = false; private disposed = false; @@ -106,6 +114,9 @@ export class WorkspaceStore implements vscode.Disposable { ...(this.agentWorkspaceIds.length ? { agentWorkspaceIds: this.agentWorkspaceIds } : {}), + ...(this.acting.size + ? { pendingActions: Object.fromEntries(this.acting) } + : {}), }; } @@ -147,6 +158,43 @@ export class WorkspaceStore implements vscode.Disposable { return this.watchListedAgents(); } + /** + * Report `action` as pending while `run` performs it, and keep the row + * current until its build settles. Ignored while another action runs there. + */ + public async followAction( + workspaceId: string, + action: WorkspaceAction, + run: () => Promise, + ): Promise { + if (this.disposed) { + return; + } + const session = this.sessionState.current; + const pending = this.acting.get(workspaceId); + if (pending) { + this.logger.warn( + `Ignoring ${action} on workspace ${workspaceId} while ${pending} runs`, + ); + return; + } + this.acting.set(workspaceId, action); + this.publish(); + void this.follow(workspaceId); + try { + await run(); + } finally { + if (!this.disposed && this.sessionState.current === session) { + // Re-read first, so the old actions never reappear. + await this.follow(workspaceId); + if (!this.disposed && this.sessionState.current === session) { + this.acting.delete(workspaceId); + this.publish(); + } + } + } + } + /** List now, keeping the list on show and offering rejected filters again. */ public refresh(): Promise { // A hidden panel cannot re-validate a filter it offers again. @@ -160,6 +208,8 @@ export class WorkspaceStore implements vscode.Disposable { public dispose(): void { this.disposed = true; + this.acting.clear(); + this.stopFollowing(); for (const disposable of this.disposables) { disposable.dispose(); } @@ -260,6 +310,58 @@ export class WorkspaceStore implements vscode.Disposable { } } + private follow(workspaceId: string): Promise { + let follower = this.followers.get(workspaceId); + if (!follower) { + follower = new Poller( + (token) => this.refreshWorkspace(workspaceId, token), + RETRY, + this.logger, + ); + this.followers.set(workspaceId, follower); + } + return follower.run(); + } + + private async refreshWorkspace( + workspaceId: string, + token: vscode.CancellationToken, + ): Promise { + let workspace: Workspace; + try { + workspace = await this.client.getWorkspace(workspaceId); + } catch (error) { + if (this.disposed || token.isCancellationRequested) { + return "idle"; + } + // A deleted workspace is gone, so list again to drop it. + this.logger.warn("Failed to refresh workspace:", error); + void this.poller.run(); + return "idle"; + } + if (this.disposed || token.isCancellationRequested) { + return "idle"; + } + if (this.findWorkspace(workspaceId)) { + this.setList( + this.workspaces.map((listed) => + listed.id === workspaceId ? workspace : listed, + ), + this.status, + ); + } + return this.acting.has(workspaceId) || isWorkspaceBusy(workspace) + ? { delayMs: FOLLOW_INTERVAL_MS } + : "idle"; + } + + private stopFollowing(): void { + for (const follower of this.followers.values()) { + follower.dispose(); + } + this.followers.clear(); + } + /** Show `workspaces` as loading, then list again. */ private reload(workspaces: readonly Workspace[] = []): Promise { this.setList(workspaces, LOADING); @@ -302,6 +404,8 @@ export class WorkspaceStore implements vscode.Disposable { this.agentLookupSupported = true; this.agentWorkspaceIds = []; this.requestedAgents = []; + this.acting.clear(); + this.stopFollowing(); if (!this.filters.includes(this.filter)) { this.filter = DEFAULT_WORKSPACE_FILTER; } diff --git a/src/webviews/workspaces/workspacesPanelProvider.ts b/src/webviews/workspaces/workspacesPanelProvider.ts index 8112a5f5b6..e5032ebd9f 100644 --- a/src/webviews/workspaces/workspacesPanelProvider.ts +++ b/src/webviews/workspaces/workspacesPanelProvider.ts @@ -4,6 +4,7 @@ import { buildCommandHandlers, WorkspacesApi, type OpenWorkspaceParams, + type WorkspaceActionParams, } from "@repo/shared"; import { extractAgents } from "../../api/models"; @@ -25,8 +26,21 @@ import type { WorkspaceStore } from "./workspaceStore"; const USER_ACTION_METHODS: ReadonlySet = new Set([ WorkspacesApi.openWorkspace.method, WorkspacesApi.viewInDashboard.method, + WorkspacesApi.runWorkspaceAction.method, ]); +type PanelCommands = Pick< + Commands, + | "login" + | "openWorkspaceFromSidebar" + | "openWorkspaceInDashboard" + | "startWorkspace" + | "stopWorkspace" + | "restartWorkspace" + | "updateWorkspace" + | "deleteWorkspace" +>; + /** * Renders the workspaces of the current deployment in a webview. All state * lives in the store: every change is pushed whole, and a webview that just @@ -55,6 +69,10 @@ export class WorkspacesPanelProvider this.requireWorkspace(p.workspaceId), p.page, ), + runWorkspaceAction: (p) => + this.store.followAction(p.workspaceId, p.action, () => + this.runWorkspaceAction(p), + ), }), }; @@ -64,10 +82,7 @@ export class WorkspacesPanelProvider constructor( private readonly extensionUri: vscode.Uri, private readonly store: WorkspaceStore, - private readonly commands: Pick< - Commands, - "login" | "openWorkspaceFromSidebar" | "openWorkspaceInDashboard" - >, + private readonly commands: PanelCommands, private readonly logger: Logger, ) {} @@ -160,6 +175,26 @@ export class WorkspacesPanelProvider await this.commands.openWorkspaceFromSidebar(workspace, agent); } + /** Commands re-read the workspace before acting. */ + private async runWorkspaceAction({ + workspaceId, + action, + }: WorkspaceActionParams): Promise { + const workspace = this.requireWorkspace(workspaceId); + switch (action) { + case "start": + return this.commands.startWorkspace(workspace); + case "stop": + return this.commands.stopWorkspace(workspace); + case "restart": + return this.commands.restartWorkspace(workspace); + case "update": + return this.commands.updateWorkspace(workspace); + case "delete": + return this.commands.deleteWorkspace(workspace); + } + } + private requireWorkspace(workspaceId: string): Workspace { const workspace = this.store.findWorkspace(workspaceId); if (!workspace) { diff --git a/test/mocks/commands.ts b/test/mocks/commands.ts index b35d25f194..5de40c2e6d 100644 --- a/test/mocks/commands.ts +++ b/test/mocks/commands.ts @@ -4,11 +4,12 @@ import { Commands } from "@/commands"; import { createTestCoderApi } from "./coderApi"; import { createTestTelemetryService } from "./telemetry"; -import { createMockLogger } from "./testHelpers"; +import { createMockLogger, TestSessionStore } from "./testHelpers"; import type { CoderApi } from "@/api/coderApi"; import type { ServiceContainer } from "@/container"; import type { DeploymentManager } from "@/deployment/deploymentManager"; +import type { SessionState } from "@/deployment/sessionStore"; /** Build `Commands`; services left unnamed stand in as empty objects. */ export function createTestCommands( @@ -16,6 +17,7 @@ export function createTestCommands( services?: Record; baseUrl?: string; client?: Partial; + session?: SessionState; } = {}, ): Commands { const services: Record = { @@ -32,6 +34,6 @@ export function createTestCommands( get: (_, name: string) => () => services[name] ?? {}, }), createTestCoderApi({ baseUrl: options.baseUrl, overrides: options.client }), - {} as DeploymentManager, + { session: options.session ?? new TestSessionStore() } as DeploymentManager, ); } diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index aee8667ef5..7303f8670a 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -1617,6 +1617,10 @@ export class MockWorkspacesClient { Promise.resolve({ workspaces: [], count: 0 }), ); + readonly getWorkspace = vi.fn((id: string): Promise => + Promise.resolve(createWorkspace({ id })), + ); + readonly getHost = vi.fn((): string | undefined => TEST_DEPLOYMENT.url); watchAgentMetadata(agentId: string) { diff --git a/test/unit/api/coderApi.test.ts b/test/unit/api/coderApi.test.ts index b9ef234250..5cfca2a1f3 100644 --- a/test/unit/api/coderApi.test.ts +++ b/test/unit/api/coderApi.test.ts @@ -42,6 +42,7 @@ import { WebSocketCloseCode, HandshakeError } from "@/websocket/codes"; import { ReconnectingWebSocket } from "@/websocket/reconnectingWebSocket"; import { + createAxiosError, createMockLogger, createMockUser, MockConfigurationProvider, @@ -1056,6 +1057,128 @@ describe("CoderApi", () => { InvalidApiResponseError, ); }); + + it("retries transient read failures only when requested", async () => { + vi.useFakeTimers(); + api = createApi(); + mockAdapter.mockRejectedValueOnce( + new AxiosError("network down", AxiosError.ERR_NETWORK), + ); + mockAdapter.mockRejectedValueOnce( + createAxiosError(HttpStatusCode.INTERNAL_SERVER_ERROR, "HTTP 500"), + ); + mockPoll({ status: "succeeded" }); + + const waiting = api.waitForBuild(BUILD, { + retryTransientErrors: true, + }); + const assertion = expect(waiting).resolves.toEqual({ + status: "succeeded", + }); + await vi.advanceTimersByTimeAsync(2000); + + await assertion; + expect(mockAdapter).toHaveBeenCalledTimes(3); + }); + + it("does not retry transient failures by default", async () => { + api = createApi(); + mockAdapter.mockRejectedValueOnce( + new AxiosError("network down", AxiosError.ERR_NETWORK), + ); + + await expect(api.waitForBuild(BUILD)).rejects.toThrow("network down"); + expect(mockAdapter).toHaveBeenCalledTimes(1); + }); + + it("stops after three consecutive transient retries", async () => { + vi.useFakeTimers(); + api = createApi(); + mockAdapter.mockRejectedValue( + new AxiosError("network down", AxiosError.ERR_NETWORK), + ); + + const waiting = api.waitForBuild(BUILD, { + retryTransientErrors: true, + }); + const assertion = expect(waiting).rejects.toThrow("network down"); + await vi.advanceTimersByTimeAsync(3000); + + await assertion; + expect(mockAdapter).toHaveBeenCalledTimes(4); + }); + + it("does not retry canceled requests", async () => { + vi.useFakeTimers(); + api = createApi(); + const controller = new AbortController(); + const canceled = new AxiosError("canceled", AxiosError.ERR_CANCELED, { + headers: new AxiosHeaders(), + signal: controller.signal, + }); + mockAdapter.mockRejectedValueOnce(canceled); + + await expect( + api.waitForBuild(BUILD, { retryTransientErrors: true }), + ).rejects.toBe(canceled); + expect(mockAdapter).toHaveBeenCalledTimes(1); + }); + + it("does not retry after cancellation during a transient failure", async () => { + api = createApi(); + let active = true; + mockAdapter.mockImplementationOnce(() => { + active = false; + return Promise.reject( + new AxiosError("network down", AxiosError.ERR_NETWORK), + ); + }); + const check = () => { + if (!active) throw new Error("session changed"); + }; + + await expect( + api.waitForBuild(BUILD, { check, retryTransientErrors: true }), + ).rejects.toThrow("session changed"); + expect(mockAdapter).toHaveBeenCalledTimes(1); + }); + + it("does not retry auth or schema errors when transient retries are enabled", async () => { + api = createApi(); + mockAdapter.mockRejectedValueOnce( + createAxiosError(HttpStatusCode.UNAUTHORIZED, "HTTP 401"), + ); + await expect( + api.waitForBuild(BUILD, { retryTransientErrors: true }), + ).rejects.toThrow("HTTP 401"); + expect(mockAdapter).toHaveBeenCalledTimes(1); + + mockPoll({}); + await expect( + api.waitForBuild(BUILD, { retryTransientErrors: true }), + ).rejects.toBeInstanceOf(InvalidApiResponseError); + expect(mockAdapter).toHaveBeenCalledTimes(2); + }); + + it("checks cancellation around reads and polling sleeps", async () => { + vi.useFakeTimers(); + api = createApi(); + let active = true; + const check = () => { + if (!active) throw new Error("session changed"); + }; + mockPoll({ status: "running" }); + mockPoll({ status: "succeeded" }); + + const waiting = api.waitForBuild(BUILD, { check }); + const assertion = expect(waiting).rejects.toThrow("session changed"); + await vi.advanceTimersByTimeAsync(0); + active = false; + await vi.advanceTimersByTimeAsync(1000); + + await assertion; + expect(mockAdapter).toHaveBeenCalledTimes(1); + }); }); describe("getHost/getSessionToken", () => { diff --git a/test/unit/api/workspace.test.ts b/test/unit/api/workspace.test.ts index d7772476d6..14f011d50b 100644 --- a/test/unit/api/workspace.test.ts +++ b/test/unit/api/workspace.test.ts @@ -2,7 +2,12 @@ import { EventEmitter } from "node:events"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as vscode from "vscode"; -import { LazyStream, startWorkspace, updateWorkspace } from "@/api/workspace"; +import { + LazyStream, + restartWorkspace, + startWorkspace, + updateWorkspace, +} from "@/api/workspace"; import { workspace as createWorkspace } from "@repo/mocks"; @@ -74,6 +79,7 @@ function createUpdateCtx( }; cliFeatures?: Partial; serverFeatures?: Partial; + check?: () => void; } = {}, ) { vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ @@ -95,9 +101,7 @@ function createUpdateCtx( .fn() .mockResolvedValue({ ...workspace.latest_build, status: "stopped" }), startWorkspace: vi.fn().mockResolvedValue(workspace.latest_build), - getTemplate: vi - .fn() - .mockResolvedValue({ active_version_id: "active-version-id" }), + waitForBuild: vi.fn().mockResolvedValue({ ...workspace.latest_build.job, status: "succeeded", @@ -114,6 +118,7 @@ function createUpdateCtx( binPath: "/usr/bin/coder", workspace, write: vi.fn<(data: string) => void>(), + check: overrides.check, cliFeatures: { ...CLI_FEATURES, ...overrides.cliFeatures }, serverFeatures: { tasks: false, @@ -356,7 +361,15 @@ describe("updateWorkspace", () => { restClient.postWorkspaceBuild.mockResolvedValue(accepted); await expect( - updateWorkspace(ctx, [{ name: "region", value: "us-east" }]), + updateWorkspace( + { + restClient, + workspace: ctx.workspace, + write: ctx.write, + serverFeatures: ctx.serverFeatures, + }, + [{ name: "region", value: "us-east" }], + ), ).resolves.toEqual({ ...ctx.workspace, latest_build: accepted }); expect(restClient.postWorkspaceBuild).toHaveBeenCalledWith( ctx.workspace.id, @@ -364,34 +377,118 @@ describe("updateWorkspace", () => { ); }); - it("falls back to the API update path when coder update is unsupported", async () => { - const { ctx, restClient, finalWorkspace } = createUpdateCtx({ - cliFeatures: { cliUpdate: false }, + it.each([ + { + name: "from start to restart", + final: { latest_build: { status: "running" as const } }, + }, + { + name: "to another version", + final: { + latest_build: { status: "stopped" as const }, + template_active_version_id: "another-version", + }, + }, + ])( + "does not change a confirmed update $name on its final read", + async ({ final }) => { + const { ctx, restClient } = createUpdateCtx({ + workspace: { latest_build: { status: "stopped" } }, + serverFeatures: { onSuccessBuild: true }, + }); + restClient.getWorkspace.mockResolvedValue( + createWorkspace({ outdated: true, ...final }), + ); + + await expect(updateWorkspace(ctx, [])).rejects.toThrow("changed"); + + expect(restClient.postWorkspaceBuild).not.toHaveBeenCalled(); + }, + ); + + it("checks the session before submitting a reread update", async () => { + let active = true; + const { ctx, restClient } = createUpdateCtx({ + serverFeatures: { onSuccessBuild: true }, + check: () => { + if (!active) throw new Error("session changed"); + }, + }); + restClient.getWorkspace.mockImplementationOnce(() => { + active = false; + return Promise.resolve(ctx.workspace); }); - await expect(updateWorkspace(ctx, [])).resolves.toBe(finalWorkspace); + await expect(updateWorkspace(ctx, [])).rejects.toThrow("session changed"); - expect(spawn).not.toHaveBeenCalled(); - expect(restClient.stopWorkspace).toHaveBeenCalledWith(ctx.workspace.id); - expect(restClient.startWorkspace).toHaveBeenCalledWith( - ctx.workspace.id, - "active-version-id", - undefined, - [], - ); + expect(restClient.postWorkspaceBuild).not.toHaveBeenCalled(); }); - it("passes collected parameters when using the API fallback", async () => { + it("checks the session between the API update stop and start", async () => { + let active = true; const { ctx, restClient } = createUpdateCtx({ cliFeatures: { cliUpdate: false }, + check: () => { + if (!active) throw new Error("session changed"); + }, + }); + restClient.waitForBuild.mockImplementationOnce(() => { + active = false; + return Promise.resolve({ status: "succeeded" }); + }); + + await expect(updateWorkspace(ctx, [])).rejects.toThrow("session changed"); + + expect(restClient.startWorkspace).not.toHaveBeenCalled(); + }); + + it.each(["version", "build"] as const)( + "does not start an API update after its confirmed $0 changes during the stop", + async (change) => { + const { ctx, restClient } = createUpdateCtx({ + cliFeatures: { cliUpdate: false }, + }); + restClient.getWorkspace.mockResolvedValue({ + ...ctx.workspace, + template_active_version_id: + change === "version" + ? "another-version" + : ctx.workspace.template_active_version_id, + latest_build: { + ...ctx.workspace.latest_build, + id: + change === "build" + ? "another-build" + : ctx.workspace.latest_build.id, + status: "stopped", + }, + }); + + await expect(updateWorkspace(ctx, [])).rejects.toThrow("changed"); + + expect(restClient.startWorkspace).not.toHaveBeenCalled(); + }, + ); + + it("falls back to the API update path when coder update is unsupported", async () => { + const { ctx, restClient, finalWorkspace } = createUpdateCtx({ + cliFeatures: { cliUpdate: false }, }); const parameters = [{ name: "region", value: "us-east" }]; + restClient.getWorkspace.mockResolvedValueOnce({ + ...ctx.workspace, + latest_build: { ...ctx.workspace.latest_build, status: "stopped" }, + }); - await updateWorkspace(ctx, parameters); + await expect(updateWorkspace(ctx, parameters)).resolves.toBe( + finalWorkspace, + ); + expect(spawn).not.toHaveBeenCalled(); + expect(restClient.stopWorkspace).toHaveBeenCalledWith(ctx.workspace.id); expect(restClient.startWorkspace).toHaveBeenCalledWith( ctx.workspace.id, - "active-version-id", + "version-1", undefined, parameters, ); @@ -402,13 +499,14 @@ describe("updateWorkspace", () => { workspace: { latest_build: { status: "stopped", transition: "stop" } }, cliFeatures: { cliUpdate: false }, }); + restClient.getWorkspace.mockResolvedValueOnce(ctx.workspace); await updateWorkspace(ctx, []); expect(restClient.stopWorkspace).not.toHaveBeenCalled(); expect(restClient.startWorkspace).toHaveBeenCalledWith( ctx.workspace.id, - "active-version-id", + "version-1", undefined, [], ); @@ -457,6 +555,19 @@ describe("startWorkspace", () => { expect(restClient.getWorkspace).toHaveBeenCalledWith(ctx.workspace.id); }); + it("runs coder start when the last build was canceled", async () => { + const { ctx } = createUpdateCtx({ + workspace: { latest_build: { status: "canceled", transition: "start" } }, + }); + const sp = controlSpawn(); + + const result = startWorkspace(ctx); + await sp.close(0); + await result; + + expect(spawnedArgs()).toContain("start"); + }); + it("no-ops when the workspace is already running", async () => { const { ctx, restClient } = createUpdateCtx(); await expect(startWorkspace(ctx)).resolves.toBe(ctx.workspace); @@ -484,3 +595,131 @@ describe("startWorkspace", () => { ]); }); }); + +interface StopFailureCase { + name: string; + waitForBuild: () => Promise; +} + +describe("restartWorkspace", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("waits for its stop, then preserves the version despite an active-version change", async () => { + const { ctx, restClient } = createUpdateCtx({ + workspace: { + outdated: false, + template_active_version_id: "v-original", + latest_build: { template_version_id: "v-original" }, + }, + serverFeatures: { onSuccessBuild: true }, + }); + const stopBuild = { ...ctx.workspace.latest_build, id: "stop-build" }; + restClient.postWorkspaceBuild.mockResolvedValueOnce(stopBuild); + restClient.getWorkspace.mockResolvedValue({ + ...ctx.workspace, + outdated: true, + template_active_version_id: "v-new", + latest_build: { ...stopBuild, status: "stopped", transition: "stop" }, + }); + const waiting = Promise.withResolvers(); + const job = Promise.withResolvers<{ status: string }>(); + restClient.waitForBuild.mockImplementationOnce(() => { + waiting.resolve(); + return job.promise; + }); + + const restarting = restartWorkspace(restClient, ctx.workspace); + await waiting.promise; + expect(restClient.postWorkspaceBuild).toHaveBeenCalledOnce(); + + job.resolve({ status: "succeeded" }); + await restarting; + + expect(restClient.postWorkspaceBuild.mock.calls).toEqual([ + [ctx.workspace.id, { transition: "stop" }], + [ + ctx.workspace.id, + { transition: "start", template_version_id: "v-original" }, + ], + ]); + }); + + it("does not restart over a build that superseded its stop", async () => { + const { ctx, restClient } = createUpdateCtx(); + const stopBuild = { ...ctx.workspace.latest_build, id: "stop-build" }; + restClient.postWorkspaceBuild.mockResolvedValueOnce(stopBuild); + restClient.getWorkspace.mockResolvedValue( + createWorkspace({ + latest_build: { id: "another-build", status: "stopped" }, + }), + ); + + await expect(restartWorkspace(restClient, ctx.workspace)).rejects.toThrow( + "changed", + ); + + expect(restClient.postWorkspaceBuild).toHaveBeenCalledOnce(); + }); + + it("does not start after its session changes while waiting for the stop", async () => { + const { ctx, restClient } = createUpdateCtx(); + const stopBuild = { ...ctx.workspace.latest_build, id: "stop-build" }; + restClient.postWorkspaceBuild.mockResolvedValueOnce(stopBuild); + let active = true; + const check = () => { + if (!active) throw new Error("session changed"); + }; + restClient.waitForBuild.mockImplementationOnce(() => { + active = false; + return Promise.resolve({ status: "succeeded" }); + }); + + await expect( + restartWorkspace(restClient, ctx.workspace, check), + ).rejects.toThrow("session changed"); + + expect(restClient.postWorkspaceBuild).toHaveBeenCalledOnce(); + expect(restClient.getWorkspace).not.toHaveBeenCalled(); + }); + + it("does not retry an ambiguous start failure and explains how to recover", async () => { + const { ctx, restClient } = createUpdateCtx(); + const stopBuild = { ...ctx.workspace.latest_build, id: "stop-build" }; + restClient.postWorkspaceBuild + .mockResolvedValueOnce(stopBuild) + .mockRejectedValueOnce(new Error("connection lost")); + restClient.getWorkspace.mockResolvedValue({ + ...ctx.workspace, + latest_build: { ...stopBuild, status: "stopped", transition: "stop" }, + }); + + await expect(restartWorkspace(restClient, ctx.workspace)).rejects.toThrow( + "Check its status before retrying", + ); + + expect(restClient.postWorkspaceBuild).toHaveBeenCalledTimes(2); + }); + + it.each([ + { + name: "fails", + waitForBuild: () => Promise.reject(new Error("job failed")), + }, + { + name: "is canceled", + waitForBuild: () => Promise.resolve({ status: "canceled" }), + }, + ])("does not start when the stop $name", async ({ name, waitForBuild }) => { + const { ctx, restClient } = createUpdateCtx(); + restClient.waitForBuild.mockImplementation(waitForBuild); + + await expect(restartWorkspace(restClient, ctx.workspace)).rejects.toThrow( + name === "fails" + ? "job failed" + : "Failed to stop testuser/test-workspace, so it was not started again", + ); + expect(restClient.postWorkspaceBuild).toHaveBeenCalledOnce(); + }); +}); diff --git a/test/unit/commands.workspaceActions.test.ts b/test/unit/commands.workspaceActions.test.ts new file mode 100644 index 0000000000..8b1a1b036c --- /dev/null +++ b/test/unit/commands.workspaceActions.test.ts @@ -0,0 +1,430 @@ +import { describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; + +import { + collectUpdateParameters, + WorkspaceUpdateCancelledError, +} from "@/api/updateParameters"; +import { + restartWorkspace, + startWorkspace, + updateWorkspace, +} from "@/api/workspace"; +import * as cliExec from "@/cli/cliExec"; + +import { workspace } from "@repo/mocks"; + +import { createTestCommands } from "../mocks/commands"; +import { createTelemetryHarness } from "../mocks/telemetry"; +import { + MockConfigurationProvider, + MockProgressReporter, + TestSessionStore, +} from "../mocks/testHelpers"; + +import type { + Workspace, + WorkspaceStatus, +} from "coder/site/src/api/typesGenerated"; + +import type { Commands } from "@/commands"; + +vi.mock("@/api/workspace", async (importOriginal) => ({ + ...(await importOriginal()), + startWorkspace: vi.fn(), + restartWorkspace: vi.fn(), + updateWorkspace: vi.fn(), +})); + +vi.mock("@/api/updateParameters", async (importOriginal) => ({ + ...(await importOriginal()), + collectUpdateParameters: vi.fn(), +})); + +vi.mock("@/cli/cliExec", async (importOriginal) => ({ + ...(await importOriginal()), + version: vi.fn(), +})); + +const NAME = "testuser/test-workspace"; + +const withStatus = (status: WorkspaceStatus, outdated = false) => + workspace({ outdated, latest_build: { status } }); + +/** Commands whose deployment re-reads the workspace as `latest`. */ +function setup(latest: Workspace, warningChoice?: string) { + vi.clearAllMocks(); + new MockConfigurationProvider(); + new MockProgressReporter(); + vi.mocked(cliExec.version).mockResolvedValue("v2.36.0"); + vi.mocked(vscode.window.showWarningMessage).mockResolvedValue( + warningChoice as never, + ); + const { sink, service } = createTelemetryHarness(); + const client = { + getWorkspace: vi.fn(() => Promise.resolve(latest)), + getBuildInfo: vi.fn(() => Promise.resolve({ version: "v2.36.0" } as never)), + stopWorkspace: vi.fn(() => Promise.resolve(latest.latest_build)), + deleteWorkspace: vi.fn(() => Promise.resolve(latest.latest_build)), + }; + const session = new TestSessionStore(); + const cliManager = { + locateBinary: vi.fn(() => + Promise.resolve("/bin/coder"), + ), + fetchBinary: vi.fn(() => Promise.resolve("/bin/coder")), + configure: vi.fn(() => Promise.resolve()), + }; + const commands = createTestCommands({ + session, + baseUrl: "https://coder.test", + client, + services: { + getTelemetryService: service, + getCliManager: cliManager, + getPathResolver: { getGlobalConfigDir: () => "/cfg" }, + }, + }); + const triggered = (action: string) => + sink.eventsNamed(`workspace.${action}.triggered`); + return { commands, client, triggered, session, cliManager }; +} + +describe("workspace lifecycle commands", () => { + it("starts with coder start on the workspace as it is now", async () => { + const latest = withStatus("stopped"); + const { commands, triggered } = setup(latest); + + await commands.startWorkspace(withStatus("running")); + + expect(startWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ workspace: latest, binPath: "/bin/coder" }), + ); + expect(triggered("start")).toHaveLength(1); + }); + + it("stops with the API", async () => { + const latest = withStatus("running"); + const { commands, client } = setup(latest); + + await commands.stopWorkspace(latest); + + expect(vscode.window.showWarningMessage).not.toHaveBeenCalled(); + expect(client.stopWorkspace).toHaveBeenCalledWith(latest.id); + }); + + it("restarts without depending on deployment feature detection", async () => { + const latest = withStatus("running"); + const { commands, client } = setup(latest); + + await commands.restartWorkspace(latest); + + expect(restartWorkspace).toHaveBeenCalledWith( + expect.anything(), + latest, + expect.any(Function), + ); + expect(client.getBuildInfo).not.toHaveBeenCalled(); + }); + + it("deletes once confirmed", async () => { + const latest = withStatus("stopped"); + const { commands, client } = setup(latest, "Delete"); + + await commands.deleteWorkspace(latest); + + expect(client.deleteWorkspace).toHaveBeenCalledWith(latest.id); + }); + + it("updates a listed workspace in place, without a CLI from 2.36", async () => { + const latest = withStatus("stopped", true); + const parameters = [{ name: "region", value: "eu" }]; + vi.mocked(collectUpdateParameters).mockResolvedValue(parameters); + const { commands, cliManager } = setup(latest, "Update and Start"); + cliManager.locateBinary.mockRejectedValue(new Error("CLI unavailable")); + + await commands.updateWorkspace(latest); + + expect(updateWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ workspace: latest }), + parameters, + ); + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith( + "workbench.action.reloadWindow", + ); + expect(cliManager.locateBinary).not.toHaveBeenCalled(); + expect(cliManager.configure).not.toHaveBeenCalled(); + }); + + it("aborts an update when starting becomes restarting during confirmation", async () => { + const latest = { ...withStatus("running", true), name: "renamed" }; + const { commands } = setup(latest, "Update and Start"); + + await commands.updateWorkspace(withStatus("stopped", true)); + + expect(collectUpdateParameters).not.toHaveBeenCalled(); + expect(updateWorkspace).not.toHaveBeenCalled(); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + `${NAME} changed while preparing its update. Run Update again to confirm the current operation.`, + ); + }); + + it("aborts an update when its effect changes during parameter prompts", async () => { + const latest = withStatus("stopped", true); + const { commands, client } = setup(latest, "Update and Start"); + vi.mocked(collectUpdateParameters).mockImplementationOnce(() => { + client.getWorkspace.mockResolvedValue({ + ...withStatus("running", true), + name: "renamed", + }); + return Promise.resolve([]); + }); + + await commands.updateWorkspace(latest); + + expect(updateWorkspace).not.toHaveBeenCalled(); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + `${NAME} changed while preparing its update. Run Update again to confirm the current operation.`, + ); + }); + + it.each([ + { action: "start", status: "stopped", confirm: undefined }, + { action: "stop", status: "running", confirm: undefined }, + { action: "restart", status: "running", confirm: undefined }, + { action: "delete", status: "stopped", confirm: "Delete" }, + { action: "update", status: "stopped", confirm: "Update and Start" }, + ] as const)( + "does not $action under a session that replaced its workspace read", + async ({ action, status, confirm }) => { + const latest = withStatus(status, true); + const { commands, client, session, cliManager } = setup(latest, confirm); + const read = Promise.withResolvers(); + const requested = Promise.withResolvers(); + client.getWorkspace.mockImplementationOnce(() => { + requested.resolve(); + return read.promise; + }); + const methods = { + start: () => commands.startWorkspace(latest), + stop: () => commands.stopWorkspace(latest), + restart: () => commands.restartWorkspace(latest), + delete: () => commands.deleteWorkspace(latest), + update: () => commands.updateWorkspace(latest), + }; + + const running = methods[action](); + await requested.promise; + session.signInAs("replacement-user"); + read.resolve(latest); + await running; + + expect(client.stopWorkspace).not.toHaveBeenCalled(); + expect(client.deleteWorkspace).not.toHaveBeenCalled(); + expect(startWorkspace).not.toHaveBeenCalled(); + expect(restartWorkspace).not.toHaveBeenCalled(); + expect(updateWorkspace).not.toHaveBeenCalled(); + expect(cliManager.configure).not.toHaveBeenCalled(); + }, + ); + + it("does not revalidate a delete confirmed after a session change", async () => { + const latest = withStatus("stopped"); + const { commands, client, session } = setup(latest); + const confirmation = Promise.withResolvers(); + vi.mocked(vscode.window.showWarningMessage).mockReturnValueOnce( + confirmation.promise as never, + ); + + const deleting = commands.deleteWorkspace(latest); + session.signOut(); + confirmation.resolve("Delete"); + await deleting; + + expect(client.getWorkspace).not.toHaveBeenCalled(); + expect(client.deleteWorkspace).not.toHaveBeenCalled(); + }); + + it.each(["locating it", "downloading it", "checking its version"] as const)( + "does not continue CLI preparation after its session changed while %s", + async (stage) => { + const latest = withStatus("stopped"); + const { commands, session, cliManager } = setup(latest); + const gate = Promise.withResolvers(); + const reached = Promise.withResolvers(); + const pause = async (value: T) => { + reached.resolve(); + await gate.promise; + return value; + }; + if (stage === "locating it") { + cliManager.locateBinary.mockImplementationOnce(() => + pause(undefined), + ); + } else if (stage === "downloading it") { + cliManager.locateBinary.mockResolvedValueOnce(undefined); + cliManager.fetchBinary.mockImplementationOnce(() => + pause("/bin/coder"), + ); + } else { + vi.mocked(cliExec.version).mockImplementationOnce(() => + pause("v2.36.0"), + ); + } + + const starting = commands.startWorkspace(latest); + await reached.promise; + session.signInAs("replacement-user"); + gate.resolve(); + await starting; + + if (stage === "downloading it") { + expect(cliExec.version).not.toHaveBeenCalled(); + } else { + expect(cliManager.fetchBinary).not.toHaveBeenCalled(); + } + expect(cliManager.configure).not.toHaveBeenCalled(); + expect(startWorkspace).not.toHaveBeenCalled(); + }, + ); + + it("does not update when a parameter prompt is dismissed", async () => { + vi.mocked(collectUpdateParameters).mockRejectedValue( + new WorkspaceUpdateCancelledError(), + ); + const { commands } = setup(withStatus("stopped", true), "Update and Start"); + + await commands.updateWorkspace(withStatus("stopped", true)); + + expect(updateWorkspace).not.toHaveBeenCalled(); + }); + + interface RefusalCase { + action: string; + latest: Workspace; + confirm?: string; + run: (commands: Commands, workspace: Workspace) => Promise; + message: string; + } + + it.each([ + { + action: "start", + latest: withStatus("starting"), + run: (c, w) => c.startWorkspace(w), + message: `Cannot start ${NAME} while it is starting.`, + }, + { + action: "stop", + latest: withStatus("stopping"), + run: (c, w) => c.stopWorkspace(w), + message: `Cannot stop ${NAME} while it is stopping.`, + }, + { + action: "restart", + latest: workspace({ + outdated: true, + template_require_active_version: true, + }), + run: (c, w) => c.restartWorkspace(w), + message: `Cannot restart ${NAME} while it is running.`, + }, + { + action: "delete", + latest: withStatus("deleting"), + confirm: "Delete", + run: (c, w) => c.deleteWorkspace(w), + message: `Cannot delete ${NAME} while it is deleting.`, + }, + { + action: "update", + latest: withStatus("stopped"), + confirm: "Update and Start", + run: (c, w) => c.updateWorkspace(w), + message: `${NAME} is already up to date.`, + }, + ])( + "refuses to $action when the workspace changed since it was listed", + async ({ action, latest, confirm, run, message }) => { + const { commands, triggered } = setup(latest, confirm); + + await run(commands, withStatus("stopped", true)); + + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + message, + ); + expect(triggered(action)).toEqual([]); + }, + ); + + interface ConfirmationCase { + action: string; + listed: Workspace; + connected?: boolean; + run: (commands: Commands, workspace: Workspace) => Promise; + title: string; + button: string; + } + + it.each([ + { + action: "stop", + listed: withStatus("running"), + connected: true, + run: (c, w) => c.stopWorkspace(w), + title: `Stop ${NAME}?`, + button: "Stop", + }, + { + action: "restart", + listed: withStatus("running"), + connected: true, + run: (c, w) => c.restartWorkspace(w), + title: `Restart ${NAME}?`, + button: "Restart", + }, + { + action: "delete", + listed: withStatus("stopped"), + run: (c, w) => c.deleteWorkspace(w), + title: `Delete ${NAME}?`, + button: "Delete", + }, + { + action: "update", + listed: withStatus("running", true), + run: (c, w) => c.updateWorkspace(w), + title: "Update Workspace", + button: "Update and Restart", + }, + ])( + "asks before it will $action and stops when dismissed", + async ({ action, listed, connected, run, title, button }) => { + const { commands, client, triggered } = setup(listed); + if (connected) { + commands.workspace = listed; + } + + await run(commands, listed); + + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( + title, + expect.objectContaining({ modal: true }), + button, + ); + expect(client.getWorkspace).not.toHaveBeenCalled(); + expect(triggered(action)).toEqual([]); + }, + ); +}); + +it("opens the dashboard's sharing page", async () => { + const latest = withStatus("running"); + const { commands } = setup(latest); + + await commands.openWorkspaceInDashboard(latest, "sharing"); + + expect(vscode.env.openExternal).toHaveBeenCalledWith( + vscode.Uri.parse(`https://coder.test/@${NAME}/settings/sharing`), + ); +}); diff --git a/test/unit/webviews/workspaces/workspaceStore.test.ts b/test/unit/webviews/workspaces/workspaceStore.test.ts index 2a9345e61d..7d572b1192 100644 --- a/test/unit/webviews/workspaces/workspaceStore.test.ts +++ b/test/unit/webviews/workspaces/workspaceStore.test.ts @@ -18,6 +18,8 @@ import { userWithRoles, } from "../../../mocks/testHelpers"; +import type { WorkspaceStatus } from "coder/site/src/api/typesGenerated"; + import type { Workspace, WorkspacesState } from "@repo/shared"; /** The 400 a deployment answers with when it cannot run a filter's query. */ @@ -356,6 +358,167 @@ describe("WorkspaceStore", () => { }); }); + describe("following an action", () => { + const withStatus = (status: WorkspaceStatus) => + workspace({ id: "workspace-1", latest_build: { status } }); + + /** Shown under "shared", which lists only on demand. */ + async function onDemand() { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const h = await shown([]); + h.client.respondOnce([withStatus("running")]); + await h.store.setFilter("shared"); + return h; + } + + const status = (h: Store) => h.last()?.workspaces[0].latest_build.status; + + /** Start a stop on workspace-1; the returned function completes it. */ + function stopUntilFinished(h: Store): () => Promise { + const stop = Promise.withResolvers(); + const stopping = h.store.followAction( + "workspace-1", + "stop", + () => stop.promise, + ); + return () => { + stop.resolve(); + return stopping; + }; + } + + it("re-reads the workspace while the action runs and until its build settles", async () => { + const h = await onDemand(); + const lists = h.client.getWorkspaces.mock.calls.length; + const action = Promise.withResolvers(); + h.client.getWorkspace + .mockResolvedValueOnce(withStatus("running")) + .mockResolvedValueOnce(withStatus("stopping")) + .mockResolvedValueOnce(withStatus("stopping")) + .mockResolvedValueOnce(withStatus("stopped")); + + const following = h.store.followAction( + "workspace-1", + "stop", + () => action.promise, + ); + await vi.advanceTimersByTimeAsync(5_000); + expect(status(h)).toBe("stopping"); + + action.resolve(); + await following; + await vi.advanceTimersByTimeAsync(5_000); + expect(status(h)).toBe("stopped"); + + await vi.advanceTimersByTimeAsync(60_000); + expect(h.client.getWorkspace).toHaveBeenCalledTimes(4); + expect(h.client.getWorkspaces).toHaveBeenCalledTimes(lists); + }); + + it("reports the action as pending until the row is re-read after it", async () => { + const h = await onDemand(); + const before = h.states.length; + h.client.getWorkspace + .mockResolvedValueOnce(withStatus("running")) + .mockResolvedValue(withStatus("stopping")); + + await h.store.followAction("workspace-1", "stop", () => + Promise.resolve(), + ); + + expect( + h.states + .slice(before) + .map((state) => [ + state.pendingActions, + state.workspaces[0].latest_build.status, + ]), + ).toEqual([ + [{ "workspace-1": "stop" }, "running"], + [{ "workspace-1": "stop" }, "stopping"], + [undefined, "stopping"], + ]); + }); + + it("clears the pending action when it fails", async () => { + const h = await onDemand(); + h.client.getWorkspace.mockResolvedValue(withStatus("running")); + + await expect( + h.store.followAction("workspace-1", "stop", () => + Promise.reject(new Error("denied")), + ), + ).rejects.toThrow("denied"); + + expect(h.store.state.pendingActions).toBeUndefined(); + }); + + it("ignores another action on the workspace while one runs", async () => { + const h = await onDemand(); + h.client.getWorkspace.mockResolvedValue(withStatus("running")); + const finishStop = stopUntilFinished(h); + const remove = vi.fn(() => Promise.resolve()); + + await h.store.followAction("workspace-1", "delete", remove); + + expect(remove).not.toHaveBeenCalled(); + expect(h.store.state.pendingActions).toEqual({ "workspace-1": "stop" }); + await finishStop(); + }); + + it("isolates pending actions and their completions across sessions", async () => { + const h = await onDemand(); + h.client.getWorkspace.mockResolvedValue(withStatus("running")); + const finishStop = stopUntilFinished(h); + + h.session.signInAs("someone-else"); + await h.store.settled; + expect(h.store.state.pendingActions).toBeUndefined(); + + const start = Promise.withResolvers(); + const run = vi.fn(() => start.promise); + const starting = h.store.followAction("workspace-1", "start", run); + expect(run).toHaveBeenCalledOnce(); + const fetches = h.client.getWorkspace.mock.calls.length; + + await finishStop(); + expect(h.client.getWorkspace).toHaveBeenCalledTimes(fetches); + expect(h.store.state.pendingActions).toEqual({ "workspace-1": "start" }); + + start.resolve(); + await starting; + expect(h.store.state.pendingActions).toBeUndefined(); + }); + + it("does not resume following when an action outlives disposal", async () => { + const h = await onDemand(); + const finishStop = stopUntilFinished(h); + h.store.dispose(); + const fetches = h.client.getWorkspace.mock.calls.length; + + await finishStop(); + + expect(h.client.getWorkspace).toHaveBeenCalledTimes(fetches); + expect(h.store.state.pendingActions).toBeUndefined(); + }); + + it("lists again when the workspace cannot be read, as after a delete", async () => { + const h = await onDemand(); + h.client.getWorkspace.mockRejectedValue(createAxiosError(410, "gone")); + h.client.respondOnce([]); + + await h.store.followAction("workspace-1", "delete", () => + Promise.resolve(), + ); + await vi.advanceTimersByTimeAsync(0); + + expect(ids(h.last())).toEqual([]); + }); + }); + describe("requests that outlive a hide", () => { it("caches a response, and keeps it on reveal while the fresh list loads", async () => { const h = await shown(); diff --git a/test/unit/webviews/workspaces/workspacesPanelProvider.test.ts b/test/unit/webviews/workspaces/workspacesPanelProvider.test.ts index 408be11d7d..747fe796db 100644 --- a/test/unit/webviews/workspaces/workspacesPanelProvider.test.ts +++ b/test/unit/webviews/workspaces/workspacesPanelProvider.test.ts @@ -15,6 +15,7 @@ import { WorkspacesApi, type CommandDef, type OpenWorkspaceParams, + type WorkspaceAction, type WorkspacesState, } from "@repo/shared"; @@ -30,8 +31,6 @@ import { import type { Workspace } from "coder/site/src/api/typesGenerated"; -import type { Commands } from "@/commands"; - const alice = () => workspace({ id: "workspace-1", @@ -60,10 +59,12 @@ function setup() { login: vi.fn(() => Promise.resolve()), openWorkspaceFromSidebar: vi.fn(() => Promise.resolve()), openWorkspaceInDashboard: vi.fn(() => Promise.resolve()), - } satisfies Pick< - Commands, - "login" | "openWorkspaceFromSidebar" | "openWorkspaceInDashboard" - >; + startWorkspace: vi.fn(() => Promise.resolve()), + stopWorkspace: vi.fn(() => Promise.resolve()), + restartWorkspace: vi.fn(() => Promise.resolve()), + updateWorkspace: vi.fn(() => Promise.resolve()), + deleteWorkspace: vi.fn(() => Promise.resolve()), + } satisfies ConstructorParameters[2]; const provider = new WorkspacesPanelProvider( vscode.Uri.file("/test"), store, @@ -280,6 +281,32 @@ describe("WorkspacesPanelProvider", () => { ); }); + interface ActionCase { + action: WorkspaceAction; + command: keyof Panel["commands"]; + } + + it.each([ + { action: "start", command: "startWorkspace" }, + { action: "stop", command: "stopWorkspace" }, + { action: "restart", command: "restartWorkspace" }, + { action: "update", command: "updateWorkspace" }, + { action: "delete", command: "deleteWorkspace" }, + ])( + "runs $action through Commands and follows the workspace", + async ({ action, command }) => { + const h = await shown([alice()]); + await h.send(WorkspacesApi.runWorkspaceAction, { + workspaceId: "workspace-1", + action, + }); + expect(h.commands[command]).toHaveBeenCalledWith( + expect.objectContaining({ id: "workspace-1" }), + ); + expect(h.client.getWorkspace).toHaveBeenCalledWith("workspace-1"); + }, + ); + interface FailedOpenCase { name: string; params: OpenWorkspaceParams; diff --git a/test/webview/shared/workspaces/actions.test.ts b/test/webview/shared/workspaces/actions.test.ts new file mode 100644 index 0000000000..668c7ab0e8 --- /dev/null +++ b/test/webview/shared/workspaces/actions.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; + +import { workspace } from "@repo/mocks"; +import { workspaceActions, type WorkspaceAction } from "@repo/shared"; + +import type { Workspace } from "coder/site/src/api/typesGenerated"; + +interface ActionsCase { + name: string; + workspace: Workspace; + actions: WorkspaceAction[]; +} + +const outdated = ( + status: "running" | "stopped", + policy: Partial = {}, +) => workspace({ ...policy, outdated: true, latest_build: { status } }); + +describe("workspaceActions", () => { + it.each([ + { + name: "running", + workspace: workspace({ latest_build: { status: "running" } }), + actions: ["stop", "restart", "delete"], + }, + { + name: "stopped", + workspace: workspace({ latest_build: { status: "stopped" } }), + actions: ["start", "delete"], + }, + { + name: "canceled", + workspace: workspace({ latest_build: { status: "canceled" } }), + actions: ["start", "stop", "delete"], + }, + { + name: "a failed start, retried by starting", + workspace: workspace({ + latest_build: { status: "failed", transition: "start" }, + }), + actions: ["start", "delete"], + }, + { + name: "a failed stop, retried by stopping", + workspace: workspace({ + latest_build: { status: "failed", transition: "stop" }, + }), + actions: ["stop", "delete"], + }, + { + name: "a failed delete", + workspace: workspace({ + latest_build: { status: "failed", transition: "delete" }, + }), + actions: ["delete"], + }, + { + name: "outdated", + workspace: outdated("stopped"), + actions: ["update", "start", "delete"], + }, + { + name: "running and forced to the active version", + workspace: outdated("running", { template_require_active_version: true }), + actions: ["update", "stop", "delete"], + }, + { + name: "stopped and updating automatically", + workspace: outdated("stopped", { automatic_updates: "always" }), + actions: ["update", "delete"], + }, + { + name: "dormant, which coder start reactivates", + workspace: workspace({ + dormant_at: "2024-01-01T00:00:00Z", + latest_build: { status: "stopped" }, + }), + actions: ["start", "delete"], + }, + { + name: "on an external agent", + workspace: workspace({ latest_build: { has_external_agent: true } }), + actions: ["delete"], + }, + ...( + [ + "pending", + "starting", + "stopping", + "canceling", + "deleting", + "deleted", + ] as const + ).map((status) => ({ + name: status, + workspace: workspace({ latest_build: { status } }), + actions: [], + })), + ])("offers what $name allows", ({ workspace, actions }) => { + expect(workspaceActions(workspace)).toEqual(actions); + }); +}); diff --git a/test/webview/workspaces/components/WorkspacesPanel.test.tsx b/test/webview/workspaces/components/WorkspacesPanel.test.tsx index 3cd42ee51a..a073dc37db 100644 --- a/test/webview/workspaces/components/WorkspacesPanel.test.tsx +++ b/test/webview/workspaces/components/WorkspacesPanel.test.tsx @@ -4,7 +4,11 @@ import { format, formatISO } from "date-fns"; import { describe, expect, it, onTestFinished, vi } from "vitest"; import { agent, agentMetadata, app, appStatus, workspace } from "@repo/mocks"; -import { WorkspacesApi, type WorkspacesState } from "@repo/shared"; +import { + WorkspacesApi, + type Workspace, + type WorkspacesState, +} from "@repo/shared"; import App from "@repo/workspaces/App"; import { @@ -185,44 +189,141 @@ describe("WorkspacesPanel", () => { ]); }); - it("opens workspace settings from the menu", async () => { - const { user, drain } = await setup(); + /** Open a row's "More actions" menu. */ + async function openMenu( + user: ReturnType, + name: string, + ) { await user.click( - screen.getByRole("button", { name: "More actions for beta" }), + screen.getByRole("button", { name: `More actions for ${name}` }), ); - const menu = await screen.findByRole("menu"); - await user.click(within(menu).getByRole("menuitem", { name: "Settings" })); - expect(drain(WorkspacesApi.viewInDashboard.method)).toEqual([ - { workspaceId: "ws-beta", page: "settings" }, - ]); + return screen.findByRole("menu"); + } + + const enabledLabels = (menu: HTMLElement) => + within(menu) + .getAllByRole("menuitem") + .filter((item) => item.getAttribute("aria-disabled") !== "true") + .map((item) => item.textContent); + + interface MenuCase { + name: string; + build: Partial; + outdated?: boolean; + items: string[]; + } + + it.each([ + { + name: "running", + build: { status: "running" }, + items: ["Stop", "Restart", "Share", "Settings", "Delete..."], + }, + { + name: "outdated and stopped", + build: { status: "stopped" }, + outdated: true, + items: ["Update", "Start", "Share", "Settings", "Delete..."], + }, + { + name: "a failed start", + build: { status: "failed", transition: "start" }, + items: ["Retry", "Share", "Settings", "Delete..."], + }, + { + name: "starting", + build: { status: "starting" }, + items: ["Share", "Settings"], + }, + ])("offers the actions $name allows", async ({ build, outdated, items }) => { + const { user } = await setup({ + ...STATE, + workspaces: [workspace({ outdated, latest_build: build })], + }); + const menu = await openMenu(user, "test-workspace"); + expect(enabledLabels(menu)).toEqual(items); }); - it.each([ - ["running", ["Stop", "Restart"]], - ["stopped", ["Start"]], - ["canceled", ["Retry"]], - ["starting", []], - ] as const)( - "offers lifecycle actions appropriate to %s", - async (status, actions) => { - const { user } = await setup({ - ...STATE, - workspaces: [workspace({ latest_build: { status } })], - }); - await user.click( - screen.getByRole("button", { name: "More actions for test-workspace" }), - ); - const menu = await screen.findByRole("menu"); - for (const name of ["Start", "Stop", "Restart", "Retry"]) { - const item = within(menu).queryByRole("menuitem", { name }); - if (actions.some((action) => action === name)) { - expect(item).toHaveAttribute("aria-disabled", "true"); - } else { - expect(item).not.toBeInTheDocument(); - } - } + interface SendCase { + name: string; + build: Partial; + item: string; + method: string; + params: object; + } + + it.each([ + { + name: "stops", + build: { status: "running" }, + item: "Stop", + method: WorkspacesApi.runWorkspaceAction.method, + params: { workspaceId: "workspace-1", action: "stop" }, }, - ); + { + name: "retries a failed stop by stopping", + build: { status: "failed", transition: "stop" }, + item: "Retry", + method: WorkspacesApi.runWorkspaceAction.method, + params: { workspaceId: "workspace-1", action: "stop" }, + }, + { + name: "deletes", + build: { status: "stopped" }, + item: "Delete...", + method: WorkspacesApi.runWorkspaceAction.method, + params: { workspaceId: "workspace-1", action: "delete" }, + }, + { + name: "shares in the dashboard", + build: { status: "running" }, + item: "Share", + method: WorkspacesApi.viewInDashboard.method, + params: { workspaceId: "workspace-1", page: "sharing" }, + }, + { + name: "opens settings in the dashboard", + build: { status: "running" }, + item: "Settings", + method: WorkspacesApi.viewInDashboard.method, + params: { workspaceId: "workspace-1", page: "settings" }, + }, + ])("$name from the menu", async ({ build, item, method, params }) => { + const { user, drain } = await setup({ + ...STATE, + workspaces: [workspace({ latest_build: build })], + }); + const menu = await openMenu(user, "test-workspace"); + await user.click(within(menu).getByRole("menuitem", { name: item })); + expect(drain(method)).toEqual([params]); + }); + + it("shows a pending action and disables the lifecycle actions", async () => { + stubElementBoxes(); + const { user, drain } = await setup({ + ...STATE, + pendingActions: { [ALPHA.id]: "stop" }, + }); + expect( + within(row("alpha")).getByRole("status", { name: "Stopping" }), + ).toBeInTheDocument(); + expect(within(row("beta")).queryByRole("status")).not.toBeInTheDocument(); + + await user.click(row("alpha")); + expect(hoverDetails(await showHover(user))).toContainEqual([ + "Status", + "Stopping", + ]); + await user.keyboard("{Escape}"); + + const menu = await openMenu(user, "alpha"); + expect(menu).toHaveAccessibleDescription( + "Ping, Speed Test and Support Bundle are not supported by this panel yet.", + ); + expect(enabledLabels(menu)).toEqual(["Share", "Settings"]); + await user.click(within(menu).getByRole("menuitem", { name: "Stop" })); + expect(drain(WorkspacesApi.runWorkspaceAction.method)).toEqual([]); + }); it("describes workspaces, workspace agents and metadata on hover", async () => { stubElementBoxes();