From a6394470eb7e32ad39fb94bd662680e9ed7d27c3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:00:42 +0000 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20add=20coder=20ar?= =?UTF-8?q?chive=20behavior=20config=20enum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/common/config/coderArchiveBehavior.ts | 5 + src/common/config/schemas/appConfigOnDisk.ts | 2 + src/common/types/project.ts | 10 +- src/node/config.test.ts | 113 +++++++++++++++++++ src/node/config.ts | 72 +++++++++++- 5 files changed, 194 insertions(+), 8 deletions(-) create mode 100644 src/common/config/coderArchiveBehavior.ts diff --git a/src/common/config/coderArchiveBehavior.ts b/src/common/config/coderArchiveBehavior.ts new file mode 100644 index 0000000000..3fd5b6cf73 --- /dev/null +++ b/src/common/config/coderArchiveBehavior.ts @@ -0,0 +1,5 @@ +export const CODER_ARCHIVE_BEHAVIORS = ["keep", "stop", "delete"] as const; + +export type CoderWorkspaceArchiveBehavior = (typeof CODER_ARCHIVE_BEHAVIORS)[number]; + +export const DEFAULT_CODER_ARCHIVE_BEHAVIOR: CoderWorkspaceArchiveBehavior = "stop"; diff --git a/src/common/config/schemas/appConfigOnDisk.ts b/src/common/config/schemas/appConfigOnDisk.ts index ed65a8208a..fa9cc5e847 100644 --- a/src/common/config/schemas/appConfigOnDisk.ts +++ b/src/common/config/schemas/appConfigOnDisk.ts @@ -4,6 +4,7 @@ import { AgentIdSchema, RuntimeEnablementIdSchema } from "../../schemas/ids"; import { ProjectConfigSchema } from "../../schemas/project"; import { RuntimeEnablementOverridesSchema } from "../../schemas/runtimeEnablement"; import { ThinkingLevelSchema } from "../../types/thinking"; +import { CODER_ARCHIVE_BEHAVIORS } from "../coderArchiveBehavior"; import { TaskSettingsSchema } from "./taskSettings"; export { RuntimeEnablementOverridesSchema } from "../../schemas/runtimeEnablement"; @@ -58,6 +59,7 @@ export const AppConfigOnDiskSchema = z useSSH2Transport: z.boolean().optional(), muxGovernorUrl: z.string().optional(), muxGovernorToken: z.string().optional(), + coderWorkspaceArchiveBehavior: z.enum(CODER_ARCHIVE_BEHAVIORS).optional(), stopCoderWorkspaceOnArchive: z.boolean().optional(), terminalDefaultShell: z.string().optional(), updateChannel: UpdateChannelSchema.optional(), diff --git a/src/common/types/project.ts b/src/common/types/project.ts index 81d608c9ea..c3b58917e6 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -3,6 +3,7 @@ * Kept lightweight for preload script usage. */ +import type { CoderWorkspaceArchiveBehavior } from "@/common/config/coderArchiveBehavior"; import type { FeatureFlagOverride, UpdateChannel } from "@/common/config/schemas/appConfigOnDisk"; import type { z } from "zod"; import type { @@ -112,8 +113,13 @@ export interface ProjectsConfig { muxGovernorToken?: string; /** - * When true (default), archiving a Mux workspace will stop its dedicated mux-created Coder - * workspace first, and unarchiving will attempt to start it again. + * What to do with a dedicated mux-created Coder workspace when its chat is archived. + * Defaults to `"stop"` to preserve existing behavior. + */ + coderWorkspaceArchiveBehavior?: CoderWorkspaceArchiveBehavior; + + /** + * Legacy boolean shim for downgrade compatibility. * * Stored as `false` only (undefined behaves as true) to keep config.json minimal. */ diff --git a/src/node/config.test.ts b/src/node/config.test.ts index 3a74224cd9..e31d63e0ab 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -2,6 +2,10 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { Config } from "./config"; +import { + CODER_ARCHIVE_BEHAVIORS, + DEFAULT_CODER_ARCHIVE_BEHAVIOR, +} from "@/common/config/coderArchiveBehavior"; import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; import { type ExternalSecretResolver, secretsToRecord } from "@/common/types/secrets"; @@ -182,6 +186,115 @@ describe("Config", () => { }); }); + describe("coderWorkspaceArchiveBehavior", () => { + const readRawArchiveConfig = () => + JSON.parse(fs.readFileSync(path.join(tempDir, "config.json"), "utf-8")) as { + coderWorkspaceArchiveBehavior?: unknown; + stopCoderWorkspaceOnArchive?: unknown; + terminalDefaultShell?: unknown; + }; + + const legacyBooleanForBehavior = (behavior: string): false | undefined => + behavior === "keep" ? false : undefined; + + for (const behavior of CODER_ARCHIVE_BEHAVIORS) { + it(`loads the new enum value ${behavior}`, () => { + fs.writeFileSync( + path.join(tempDir, "config.json"), + JSON.stringify({ + projects: [], + coderWorkspaceArchiveBehavior: behavior, + }) + ); + + const loaded = config.loadConfigOrDefault(); + expect(loaded.coderWorkspaceArchiveBehavior).toBe(behavior); + expect(loaded.stopCoderWorkspaceOnArchive).toBe(legacyBooleanForBehavior(behavior)); + }); + } + + it("resolves legacy false to keep when the enum is missing", () => { + fs.writeFileSync( + path.join(tempDir, "config.json"), + JSON.stringify({ + projects: [], + stopCoderWorkspaceOnArchive: false, + }) + ); + + const loaded = config.loadConfigOrDefault(); + expect(loaded.coderWorkspaceArchiveBehavior).toBe("keep"); + expect(loaded.stopCoderWorkspaceOnArchive).toBe(false); + }); + + it("resolves legacy true or undefined to stop when the enum is missing", () => { + fs.writeFileSync( + path.join(tempDir, "config.json"), + JSON.stringify({ + projects: [], + stopCoderWorkspaceOnArchive: true, + }) + ); + expect(config.loadConfigOrDefault().coderWorkspaceArchiveBehavior).toBe( + DEFAULT_CODER_ARCHIVE_BEHAVIOR + ); + + fs.writeFileSync(path.join(tempDir, "config.json"), JSON.stringify({ projects: [] })); + expect(config.loadConfigOrDefault().coderWorkspaceArchiveBehavior).toBe( + DEFAULT_CODER_ARCHIVE_BEHAVIOR + ); + }); + + it("prefers the new enum when both fields are present", () => { + fs.writeFileSync( + path.join(tempDir, "config.json"), + JSON.stringify({ + projects: [], + coderWorkspaceArchiveBehavior: "delete", + stopCoderWorkspaceOnArchive: false, + }) + ); + + const loaded = config.loadConfigOrDefault(); + expect(loaded.coderWorkspaceArchiveBehavior).toBe("delete"); + expect(loaded.stopCoderWorkspaceOnArchive).toBeUndefined(); + }); + + it("falls back to stop when the enum value is invalid", () => { + fs.writeFileSync( + path.join(tempDir, "config.json"), + JSON.stringify({ + projects: [], + coderWorkspaceArchiveBehavior: "hibernate", + terminalDefaultShell: "zsh", + }) + ); + + const loaded = config.loadConfigOrDefault(); + expect(loaded.coderWorkspaceArchiveBehavior).toBe(DEFAULT_CODER_ARCHIVE_BEHAVIOR); + expect(loaded.stopCoderWorkspaceOnArchive).toBeUndefined(); + expect(loaded.terminalDefaultShell).toBe("zsh"); + }); + + it("round-trips each behavior with the enum field and legacy shim", async () => { + for (const behavior of CODER_ARCHIVE_BEHAVIORS) { + await config.editConfig((cfg) => { + cfg.coderWorkspaceArchiveBehavior = behavior; + cfg.stopCoderWorkspaceOnArchive = legacyBooleanForBehavior(behavior); + return cfg; + }); + + const raw = readRawArchiveConfig(); + expect(raw.coderWorkspaceArchiveBehavior).toBe(behavior); + expect(raw.stopCoderWorkspaceOnArchive).toBe(legacyBooleanForBehavior(behavior)); + + const reloaded = new Config(tempDir).loadConfigOrDefault(); + expect(reloaded.coderWorkspaceArchiveBehavior).toBe(behavior); + expect(reloaded.stopCoderWorkspaceOnArchive).toBe(legacyBooleanForBehavior(behavior)); + } + }); + }); + describe("model preferences", () => { it("should preserve explicit gateway-scoped defaultModel and hiddenModels", async () => { await config.editConfig((cfg) => { diff --git a/src/node/config.ts b/src/node/config.ts index d10737c0bb..3434210b4a 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -36,6 +36,11 @@ import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import { isIncompatibleRuntimeConfig } from "@/common/utils/runtimeCompatibility"; import { getMuxHome } from "@/common/constants/paths"; import { GATEWAY_PROVIDERS } from "@/common/constants/providers"; +import { + CODER_ARCHIVE_BEHAVIORS, + DEFAULT_CODER_ARCHIVE_BEHAVIOR, + type CoderWorkspaceArchiveBehavior, +} from "@/common/config/coderArchiveBehavior"; import { PlatformPaths } from "@/common/utils/paths"; import { isValidModelFormat, @@ -92,6 +97,50 @@ function parseUpdateChannel(value: unknown): UpdateChannel | undefined { return undefined; } +function parseCoderWorkspaceArchiveBehavior( + value: unknown +): CoderWorkspaceArchiveBehavior | undefined { + if (typeof value !== "string") { + return undefined; + } + + return CODER_ARCHIVE_BEHAVIORS.includes(value as CoderWorkspaceArchiveBehavior) + ? (value as CoderWorkspaceArchiveBehavior) + : undefined; +} + +function resolveCoderWorkspaceArchiveBehavior( + coderWorkspaceArchiveBehavior: unknown, + stopCoderWorkspaceOnArchive: unknown +): CoderWorkspaceArchiveBehavior { + const parsedBehavior = parseCoderWorkspaceArchiveBehavior(coderWorkspaceArchiveBehavior); + if (parsedBehavior !== undefined) { + return parsedBehavior; + } + + return parseOptionalBoolean(stopCoderWorkspaceOnArchive) === false + ? "keep" + : DEFAULT_CODER_ARCHIVE_BEHAVIOR; +} + +function getLegacyStopCoderWorkspaceOnArchiveValue( + coderWorkspaceArchiveBehavior: CoderWorkspaceArchiveBehavior +): false | undefined { + return coderWorkspaceArchiveBehavior === "keep" ? false : undefined; +} + +function resolveCoderWorkspaceArchiveBehaviorForSave( + config: Pick +): CoderWorkspaceArchiveBehavior { + const parsedBehavior = parseCoderWorkspaceArchiveBehavior(config.coderWorkspaceArchiveBehavior); + if (config.stopCoderWorkspaceOnArchive === false && parsedBehavior !== "delete") { + // Keep legacy writers working until the router/settings UI starts sending the enum field. + return "keep"; + } + + return parsedBehavior ?? DEFAULT_CODER_ARCHIVE_BEHAVIOR; +} + function parseOptionalStringArray(value: unknown): string[] | undefined { if (!Array.isArray(value)) { return undefined; @@ -566,9 +615,13 @@ export class Config { const hiddenModels = normalizeOptionalModelStringArray(parsed.hiddenModels); const legacySubagentAiDefaults = normalizeSubagentAiDefaults(parsed.subagentAiDefaults); - // Default ON: store `false` only so config.json stays minimal. - const stopCoderWorkspaceOnArchive = - parseOptionalBoolean(parsed.stopCoderWorkspaceOnArchive) === false ? false : undefined; + const coderWorkspaceArchiveBehavior = resolveCoderWorkspaceArchiveBehavior( + parsed.coderWorkspaceArchiveBehavior, + parsed.stopCoderWorkspaceOnArchive + ); + const stopCoderWorkspaceOnArchive = getLegacyStopCoderWorkspaceOnArchiveValue( + coderWorkspaceArchiveBehavior + ); const updateChannel = parseUpdateChannel(parsed.updateChannel); const runtimeEnablement = normalizeRuntimeEnablementOverrides(parsed.runtimeEnablement); @@ -611,6 +664,7 @@ export class Config { useSSH2Transport: parseOptionalBoolean(parsed.useSSH2Transport), muxGovernorUrl: parseOptionalNonEmptyString(parsed.muxGovernorUrl), muxGovernorToken: parseOptionalNonEmptyString(parsed.muxGovernorToken), + coderWorkspaceArchiveBehavior, stopCoderWorkspaceOnArchive, terminalDefaultShell: parseOptionalNonEmptyString(parsed.terminalDefaultShell), updateChannel, @@ -630,6 +684,7 @@ export class Config { agentAiDefaults: {}, subagentAiDefaults: {}, routePriority: this.seedRoutePriorityFromProviders(), + coderWorkspaceArchiveBehavior: DEFAULT_CODER_ARCHIVE_BEHAVIOR, }; } @@ -765,9 +820,14 @@ export class Config { data.muxGovernorToken = muxGovernorToken; } - // Default ON: persist `false` only. - if (config.stopCoderWorkspaceOnArchive === false) { - data.stopCoderWorkspaceOnArchive = false; + const coderWorkspaceArchiveBehavior = resolveCoderWorkspaceArchiveBehaviorForSave(config); + data.coderWorkspaceArchiveBehavior = coderWorkspaceArchiveBehavior; + + const stopCoderWorkspaceOnArchive = getLegacyStopCoderWorkspaceOnArchiveValue( + coderWorkspaceArchiveBehavior + ); + if (stopCoderWorkspaceOnArchive !== undefined) { + data.stopCoderWorkspaceOnArchive = stopCoderWorkspaceOnArchive; } const terminalDefaultShell = parseOptionalNonEmptyString(config.terminalDefaultShell); From d9c891796a4cb99ec261dad19e8ad4014f9b973e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:09:27 +0000 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20use=20coder=20ar?= =?UTF-8?q?chive=20behavior=20enum=20in=20backend=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/common/orpc/schemas/api.ts | 5 +- src/node/orpc/router.ts | 7 +- src/node/runtime/coderLifecycleHooks.test.ts | 494 +++++++++++-------- src/node/runtime/coderLifecycleHooks.ts | 52 +- src/node/services/serviceContainer.ts | 18 +- 5 files changed, 331 insertions(+), 245 deletions(-) diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 8c8dbc46e5..fb09ab353c 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -1,6 +1,7 @@ import { eventIterator } from "@orpc/server"; import { UIModeSchema } from "../../types/mode"; import { z } from "zod"; +import { CODER_ARCHIVE_BEHAVIORS } from "@/common/config/coderArchiveBehavior"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import { ChatStatsSchema, SessionUsageFileSchema } from "./chatStats"; import { @@ -1696,7 +1697,7 @@ export const config = { routeOverrides: z.record(z.string(), z.string()).optional(), defaultModel: z.string().optional(), hiddenModels: z.array(z.string()).optional(), - stopCoderWorkspaceOnArchive: z.boolean(), + coderWorkspaceArchiveBehavior: z.enum(CODER_ARCHIVE_BEHAVIORS), runtimeEnablement: z.record(z.string(), z.boolean()), defaultRuntime: z.string().nullable(), agentAiDefaults: AgentAiDefaultsSchema, @@ -1752,7 +1753,7 @@ export const config = { updateCoderPrefs: { input: z .object({ - stopCoderWorkspaceOnArchive: z.boolean(), + coderWorkspaceArchiveBehavior: z.enum(CODER_ARCHIVE_BEHAVIORS), }) .strict(), output: z.void(), diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index b7dcb5cf61..86e9e03f1b 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1,4 +1,5 @@ import { os, ORPCError } from "@orpc/server"; +import { DEFAULT_CODER_ARCHIVE_BEHAVIOR } from "@/common/config/coderArchiveBehavior"; import * as schemas from "@/common/orpc/schemas"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import type { ORPCContext } from "./context"; @@ -586,7 +587,8 @@ export const router = (authToken?: string) => { routeOverrides: config.routeOverrides, defaultModel: config.defaultModel, hiddenModels: config.hiddenModels, - stopCoderWorkspaceOnArchive: config.stopCoderWorkspaceOnArchive !== false, + coderWorkspaceArchiveBehavior: + config.coderWorkspaceArchiveBehavior ?? DEFAULT_CODER_ARCHIVE_BEHAVIOR, runtimeEnablement: normalizeRuntimeEnablement(config.runtimeEnablement), defaultRuntime: config.defaultRuntime ?? null, agentAiDefaults: config.agentAiDefaults ?? {}, @@ -776,8 +778,7 @@ export const router = (authToken?: string) => { await context.config.editConfig((config) => { return { ...config, - // Default ON: store `false` only. - stopCoderWorkspaceOnArchive: input.stopCoderWorkspaceOnArchive ? undefined : false, + coderWorkspaceArchiveBehavior: input.coderWorkspaceArchiveBehavior, }; }); }), diff --git a/src/node/runtime/coderLifecycleHooks.test.ts b/src/node/runtime/coderLifecycleHooks.test.ts index 61f963c632..c61abc11ea 100644 --- a/src/node/runtime/coderLifecycleHooks.test.ts +++ b/src/node/runtime/coderLifecycleHooks.test.ts @@ -1,11 +1,9 @@ import { describe, expect, it, mock } from "bun:test"; -import { - createStartCoderOnUnarchiveHook, - createStopCoderOnArchiveHook, -} from "./coderLifecycleHooks"; -import { Ok } from "@/common/types/result"; -import type { CoderService, WorkspaceStatusResult } from "@/node/services/coderService"; +import { CODER_ARCHIVE_BEHAVIORS } from "@/common/config/coderArchiveBehavior"; +import { Err, Ok, type Result } from "@/common/types/result"; import type { WorkspaceMetadata } from "@/common/types/workspace"; +import type { CoderService, WorkspaceStatusResult } from "@/node/services/coderService"; +import { createCoderArchiveHook, createCoderUnarchiveHook } from "./coderLifecycleHooks"; function createSshCoderMetadata(overrides?: Partial): WorkspaceMetadata { return { @@ -25,24 +23,78 @@ function createSshCoderMetadata(overrides?: Partial): Workspa }; } -describe("createStopCoderOnArchiveHook", () => { - it("does nothing when stop-on-archive is disabled", async () => { - const getWorkspaceStatus = mock<(workspaceName: string) => Promise>(() => - Promise.resolve({ kind: "ok", status: "running" }) +type GetWorkspaceStatusMock = ReturnType< + typeof mock< + (workspaceName: string, options?: { timeoutMs?: number }) => Promise + > +>; +type StopWorkspaceMock = ReturnType< + typeof mock<(workspaceName: string, options?: { timeoutMs?: number }) => Promise>> +>; +type StartWorkspaceMock = ReturnType< + typeof mock<(workspaceName: string, options?: { timeoutMs?: number }) => Promise>> +>; +type DeleteWorkspaceMock = ReturnType Promise>>; + +function createCoderServiceMocks(overrides?: { + getWorkspaceStatus?: GetWorkspaceStatusMock; + stopWorkspace?: StopWorkspaceMock; + startWorkspace?: StartWorkspaceMock; + deleteWorkspace?: DeleteWorkspaceMock; +}): { + coderService: CoderService; + getWorkspaceStatus: GetWorkspaceStatusMock; + stopWorkspace: StopWorkspaceMock; + startWorkspace: StartWorkspaceMock; + deleteWorkspace: DeleteWorkspaceMock; +} { + const getWorkspaceStatus = + overrides?.getWorkspaceStatus ?? + mock< + (workspaceName: string, options?: { timeoutMs?: number }) => Promise + >(() => Promise.resolve({ kind: "ok", status: "running" })); + const stopWorkspace = + overrides?.stopWorkspace ?? + mock<(workspaceName: string, options?: { timeoutMs?: number }) => Promise>>(() => + Promise.resolve(Ok(undefined)) ); - - const stopWorkspace = mock<(workspaceName: string) => Promise>>(() => + const startWorkspace = + overrides?.startWorkspace ?? + mock<(workspaceName: string, options?: { timeoutMs?: number }) => Promise>>(() => Promise.resolve(Ok(undefined)) ); + const deleteWorkspace = + overrides?.deleteWorkspace ?? + mock<(workspaceName: string) => Promise>(() => Promise.resolve()); - const coderService = { + return { + coderService: { getWorkspaceStatus, stopWorkspace, - } as unknown as CoderService; + startWorkspace, + deleteWorkspace, + } as unknown as CoderService, + getWorkspaceStatus, + stopWorkspace, + startWorkspace, + deleteWorkspace, + }; +} + +function expectError(result: Result): string { + expect(result.success).toBe(false); + if (result.success) { + throw new Error("Expected lifecycle hook to fail"); + } + return result.error; +} - const hook = createStopCoderOnArchiveHook({ - coderService, - shouldStopOnArchive: () => false, +describe("createCoderArchiveHook", () => { + it("does nothing when archive behavior is keep", async () => { + const service = createCoderServiceMocks(); + const hook = createCoderArchiveHook({ + coderService: service.coderService, + getArchiveBehavior: () => "keep", }); const result = await hook({ @@ -51,112 +103,101 @@ describe("createStopCoderOnArchiveHook", () => { }); expect(result.success).toBe(true); - expect(getWorkspaceStatus).toHaveBeenCalledTimes(0); - expect(stopWorkspace).toHaveBeenCalledTimes(0); + expect(service.getWorkspaceStatus).toHaveBeenCalledTimes(0); + expect(service.stopWorkspace).toHaveBeenCalledTimes(0); + expect(service.deleteWorkspace).toHaveBeenCalledTimes(0); }); - it("does nothing when connected to an existing Coder workspace", async () => { - const getWorkspaceStatus = mock<(workspaceName: string) => Promise>(() => - Promise.resolve({ kind: "ok", status: "running" }) - ); - - const stopWorkspace = mock<(workspaceName: string) => Promise>>(() => - Promise.resolve(Ok(undefined)) - ); - - const coderService = { - getWorkspaceStatus, - stopWorkspace, - } as unknown as CoderService; - - const hook = createStopCoderOnArchiveHook({ - coderService, - shouldStopOnArchive: () => true, + for (const archiveBehavior of CODER_ARCHIVE_BEHAVIORS) { + it(`skips existing Coder workspaces when archive behavior is ${archiveBehavior}`, async () => { + const service = createCoderServiceMocks(); + const hook = createCoderArchiveHook({ + coderService: service.coderService, + getArchiveBehavior: () => archiveBehavior, + }); + + const result = await hook({ + workspaceId: "ws", + workspaceMetadata: createSshCoderMetadata({ + runtimeConfig: { + type: "ssh", + host: "coder://", + srcBaseDir: "~/mux", + coder: { workspaceName: "mux-ws", existingWorkspace: true }, + }, + }), + }); + + expect(result.success).toBe(true); + expect(service.getWorkspaceStatus).toHaveBeenCalledTimes(0); + expect(service.stopWorkspace).toHaveBeenCalledTimes(0); + expect(service.deleteWorkspace).toHaveBeenCalledTimes(0); }); + } - const result = await hook({ - workspaceId: "ws", - workspaceMetadata: createSshCoderMetadata({ - runtimeConfig: { - type: "ssh", - host: "coder://", - srcBaseDir: "~/mux", - coder: { workspaceName: "mux-ws", existingWorkspace: true }, - }, - }), + it("stops a running dedicated Coder workspace when archive behavior is stop", async () => { + const service = createCoderServiceMocks({ + getWorkspaceStatus: mock< + (workspaceName: string, options?: { timeoutMs?: number }) => Promise + >(() => Promise.resolve({ kind: "ok", status: "running" })), }); - - expect(result.success).toBe(true); - expect(getWorkspaceStatus).toHaveBeenCalledTimes(0); - expect(stopWorkspace).toHaveBeenCalledTimes(0); - }); - - it("stops a running dedicated Coder workspace", async () => { - const getWorkspaceStatus = mock< - (workspaceName: string, options?: { timeoutMs?: number }) => Promise - >(() => Promise.resolve({ kind: "ok", status: "running" })); - - const stopWorkspace = mock< - (workspaceName: string, options?: { timeoutMs?: number }) => Promise> - >(() => Promise.resolve(Ok(undefined))); - - const coderService = { - getWorkspaceStatus, - stopWorkspace, - } as unknown as CoderService; - - const hook = createStopCoderOnArchiveHook({ - coderService, - shouldStopOnArchive: () => true, + const hook = createCoderArchiveHook({ + coderService: service.coderService, + getArchiveBehavior: () => "stop", timeoutMs: 1234, }); const result = await hook({ workspaceId: "ws", - workspaceMetadata: createSshCoderMetadata({ - runtimeConfig: { - type: "ssh", - host: "coder://", - srcBaseDir: "~/mux", - coder: { workspaceName: "mux-ws" }, - }, - }), + workspaceMetadata: createSshCoderMetadata(), }); expect(result.success).toBe(true); + expect(service.getWorkspaceStatus).toHaveBeenCalledTimes(1); + expect(service.getWorkspaceStatus).toHaveBeenCalledWith("mux-ws", expect.any(Object)); - expect(getWorkspaceStatus).toHaveBeenCalledTimes(1); - expect(getWorkspaceStatus).toHaveBeenCalledWith("mux-ws", expect.any(Object)); - - const statusOptions = (getWorkspaceStatus as ReturnType).mock.calls[0]?.[1] as { + const statusOptions = service.getWorkspaceStatus.mock.calls[0]?.[1] as { timeoutMs?: number; }; expect(typeof statusOptions.timeoutMs).toBe("number"); expect(statusOptions.timeoutMs).toBeGreaterThan(0); - expect(stopWorkspace).toHaveBeenCalledTimes(1); - expect(stopWorkspace).toHaveBeenCalledWith("mux-ws", { timeoutMs: 1234 }); + expect(service.stopWorkspace).toHaveBeenCalledTimes(1); + expect(service.stopWorkspace).toHaveBeenCalledWith("mux-ws", { timeoutMs: 1234 }); + expect(service.deleteWorkspace).toHaveBeenCalledTimes(0); }); -}); -describe("createStartCoderOnUnarchiveHook", () => { - it("does nothing when stop-on-archive is disabled", async () => { - const getWorkspaceStatus = mock<(workspaceName: string) => Promise>(() => - Promise.resolve({ kind: "ok", status: "stopped" }) - ); + it("deletes a dedicated Coder workspace when archive behavior is delete", async () => { + const service = createCoderServiceMocks(); + const hook = createCoderArchiveHook({ + coderService: service.coderService, + getArchiveBehavior: () => "delete", + }); - const startWorkspace = mock<(workspaceName: string) => Promise>>(() => - Promise.resolve(Ok(undefined)) - ); + const result = await hook({ + workspaceId: "ws", + workspaceMetadata: createSshCoderMetadata(), + }); - const coderService = { - getWorkspaceStatus, - startWorkspace, - } as unknown as CoderService; + expect(result.success).toBe(true); + expect(service.getWorkspaceStatus).toHaveBeenCalledTimes(0); + expect(service.stopWorkspace).toHaveBeenCalledTimes(0); + expect(service.deleteWorkspace).toHaveBeenCalledTimes(1); + expect(service.deleteWorkspace).toHaveBeenCalledWith("mux-ws"); + }); - const hook = createStartCoderOnUnarchiveHook({ - coderService, - shouldStopOnArchive: () => false, + it("returns Err when stopping a dedicated Coder workspace fails", async () => { + const service = createCoderServiceMocks({ + getWorkspaceStatus: mock< + (workspaceName: string, options?: { timeoutMs?: number }) => Promise + >(() => Promise.resolve({ kind: "ok", status: "running" })), + stopWorkspace: mock< + (workspaceName: string, options?: { timeoutMs?: number }) => Promise> + >(() => Promise.resolve(Err("boom"))), + }); + const hook = createCoderArchiveHook({ + coderService: service.coderService, + getArchiveBehavior: () => "stop", }); const result = await hook({ @@ -164,118 +205,154 @@ describe("createStartCoderOnUnarchiveHook", () => { workspaceMetadata: createSshCoderMetadata(), }); - expect(result.success).toBe(true); - expect(getWorkspaceStatus).toHaveBeenCalledTimes(0); - expect(startWorkspace).toHaveBeenCalledTimes(0); + expect(expectError(result)).toContain('Failed to stop Coder workspace "mux-ws": boom'); + expect(service.stopWorkspace).toHaveBeenCalledTimes(1); }); - it("does nothing when connected to an existing Coder workspace", async () => { - const getWorkspaceStatus = mock<(workspaceName: string) => Promise>(() => - Promise.resolve({ kind: "ok", status: "stopped" }) - ); + it("returns Err when deleting a dedicated Coder workspace fails", async () => { + const service = createCoderServiceMocks({ + deleteWorkspace: mock<(workspaceName: string) => Promise>(() => + Promise.reject(new Error("boom")) + ), + }); + const hook = createCoderArchiveHook({ + coderService: service.coderService, + getArchiveBehavior: () => "delete", + }); - const startWorkspace = mock<(workspaceName: string) => Promise>>(() => - Promise.resolve(Ok(undefined)) - ); + const result = await hook({ + workspaceId: "ws", + workspaceMetadata: createSshCoderMetadata(), + }); - const coderService = { - getWorkspaceStatus, - startWorkspace, - } as unknown as CoderService; + expect(expectError(result)).toContain('Failed to delete Coder workspace "mux-ws": boom'); + expect(service.deleteWorkspace).toHaveBeenCalledTimes(1); + }); +}); - const hook = createStartCoderOnUnarchiveHook({ - coderService, - shouldStopOnArchive: () => true, +describe("createCoderUnarchiveHook", () => { + it("does nothing when archive behavior is keep", async () => { + const service = createCoderServiceMocks({ + getWorkspaceStatus: mock< + (workspaceName: string, options?: { timeoutMs?: number }) => Promise + >(() => Promise.resolve({ kind: "ok", status: "stopped" })), + }); + const hook = createCoderUnarchiveHook({ + coderService: service.coderService, + getArchiveBehavior: () => "keep", }); const result = await hook({ workspaceId: "ws", - workspaceMetadata: createSshCoderMetadata({ - runtimeConfig: { - type: "ssh", - host: "coder://", - srcBaseDir: "~/mux", - coder: { workspaceName: "mux-ws", existingWorkspace: true }, - }, - }), + workspaceMetadata: createSshCoderMetadata(), }); expect(result.success).toBe(true); - expect(getWorkspaceStatus).toHaveBeenCalledTimes(0); - expect(startWorkspace).toHaveBeenCalledTimes(0); + expect(service.getWorkspaceStatus).toHaveBeenCalledTimes(0); + expect(service.startWorkspace).toHaveBeenCalledTimes(0); }); - it("starts a stopped dedicated Coder workspace", async () => { - const getWorkspaceStatus = mock< - (workspaceName: string, options?: { timeoutMs?: number }) => Promise - >(() => Promise.resolve({ kind: "ok", status: "stopped" })); - - const startWorkspace = mock< - (workspaceName: string, options?: { timeoutMs?: number }) => Promise> - >(() => Promise.resolve(Ok(undefined))); - - const coderService = { - getWorkspaceStatus, - startWorkspace, - } as unknown as CoderService; + for (const archiveBehavior of CODER_ARCHIVE_BEHAVIORS) { + it(`skips existing Coder workspaces when unarchive behavior is ${archiveBehavior}`, async () => { + const service = createCoderServiceMocks({ + getWorkspaceStatus: mock< + ( + workspaceName: string, + options?: { timeoutMs?: number } + ) => Promise + >(() => Promise.resolve({ kind: "ok", status: "stopped" })), + }); + const hook = createCoderUnarchiveHook({ + coderService: service.coderService, + getArchiveBehavior: () => archiveBehavior, + }); + + const result = await hook({ + workspaceId: "ws", + workspaceMetadata: createSshCoderMetadata({ + runtimeConfig: { + type: "ssh", + host: "coder://", + srcBaseDir: "~/mux", + coder: { workspaceName: "mux-ws", existingWorkspace: true }, + }, + }), + }); + + expect(result.success).toBe(true); + expect(service.getWorkspaceStatus).toHaveBeenCalledTimes(0); + expect(service.startWorkspace).toHaveBeenCalledTimes(0); + }); + } - const hook = createStartCoderOnUnarchiveHook({ - coderService, - shouldStopOnArchive: () => true, + it("starts a stopped dedicated Coder workspace when archive behavior is stop", async () => { + const service = createCoderServiceMocks({ + getWorkspaceStatus: mock< + (workspaceName: string, options?: { timeoutMs?: number }) => Promise + >(() => Promise.resolve({ kind: "ok", status: "stopped" })), + }); + const hook = createCoderUnarchiveHook({ + coderService: service.coderService, + getArchiveBehavior: () => "stop", timeoutMs: 1234, }); const result = await hook({ workspaceId: "ws", - workspaceMetadata: createSshCoderMetadata({ - runtimeConfig: { - type: "ssh", - host: "coder://", - srcBaseDir: "~/mux", - coder: { workspaceName: "mux-ws" }, - }, - }), + workspaceMetadata: createSshCoderMetadata(), }); expect(result.success).toBe(true); + expect(service.getWorkspaceStatus).toHaveBeenCalledTimes(1); + expect(service.getWorkspaceStatus).toHaveBeenCalledWith("mux-ws", expect.any(Object)); - expect(getWorkspaceStatus).toHaveBeenCalledTimes(1); - expect(getWorkspaceStatus).toHaveBeenCalledWith("mux-ws", expect.any(Object)); - - const statusOptions = (getWorkspaceStatus as ReturnType).mock.calls[0]?.[1] as { + const statusOptions = service.getWorkspaceStatus.mock.calls[0]?.[1] as { timeoutMs?: number; }; expect(typeof statusOptions.timeoutMs).toBe("number"); expect(statusOptions.timeoutMs).toBeGreaterThan(0); - expect(startWorkspace).toHaveBeenCalledTimes(1); - expect(startWorkspace).toHaveBeenCalledWith("mux-ws", { timeoutMs: 1234 }); + expect(service.startWorkspace).toHaveBeenCalledTimes(1); + expect(service.startWorkspace).toHaveBeenCalledWith("mux-ws", { timeoutMs: 1234 }); }); - it("waits for stopping workspace to become stopped before starting", async () => { - let pollCount = 0; - const getWorkspaceStatus = mock< - (workspaceName: string, options?: { timeoutMs?: number }) => Promise - >(() => { - pollCount++; - if (pollCount === 1) { - return Promise.resolve({ kind: "ok", status: "stopping" }); - } - return Promise.resolve({ kind: "ok", status: "stopped" }); + it("does nothing when archive behavior is delete", async () => { + const service = createCoderServiceMocks({ + getWorkspaceStatus: mock< + (workspaceName: string, options?: { timeoutMs?: number }) => Promise + >(() => Promise.resolve({ kind: "not_found" })), + }); + const hook = createCoderUnarchiveHook({ + coderService: service.coderService, + getArchiveBehavior: () => "delete", }); - const startWorkspace = mock< - (workspaceName: string, options?: { timeoutMs?: number }) => Promise> - >(() => Promise.resolve(Ok(undefined))); + const result = await hook({ + workspaceId: "ws", + workspaceMetadata: createSshCoderMetadata(), + }); - const coderService = { - getWorkspaceStatus, - startWorkspace, - } as unknown as CoderService; + expect(result.success).toBe(true); + expect(service.getWorkspaceStatus).toHaveBeenCalledTimes(0); + expect(service.startWorkspace).toHaveBeenCalledTimes(0); + }); - const hook = createStartCoderOnUnarchiveHook({ - coderService, - shouldStopOnArchive: () => true, + it("waits for stopping workspace to become stopped before starting", async () => { + let pollCount = 0; + const service = createCoderServiceMocks({ + getWorkspaceStatus: mock< + (workspaceName: string, options?: { timeoutMs?: number }) => Promise + >(() => { + pollCount++; + if (pollCount === 1) { + return Promise.resolve({ kind: "ok", status: "stopping" }); + } + return Promise.resolve({ kind: "ok", status: "stopped" }); + }), + }); + const hook = createCoderUnarchiveHook({ + coderService: service.coderService, + getArchiveBehavior: () => "stop", timeoutMs: 1234, stoppingPollIntervalMs: 0, stoppingWaitTimeoutMs: 1000, @@ -287,27 +364,20 @@ describe("createStartCoderOnUnarchiveHook", () => { }); expect(result.success).toBe(true); - expect(getWorkspaceStatus).toHaveBeenCalledTimes(2); - expect(startWorkspace).toHaveBeenCalledTimes(1); - expect(startWorkspace).toHaveBeenCalledWith("mux-ws", { timeoutMs: 1234 }); + expect(service.getWorkspaceStatus).toHaveBeenCalledTimes(2); + expect(service.startWorkspace).toHaveBeenCalledTimes(1); + expect(service.startWorkspace).toHaveBeenCalledWith("mux-ws", { timeoutMs: 1234 }); }); - it("does nothing when workspace is already running or starting", async () => { - const getWorkspaceStatus = mock<(workspaceName: string) => Promise>(() => - Promise.resolve({ kind: "ok", status: "running" }) - ); - - const startWorkspace = mock<(workspaceName: string) => Promise>>(() => - Promise.resolve(Ok(undefined)) - ); - const coderService = { - getWorkspaceStatus, - startWorkspace, - } as unknown as CoderService; - - const hook = createStartCoderOnUnarchiveHook({ - coderService, - shouldStopOnArchive: () => true, + it("does nothing when workspace is already running or starting", async () => { + const service = createCoderServiceMocks({ + getWorkspaceStatus: mock< + (workspaceName: string, options?: { timeoutMs?: number }) => Promise + >(() => Promise.resolve({ kind: "ok", status: "running" })), + }); + const hook = createCoderUnarchiveHook({ + coderService: service.coderService, + getArchiveBehavior: () => "stop", }); const result = await hook({ @@ -316,27 +386,19 @@ describe("createStartCoderOnUnarchiveHook", () => { }); expect(result.success).toBe(true); - expect(getWorkspaceStatus).toHaveBeenCalledTimes(1); - expect(startWorkspace).toHaveBeenCalledTimes(0); + expect(service.getWorkspaceStatus).toHaveBeenCalledTimes(1); + expect(service.startWorkspace).toHaveBeenCalledTimes(0); }); - it("treats not_found status as success", async () => { - const getWorkspaceStatus = mock<(workspaceName: string) => Promise>(() => - Promise.resolve({ kind: "not_found" }) - ); - - const startWorkspace = mock<(workspaceName: string) => Promise>>(() => - Promise.resolve(Ok(undefined)) - ); - - const coderService = { - getWorkspaceStatus, - startWorkspace, - } as unknown as CoderService; - - const hook = createStartCoderOnUnarchiveHook({ - coderService, - shouldStopOnArchive: () => true, + it("treats not_found status as success when archive behavior is stop", async () => { + const service = createCoderServiceMocks({ + getWorkspaceStatus: mock< + (workspaceName: string, options?: { timeoutMs?: number }) => Promise + >(() => Promise.resolve({ kind: "not_found" })), + }); + const hook = createCoderUnarchiveHook({ + coderService: service.coderService, + getArchiveBehavior: () => "stop", }); const result = await hook({ @@ -345,7 +407,7 @@ describe("createStartCoderOnUnarchiveHook", () => { }); expect(result.success).toBe(true); - expect(getWorkspaceStatus).toHaveBeenCalledTimes(1); - expect(startWorkspace).toHaveBeenCalledTimes(0); + expect(service.getWorkspaceStatus).toHaveBeenCalledTimes(1); + expect(service.startWorkspace).toHaveBeenCalledTimes(0); }); }); diff --git a/src/node/runtime/coderLifecycleHooks.ts b/src/node/runtime/coderLifecycleHooks.ts index f9ce468276..a72c99dbe0 100644 --- a/src/node/runtime/coderLifecycleHooks.ts +++ b/src/node/runtime/coderLifecycleHooks.ts @@ -1,5 +1,9 @@ +import type { + CoderWorkspaceArchiveBehavior, +} from "@/common/config/coderArchiveBehavior"; import { isSSHRuntime } from "@/common/types/runtime"; import { Err, Ok, type Result } from "@/common/types/result"; +import { getErrorMessage } from "@/common/utils/errors"; import type { CoderService, WorkspaceStatusResult } from "@/node/services/coderService"; import { log } from "@/node/services/log"; import type { @@ -49,19 +53,14 @@ function isAlreadyRunningOrStarting(status: WorkspaceStatusResult): boolean { return status.status === "running" || status.status === "starting"; } -export function createStopCoderOnArchiveHook(options: { +export function createCoderArchiveHook(options: { coderService: CoderService; - shouldStopOnArchive: () => boolean; + getArchiveBehavior: () => CoderWorkspaceArchiveBehavior; timeoutMs?: number; }): BeforeArchiveHook { const timeoutMs = options.timeoutMs ?? DEFAULT_STOP_TIMEOUT_MS; return async ({ workspaceId, workspaceMetadata }): Promise> => { - // Config default is ON (undefined behaves true). - if (!options.shouldStopOnArchive()) { - return Ok(undefined); - } - const runtimeConfig = workspaceMetadata.runtimeConfig; if (!isSSHRuntime(runtimeConfig) || !runtimeConfig.coder) { return Ok(undefined); @@ -70,8 +69,9 @@ export function createStopCoderOnArchiveHook(options: { const coder = runtimeConfig.coder; // Important safety invariant: - // Only stop Coder workspaces that mux created (dedicated workspaces). If the user connected - // mux to an existing Coder workspace, archiving in mux should *not* stop their environment. + // Only stop/delete Coder workspaces that mux created (dedicated workspaces). If the user + // connected mux to an existing Coder workspace, archiving in mux should *not* manage their + // environment. if (coder.existingWorkspace === true) { return Ok(undefined); } @@ -81,6 +81,27 @@ export function createStopCoderOnArchiveHook(options: { return Ok(undefined); } + const archiveBehavior = options.getArchiveBehavior(); + if (archiveBehavior === "keep") { + return Ok(undefined); + } + + if (archiveBehavior === "delete") { + log.debug("Deleting Coder workspace before mux archive", { + workspaceId, + coderWorkspaceName: workspaceName, + }); + + try { + await options.coderService.deleteWorkspace(workspaceName); + return Ok(undefined); + } catch (error) { + return Err( + `Failed to delete Coder workspace "${workspaceName}": ${getErrorMessage(error)}` + ); + } + } + // Best-effort: skip the stop call if the control-plane already thinks the workspace is down. const status = await options.coderService.getWorkspaceStatus(workspaceName, { timeoutMs: DEFAULT_STATUS_TIMEOUT_MS, @@ -106,9 +127,9 @@ export function createStopCoderOnArchiveHook(options: { }; } -export function createStartCoderOnUnarchiveHook(options: { +export function createCoderUnarchiveHook(options: { coderService: CoderService; - shouldStopOnArchive: () => boolean; + getArchiveBehavior: () => CoderWorkspaceArchiveBehavior; timeoutMs?: number; stoppingWaitTimeoutMs?: number; stoppingPollIntervalMs?: number; @@ -116,11 +137,6 @@ export function createStartCoderOnUnarchiveHook(options: { const timeoutMs = options.timeoutMs ?? DEFAULT_START_TIMEOUT_MS; return async ({ workspaceId, workspaceMetadata }): Promise> => { - // Config default is ON (undefined behaves true). - if (!options.shouldStopOnArchive()) { - return Ok(undefined); - } - const runtimeConfig = workspaceMetadata.runtimeConfig; if (!isSSHRuntime(runtimeConfig) || !runtimeConfig.coder) { return Ok(undefined); @@ -140,6 +156,10 @@ export function createStartCoderOnUnarchiveHook(options: { return Ok(undefined); } + if (options.getArchiveBehavior() !== "stop") { + return Ok(undefined); + } + let status = await options.coderService.getWorkspaceStatus(workspaceName, { timeoutMs: DEFAULT_STATUS_TIMEOUT_MS, }); diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 065689966c..7dc3d3947d 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -7,6 +7,7 @@ import { MUX_HELP_CHAT_WORKSPACE_TITLE, } from "@/common/constants/muxChat"; import { getMuxHelpChatProjectPath } from "@/node/constants/muxChat"; +import { DEFAULT_CODER_ARCHIVE_BEHAVIOR } from "@/common/config/coderArchiveBehavior"; import { createMuxMessage } from "@/common/types/message"; import { log } from "@/node/services/log"; import type { Config } from "@/node/config"; @@ -53,8 +54,8 @@ import { coderService, type CoderService } from "@/node/services/coderService"; import { SshPromptService } from "@/node/services/sshPromptService"; import { WorkspaceLifecycleHooks } from "@/node/services/workspaceLifecycleHooks"; import { - createStartCoderOnUnarchiveHook, - createStopCoderOnArchiveHook, + createCoderArchiveHook, + createCoderUnarchiveHook, } from "@/node/runtime/coderLifecycleHooks"; import { setGlobalCoderService } from "@/node/runtime/runtimeFactory"; import { setSshPromptService } from "@/node/runtime/sshConnectionPool"; @@ -289,18 +290,19 @@ export class ServiceContainer { this.serverAuthService = new ServerAuthService(config); const workspaceLifecycleHooks = new WorkspaceLifecycleHooks(); + const getArchiveBehavior = () => + this.config.loadConfigOrDefault().coderWorkspaceArchiveBehavior ?? + DEFAULT_CODER_ARCHIVE_BEHAVIOR; workspaceLifecycleHooks.registerBeforeArchive( - createStopCoderOnArchiveHook({ + createCoderArchiveHook({ coderService: this.coderService, - shouldStopOnArchive: () => - this.config.loadConfigOrDefault().stopCoderWorkspaceOnArchive !== false, + getArchiveBehavior, }) ); workspaceLifecycleHooks.registerAfterUnarchive( - createStartCoderOnUnarchiveHook({ + createCoderUnarchiveHook({ coderService: this.coderService, - shouldStopOnArchive: () => - this.config.loadConfigOrDefault().stopCoderWorkspaceOnArchive !== false, + getArchiveBehavior, }) ); this.workspaceService.setWorkspaceLifecycleHooks(workspaceLifecycleHooks); From b76d4a5196bb41610b918eacce9880dbf5559abd Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:18:15 +0000 Subject: [PATCH 3/6] fix: use enum archive behavior in settings --- .../Settings/Sections/GeneralSection.tsx | 126 +++++++++++------- src/browser/stories/mocks/orpc.ts | 15 ++- tests/ipc/config/coderArchiveBehavior.test.ts | 48 +++++++ 3 files changed, 138 insertions(+), 51 deletions(-) create mode 100644 tests/ipc/config/coderArchiveBehavior.test.ts diff --git a/src/browser/features/Settings/Sections/GeneralSection.tsx b/src/browser/features/Settings/Sections/GeneralSection.tsx index b232754ee8..a908375ffb 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -29,6 +29,11 @@ import { isFontFamilyAvailableInBrowser, isGenericFontFamily, } from "@/browser/terminal/terminalFontFamily"; +import { + CODER_ARCHIVE_BEHAVIORS, + DEFAULT_CODER_ARCHIVE_BEHAVIOR, + type CoderWorkspaceArchiveBehavior, +} from "@/common/config/coderArchiveBehavior"; // Guard against corrupted/old persisted settings (e.g. from a downgraded build). const ALLOWED_EDITOR_TYPES: ReadonlySet = new Set([ @@ -130,6 +135,18 @@ const LAUNCH_BEHAVIOR_OPTIONS = [ { value: "new-chat", label: "New chat on recent project" }, { value: "last-workspace", label: "Last visited workspace" }, ] as const; +const ARCHIVE_BEHAVIOR_OPTIONS = [ + { value: "keep", label: "Keep running" }, + { value: "stop", label: "Stop workspace" }, + { value: "delete", label: "Delete workspace" }, +] as const; + +function isCoderWorkspaceArchiveBehavior(value: unknown): value is CoderWorkspaceArchiveBehavior { + return ( + typeof value === "string" && + CODER_ARCHIVE_BEHAVIORS.includes(value as CoderWorkspaceArchiveBehavior) + ); +} // Browser mode: window.api is not set (only exists in Electron via preload) const isBrowserMode = typeof window !== "undefined" && !window.api; @@ -171,33 +188,41 @@ export function GeneralSection() { // (which would clear the config) when the initial fetch failed. const [cloneDirLoadedOk, setCloneDirLoadedOk] = useState(false); - // Backend config: default to ON so archiving is safest even before async load completes. - const [stopCoderWorkspaceOnArchive, setStopCoderWorkspaceOnArchive] = useState(true); + // Backend config: default to stop so archiving is safest even before async load completes. + const [archiveBehavior, setArchiveBehavior] = useState( + DEFAULT_CODER_ARCHIVE_BEHAVIOR + ); const [llmDebugLogs, setLlmDebugLogs] = useState(false); - const stopCoderWorkspaceOnArchiveLoadNonceRef = useRef(0); + const archiveBehaviorLoadNonceRef = useRef(0); const llmDebugLogsLoadNonceRef = useRef(0); // updateCoderPrefs writes config.json on the backend. Serialize (and coalesce) updates so rapid - // toggles can't race and persist a stale value via out-of-order writes. - const stopCoderWorkspaceOnArchiveUpdateChainRef = useRef>(Promise.resolve()); + // selections can't race and persist a stale value via out-of-order writes. + const archiveBehaviorUpdateChainRef = useRef>(Promise.resolve()); const llmDebugLogsUpdateChainRef = useRef>(Promise.resolve()); - const stopCoderWorkspaceOnArchivePendingUpdateRef = useRef(undefined); + const archiveBehaviorPendingUpdateRef = useRef( + undefined + ); useEffect(() => { if (!api) { return; } - const stopCoderWorkspaceOnArchiveNonce = ++stopCoderWorkspaceOnArchiveLoadNonceRef.current; + const archiveBehaviorNonce = ++archiveBehaviorLoadNonceRef.current; const llmDebugLogsNonce = ++llmDebugLogsLoadNonceRef.current; void api.config .getConfig() .then((cfg) => { - // If the user toggled the setting while this request was in flight, keep the UI selection. - if (stopCoderWorkspaceOnArchiveNonce === stopCoderWorkspaceOnArchiveLoadNonceRef.current) { - setStopCoderWorkspaceOnArchive(cfg.stopCoderWorkspaceOnArchive); + // If the user changed the setting while this request was in flight, keep the UI selection. + if (archiveBehaviorNonce === archiveBehaviorLoadNonceRef.current) { + setArchiveBehavior( + isCoderWorkspaceArchiveBehavior(cfg.coderWorkspaceArchiveBehavior) + ? cfg.coderWorkspaceArchiveBehavior + : DEFAULT_CODER_ARCHIVE_BEHAVIOR + ); } // Use an independent nonce so debug-log toggles do not discard archive-setting updates. @@ -206,46 +231,45 @@ export function GeneralSection() { } }) .catch(() => { - // Best-effort only. Keep the default (ON) if config fails to load. + // Best-effort only. Keep the default (stop) if config fails to load. }); }, [api]); - const handleStopCoderWorkspaceOnArchiveChange = useCallback( - (checked: boolean) => { + const handleArchiveBehaviorChange = useCallback( + (behavior: CoderWorkspaceArchiveBehavior) => { // Invalidate any in-flight initial load so it doesn't overwrite the user's selection. - stopCoderWorkspaceOnArchiveLoadNonceRef.current++; - setStopCoderWorkspaceOnArchive(checked); + archiveBehaviorLoadNonceRef.current++; + setArchiveBehavior(behavior); if (!api?.config?.updateCoderPrefs) { return; } - stopCoderWorkspaceOnArchivePendingUpdateRef.current = checked; - - stopCoderWorkspaceOnArchiveUpdateChainRef.current = - stopCoderWorkspaceOnArchiveUpdateChainRef.current - .then(async () => { - // Drain the pending ref so a toggle that happens while updateCoderPrefs is in-flight - // doesn't get stranded without a subsequent write scheduled. - for (;;) { - const pending = stopCoderWorkspaceOnArchivePendingUpdateRef.current; - if (pending === undefined) { - return; - } + archiveBehaviorPendingUpdateRef.current = behavior; - // Clear before awaiting so rapid toggles coalesce into a new pending value. - stopCoderWorkspaceOnArchivePendingUpdateRef.current = undefined; + archiveBehaviorUpdateChainRef.current = archiveBehaviorUpdateChainRef.current + .then(async () => { + // Drain the pending ref so a change that happens while updateCoderPrefs is in-flight + // doesn't get stranded without a subsequent write scheduled. + for (;;) { + const pending = archiveBehaviorPendingUpdateRef.current; + if (pending === undefined) { + return; + } - try { - await api.config.updateCoderPrefs({ stopCoderWorkspaceOnArchive: pending }); - } catch { - // Best-effort only. Swallow errors so the queue doesn't get stuck. - } + // Clear before awaiting so rapid changes coalesce into a new pending value. + archiveBehaviorPendingUpdateRef.current = undefined; + + try { + await api.config.updateCoderPrefs({ coderWorkspaceArchiveBehavior: pending }); + } catch { + // Best-effort only. Swallow errors so the queue doesn't get stuck. } - }) - .catch(() => { - // Best-effort only. - }); + } + }) + .catch(() => { + // Best-effort only. + }); }, [api] ); @@ -518,17 +542,29 @@ export function GeneralSection() {
-
Stop Coder workspace when archiving
+
Coder workspace on archive
- When enabled, archiving a Mux workspace will stop its dedicated Coder workspace first. + Action to take on dedicated Coder workspaces when archiving a chat. Delete is permanent.
- + handleArchiveBehaviorChange(value as CoderWorkspaceArchiveBehavior) + } disabled={!api?.config?.updateCoderPrefs} - aria-label="Toggle stopping the dedicated Coder workspace when archiving a Mux workspace" - /> + > + + + + + {ARCHIVE_BEHAVIOR_OPTIONS.map((option) => ( + + {option.label} + + ))} + +
{isBrowserMode && sshHostLoaded && ( diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index 1a9080466a..c1d41958fa 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -63,6 +63,7 @@ import type { CoderTemplate, CoderWorkspace, } from "@/common/orpc/schemas/coder"; +import type { CoderWorkspaceArchiveBehavior } from "@/common/config/coderArchiveBehavior"; import type { z } from "zod"; import type { ProjectRemoveErrorSchema } from "@/common/orpc/schemas/errors"; import { isWorkspaceArchived } from "@/common/utils/archive"; @@ -124,7 +125,7 @@ export interface MockORPCClientOptions { /** Initial per-subagent AI defaults for config.getConfig (e.g., Settings → Tasks section) */ subagentAiDefaults?: SubagentAiDefaults; /** Coder lifecycle preferences for config.getConfig (e.g., Settings → Coder section) */ - stopCoderWorkspaceOnArchive?: boolean; + coderWorkspaceArchiveBehavior?: CoderWorkspaceArchiveBehavior; /** Initial runtime enablement for config.getConfig */ runtimeEnablement?: Record; /** Initial default runtime for config.getConfig (global) */ @@ -341,7 +342,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl taskSettings: initialTaskSettings, subagentAiDefaults: initialSubagentAiDefaults, agentAiDefaults: initialAgentAiDefaults, - stopCoderWorkspaceOnArchive: initialStopCoderWorkspaceOnArchive = true, + coderWorkspaceArchiveBehavior: initialCoderWorkspaceArchiveBehavior = "stop", runtimeEnablement: initialRuntimeEnablement, defaultRuntime: initialDefaultRuntime, onePasswordAccountName: initialOnePasswordAccountName = null, @@ -543,7 +544,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl let muxGatewayEnabled: boolean | undefined = undefined; let muxGatewayModels: string[] | undefined = undefined; - let stopCoderWorkspaceOnArchive = initialStopCoderWorkspaceOnArchive; + let coderWorkspaceArchiveBehavior = initialCoderWorkspaceArchiveBehavior; let runtimeEnablement: Record = initialRuntimeEnablement ?? { local: true, worktree: true, @@ -737,7 +738,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl muxGatewayModels, routePriority, routeOverrides, - stopCoderWorkspaceOnArchive, + coderWorkspaceArchiveBehavior, runtimeEnablement, defaultRuntime, agentAiDefaults, @@ -814,8 +815,10 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl notifyConfigChanged(); return Promise.resolve(undefined); }, - updateCoderPrefs: (input: { stopCoderWorkspaceOnArchive: boolean }) => { - stopCoderWorkspaceOnArchive = input.stopCoderWorkspaceOnArchive; + updateCoderPrefs: (input: { + coderWorkspaceArchiveBehavior: CoderWorkspaceArchiveBehavior; + }) => { + coderWorkspaceArchiveBehavior = input.coderWorkspaceArchiveBehavior; notifyConfigChanged(); return Promise.resolve(undefined); }, diff --git a/tests/ipc/config/coderArchiveBehavior.test.ts b/tests/ipc/config/coderArchiveBehavior.test.ts new file mode 100644 index 0000000000..e40cefdad1 --- /dev/null +++ b/tests/ipc/config/coderArchiveBehavior.test.ts @@ -0,0 +1,48 @@ +import type { TestEnvironment } from "../setup"; +import { cleanupTestEnvironment, createTestEnvironment } from "../setup"; + +describe("config.coderArchiveBehavior", () => { + let env: TestEnvironment; + + beforeEach(async () => { + env = await createTestEnvironment(); + }); + + afterEach(async () => { + if (env) { + await cleanupTestEnvironment(env); + } + }); + + it("returns default stop behavior", async () => { + const cfg = await env.orpc.config.getConfig(); + expect(cfg.coderWorkspaceArchiveBehavior).toBe("stop"); + }); + + it("persists keep behavior", async () => { + await env.orpc.config.updateCoderPrefs({ + coderWorkspaceArchiveBehavior: "keep", + }); + const cfg = await env.orpc.config.getConfig(); + expect(cfg.coderWorkspaceArchiveBehavior).toBe("keep"); + + const loaded = env.config.loadConfigOrDefault(); + expect(loaded.stopCoderWorkspaceOnArchive).toBe(false); + }); + + it("persists stop behavior", async () => { + await env.orpc.config.updateCoderPrefs({ + coderWorkspaceArchiveBehavior: "stop", + }); + const cfg = await env.orpc.config.getConfig(); + expect(cfg.coderWorkspaceArchiveBehavior).toBe("stop"); + }); + + it("persists delete behavior", async () => { + await env.orpc.config.updateCoderPrefs({ + coderWorkspaceArchiveBehavior: "delete", + }); + const cfg = await env.orpc.config.getConfig(); + expect(cfg.coderWorkspaceArchiveBehavior).toBe("delete"); + }); +}); From c98bb8601bf356170aaf3175c71b510dd7e91c3f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:21:39 +0000 Subject: [PATCH 4/6] chore: fix formatting --- bun.lock | 67 +++++++++++++------------ src/node/runtime/coderLifecycleHooks.ts | 4 +- 2 files changed, 37 insertions(+), 34 deletions(-) diff --git a/bun.lock b/bun.lock index ea035a2c59..11ada612a7 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "mux", @@ -200,8 +199,8 @@ }, "trustedDependencies": [ "esbuild", - "sharp", "node-pty", + "sharp", "ssh2", "@swc/core", "electron", @@ -1469,7 +1468,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], "@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="], @@ -3903,14 +3902,26 @@ "@istanbuljs/load-nyc-config/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "@jest/console/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "@jest/console/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "@jest/core/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "@jest/core/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], "@jest/core/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@jest/core/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], + "@jest/environment/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + + "@jest/fake-timers/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + + "@jest/pattern/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + + "@jest/reporters/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "@jest/reporters/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@jest/reporters/istanbul-lib-instrument": ["istanbul-lib-instrument@6.0.3", "", { "dependencies": { "@babel/core": "^7.23.9", "@babel/parser": "^7.23.9", "@istanbuljs/schema": "^0.1.3", "istanbul-lib-coverage": "^3.2.0", "semver": "^7.5.4" } }, "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q=="], @@ -3925,6 +3936,8 @@ "@jest/transform/write-file-atomic": ["write-file-atomic@5.0.1", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw=="], + "@jest/types/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], @@ -4017,33 +4030,17 @@ "@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], - "@types/asn1/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], - - "@types/body-parser/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], - - "@types/cacheable-request/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], - - "@types/connect/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], - - "@types/express-serve-static-core/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], - - "@types/keyv/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], + "@types/cors/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], - "@types/plist/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], + "@types/fs-extra/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], - "@types/responselike/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], - - "@types/send/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], - - "@types/serve-static/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], + "@types/jsdom/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - "@types/sshpk/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], + "@types/write-file-atomic/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], - "@types/wait-on/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], - - "@types/yauzl/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], + "@types/ws/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], @@ -4077,6 +4074,8 @@ "builder-util/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "bun-types/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "cacache/fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], "cacache/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -4119,8 +4118,6 @@ "dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], - "electron/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], - "electron-builder/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "electron-publish/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -4183,6 +4180,8 @@ "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "happy-dom/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "hasha/type-fest": ["type-fest@0.8.1", "", {}, "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA=="], "hast-util-to-parse5/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], @@ -4203,8 +4202,6 @@ "istanbul-lib-report/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "jest-circus/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], - "jest-circus/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-cli/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -4217,7 +4214,7 @@ "jest-each/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "jest-environment-node/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], + "jest-haste-map/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], "jest-haste-map/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -4229,24 +4226,28 @@ "jest-message-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "jest-mock/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "jest-process-manager/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-process-manager/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "jest-resolve/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "jest-runner/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], - "jest-runner/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-runner/source-map-support": ["source-map-support@0.5.13", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w=="], + "jest-runtime/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "jest-runtime/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-runtime/strip-bom": ["strip-bom@4.0.0", "", {}, "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w=="], "jest-snapshot/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "jest-util/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "jest-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-util/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], @@ -4255,12 +4256,16 @@ "jest-watch-typeahead/slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], + "jest-watcher/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "jest-watcher/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], "jest-watcher/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-watcher/string-length": ["string-length@4.0.2", "", { "dependencies": { "char-regex": "^1.0.2", "strip-ansi": "^6.0.0" } }, "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ=="], + "jest-worker/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "jsdom/parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="], "jsdom/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], diff --git a/src/node/runtime/coderLifecycleHooks.ts b/src/node/runtime/coderLifecycleHooks.ts index a72c99dbe0..8e151a5543 100644 --- a/src/node/runtime/coderLifecycleHooks.ts +++ b/src/node/runtime/coderLifecycleHooks.ts @@ -1,6 +1,4 @@ -import type { - CoderWorkspaceArchiveBehavior, -} from "@/common/config/coderArchiveBehavior"; +import type { CoderWorkspaceArchiveBehavior } from "@/common/config/coderArchiveBehavior"; import { isSSHRuntime } from "@/common/types/runtime"; import { Err, Ok, type Result } from "@/common/types/result"; import { getErrorMessage } from "@/common/utils/errors"; From b03a554039659ba79f31001b4344f72c85874604 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:27:56 +0000 Subject: [PATCH 5/6] fix config archive behavior save precedence --- src/node/config.test.ts | 12 ++++++++++++ src/node/config.ts | 9 ++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/node/config.test.ts b/src/node/config.test.ts index e31d63e0ab..e8c84eca05 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -276,6 +276,18 @@ describe("Config", () => { expect(loaded.terminalDefaultShell).toBe("zsh"); }); + it("enum field takes precedence over legacy boolean on save", async () => { + // Simulate: user had "keep" (legacy false), then switches to "stop" via the new enum. + await config.editConfig((c) => ({ + ...c, + coderWorkspaceArchiveBehavior: "stop", + stopCoderWorkspaceOnArchive: false, + })); + + const loaded = config.loadConfigOrDefault(); + expect(loaded.coderWorkspaceArchiveBehavior).toBe("stop"); + }); + it("round-trips each behavior with the enum field and legacy shim", async () => { for (const behavior of CODER_ARCHIVE_BEHAVIORS) { await config.editConfig((cfg) => { diff --git a/src/node/config.ts b/src/node/config.ts index 3434210b4a..6e3a87e993 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -133,12 +133,15 @@ function resolveCoderWorkspaceArchiveBehaviorForSave( config: Pick ): CoderWorkspaceArchiveBehavior { const parsedBehavior = parseCoderWorkspaceArchiveBehavior(config.coderWorkspaceArchiveBehavior); - if (config.stopCoderWorkspaceOnArchive === false && parsedBehavior !== "delete") { - // Keep legacy writers working until the router/settings UI starts sending the enum field. + if (parsedBehavior != null) { + return parsedBehavior; + } + + if (config.stopCoderWorkspaceOnArchive === false) { return "keep"; } - return parsedBehavior ?? DEFAULT_CODER_ARCHIVE_BEHAVIOR; + return DEFAULT_CODER_ARCHIVE_BEHAVIOR; } function parseOptionalStringArray(value: unknown): string[] | undefined { From 99414d441411d42cb1ba4661cf83d4cef1659ef9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:35:19 +0000 Subject: [PATCH 6/6] ci: retry flaky integration test