Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/runtime/coder.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,4 @@ Mux runs `coder config-ssh --yes` before connecting, which creates SSH aliases l

- If the **Use Coder Workspace** checkbox is missing, verify that the Coder CLI is found on the PATH.
- Each Mux workspace still lives in its own directory on the remote machine, even when sharing a single Coder workspace.
- By default, archiving a **New** (mux-created) Coder-backed workspace will also stop the underlying Coder workspace, and unarchiving will attempt to start it again. This does **not** apply when using an **Existing** Coder workspace. To disable this behavior, set `stopCoderWorkspaceOnArchive` to `false` in `~/.mux/config.json`.
89 changes: 88 additions & 1 deletion src/browser/components/Settings/sections/GeneralSection.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback } from "react";
import React, { useEffect, useState, useCallback, useRef } from "react";
import { useTheme, THEME_OPTIONS, type ThemeMode } from "@/browser/contexts/ThemeContext";
import {
Select,
Expand All @@ -8,6 +8,7 @@ import {
SelectValue,
} from "@/browser/components/ui/select";
import { Input } from "@/browser/components/ui/input";
import { Switch } from "@/browser/components/ui/switch";
import { usePersistedState } from "@/browser/hooks/usePersistedState";
import { useAPI } from "@/browser/contexts/API";
import {
Expand Down Expand Up @@ -152,6 +153,77 @@ export function GeneralSection() {
const [sshHost, setSshHost] = useState<string>("");
const [sshHostLoaded, setSshHostLoaded] = useState(false);

// Backend config: default to ON so archiving is safest even before async load completes.
const [stopCoderWorkspaceOnArchive, setStopCoderWorkspaceOnArchive] = useState(true);
const stopCoderWorkspaceOnArchiveLoadNonceRef = 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<void>>(Promise.resolve());
const stopCoderWorkspaceOnArchivePendingUpdateRef = useRef<boolean | undefined>(undefined);

useEffect(() => {
if (!api) {
return;
}

const nonce = ++stopCoderWorkspaceOnArchiveLoadNonceRef.current;

void api.config
.getConfig()
.then((cfg) => {
// If the user toggled the setting while this request was in flight, keep the UI selection.
if (nonce !== stopCoderWorkspaceOnArchiveLoadNonceRef.current) {
return;
}

setStopCoderWorkspaceOnArchive(cfg.stopCoderWorkspaceOnArchive);
})
.catch(() => {
// Best-effort only. Keep the default (ON) if config fails to load.
});
}, [api]);

const handleStopCoderWorkspaceOnArchiveChange = useCallback(
(checked: boolean) => {
// Invalidate any in-flight initial load so it doesn't overwrite the user's selection.
stopCoderWorkspaceOnArchiveLoadNonceRef.current++;
setStopCoderWorkspaceOnArchive(checked);

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

// Clear before awaiting so rapid toggles coalesce into a new pending value.
stopCoderWorkspaceOnArchivePendingUpdateRef.current = undefined;

try {
await api.config.updateCoderPrefs({ stopCoderWorkspaceOnArchive: pending });
} catch {
// Best-effort only. Swallow errors so the queue doesn't get stuck.
}
}
})
.catch(() => {
// Best-effort only.
});
},
[api]
);

// Load SSH host from server on mount (browser mode only)
useEffect(() => {
if (isBrowserMode && api) {
Expand Down Expand Up @@ -302,6 +374,21 @@ export function GeneralSection() {
</div>
)}

<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<div className="text-foreground text-sm">Stop Coder workspace when archiving</div>
<div className="text-muted text-xs">
When enabled, archiving a Mux workspace will stop its dedicated Coder workspace first.
</div>
</div>
<Switch
checked={stopCoderWorkspaceOnArchive}
onCheckedChange={handleStopCoderWorkspaceOnArchiveChange}
disabled={!api?.config?.updateCoderPrefs}
aria-label="Toggle stopping the dedicated Coder workspace when archiving a Mux workspace"
/>
</div>

{isBrowserMode && sshHostLoaded && (
<div className="flex items-center justify-between">
<div>
Expand Down
9 changes: 9 additions & 0 deletions src/browser/stories/mocks/orpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ export interface MockORPCClientOptions {
agentDefinitions?: AgentDefinitionDescriptor[];
/** 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;
/** Per-workspace chat callback. Return messages to emit, or use the callback for streaming. */
onChat?: (workspaceId: string, emit: (msg: WorkspaceChatMessage) => void) => (() => void) | void;
/** Mock for executeBash per workspace */
Expand Down Expand Up @@ -272,6 +274,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
taskSettings: initialTaskSettings,
subagentAiDefaults: initialSubagentAiDefaults,
agentAiDefaults: initialAgentAiDefaults,
stopCoderWorkspaceOnArchive: initialStopCoderWorkspaceOnArchive = true,
agentDefinitions: initialAgentDefinitions,
listBranches: customListBranches,
gitInit: customGitInit,
Expand Down Expand Up @@ -388,6 +391,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl

let muxGatewayEnabled: boolean | undefined = undefined;
let muxGatewayModels: string[] | undefined = undefined;
let stopCoderWorkspaceOnArchive = initialStopCoderWorkspaceOnArchive;

const deriveSubagentAiDefaults = () => {
const raw: Record<string, unknown> = {};
Expand Down Expand Up @@ -503,6 +507,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
taskSettings,
muxGatewayEnabled,
muxGatewayModels,
stopCoderWorkspaceOnArchive,
agentAiDefaults,
subagentAiDefaults,
muxGovernorUrl,
Expand Down Expand Up @@ -546,6 +551,10 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
muxGatewayModels = input.muxGatewayModels.length > 0 ? input.muxGatewayModels : undefined;
return Promise.resolve(undefined);
},
updateCoderPrefs: (input: { stopCoderWorkspaceOnArchive: boolean }) => {
stopCoderWorkspaceOnArchive = input.stopCoderWorkspaceOnArchive;
return Promise.resolve(undefined);
},
unenrollMuxGovernor: () => Promise.resolve(undefined),
},
agents: {
Expand Down
9 changes: 9 additions & 0 deletions src/common/orpc/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1154,6 +1154,7 @@ export const config = {
}),
muxGatewayEnabled: z.boolean().optional(),
muxGatewayModels: z.array(z.string()).optional(),
stopCoderWorkspaceOnArchive: z.boolean(),
agentAiDefaults: AgentAiDefaultsSchema,
// Legacy fields (downgrade compatibility)
subagentAiDefaults: SubagentAiDefaultsSchema,
Expand Down Expand Up @@ -1193,6 +1194,14 @@ export const config = {
}),
output: z.void(),
},
updateCoderPrefs: {
input: z
.object({
stopCoderWorkspaceOnArchive: z.boolean(),
})
.strict(),
output: z.void(),
},
unenrollMuxGovernor: {
input: z.void(),
output: z.void(),
Expand Down
8 changes: 8 additions & 0 deletions src/common/types/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,12 @@ export interface ProjectsConfig {
muxGovernorUrl?: string;
/** Mux Governor OAuth access token (secret - never return to UI) */
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.
*
* Stored as `false` only (undefined behaves as true) to keep config.json minimal.
*/
stopCoderWorkspaceOnArchive?: boolean;
}
12 changes: 12 additions & 0 deletions src/node/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export class Config {
useSSH2Transport?: unknown;
muxGovernorUrl?: unknown;
muxGovernorToken?: unknown;
stopCoderWorkspaceOnArchive?: unknown;
};

// Config is stored as array of [path, config] pairs
Expand Down Expand Up @@ -163,6 +164,10 @@ export class Config {
const muxGatewayModels = parseOptionalStringArray(parsed.muxGatewayModels);
const legacySubagentAiDefaults = normalizeSubagentAiDefaults(parsed.subagentAiDefaults);

// Default ON: store `false` only so config.json stays minimal.
const stopCoderWorkspaceOnArchive =
parseOptionalBoolean(parsed.stopCoderWorkspaceOnArchive) === false ? false : undefined;

const agentAiDefaults =
parsed.agentAiDefaults !== undefined
? normalizeAgentAiDefaults(parsed.agentAiDefaults)
Expand Down Expand Up @@ -195,6 +200,7 @@ export class Config {
useSSH2Transport: parseOptionalBoolean(parsed.useSSH2Transport),
muxGovernorUrl: parseOptionalNonEmptyString(parsed.muxGovernorUrl),
muxGovernorToken: parseOptionalNonEmptyString(parsed.muxGovernorToken),
stopCoderWorkspaceOnArchive,
};
}
}
Expand Down Expand Up @@ -236,6 +242,7 @@ export class Config {
useSSH2Transport?: boolean;
muxGovernorUrl?: string;
muxGovernorToken?: string;
stopCoderWorkspaceOnArchive?: boolean;
} = {
projects: Array.from(config.projects.entries()),
taskSettings: config.taskSettings ?? DEFAULT_TASK_SETTINGS,
Expand Down Expand Up @@ -322,6 +329,11 @@ export class Config {
data.muxGovernorToken = muxGovernorToken;
}

// Default ON: persist `false` only.
if (config.stopCoderWorkspaceOnArchive === false) {
data.stopCoderWorkspaceOnArchive = false;
}

await writeFileAtomic(this.configFile, JSON.stringify(data, null, 2), "utf-8");
} catch (error) {
log.error("Error saving config:", error);
Expand Down
13 changes: 13 additions & 0 deletions src/node/orpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,7 @@ export const router = (authToken?: string) => {
taskSettings: config.taskSettings ?? DEFAULT_TASK_SETTINGS,
muxGatewayEnabled: config.muxGatewayEnabled,
muxGatewayModels: config.muxGatewayModels,
stopCoderWorkspaceOnArchive: config.stopCoderWorkspaceOnArchive !== false,
agentAiDefaults: config.agentAiDefaults ?? {},
// Legacy fields (downgrade compatibility)
subagentAiDefaults: config.subagentAiDefaults ?? {},
Expand Down Expand Up @@ -545,6 +546,18 @@ export const router = (authToken?: string) => {
};
});
}),
updateCoderPrefs: t
.input(schemas.config.updateCoderPrefs.input)
.output(schemas.config.updateCoderPrefs.output)
.handler(async ({ context, input }) => {
await context.config.editConfig((config) => {
return {
...config,
// Default ON: store `false` only.
stopCoderWorkspaceOnArchive: input.stopCoderWorkspaceOnArchive ? undefined : false,
};
});
}),
saveConfig: t
.input(schemas.config.saveConfig.input)
.output(schemas.config.saveConfig.output)
Expand Down
Loading
Loading