Skip to content

Conversation

@ThomasK33
Copy link
Member

@ThomasK33 ThomasK33 commented Feb 4, 2026

Summary

Adds backend workspace lifecycle hooks to manage Mux-created dedicated Coder workspaces when a Mux workspace is archived/unarchived (config-gated, default ON):

  • Archive: synchronously stop the underlying dedicated Coder workspace before persisting the Mux archive.
  • Unarchive: best-effort attempt to start the underlying dedicated Coder workspace again.

Also adds a Settings toggle, tests, and docs.

Background

Archiving a Mux workspace previously only updated archivedAt (and interrupted streaming), which could leave the underlying Coder workspace running.

Implementation

  • Introduces WorkspaceLifecycleHooks (beforeArchive, afterUnarchive).
  • WorkspaceService.archive() runs beforeArchive hooks synchronously and blocks archival if any hook fails.
  • WorkspaceService.unarchive() runs afterUnarchive hooks best-effort after persisting unarchivedAt.
  • Adds Coder hooks that stop/start the underlying Coder workspace only when existingWorkspace !== true.
  • Adds config + oRPC surface for stopCoderWorkspaceOnArchive (default ON; persisted only when false) and a Settings toggle with clarified copy.

Validation

  • make static-check

Risks

  • Archiving can now fail (by design) if stopping the dedicated Coder workspace fails/timeouts while the setting is enabled.
  • Unarchiving may be delayed by the best-effort start attempt timeout.
  • Uses coder stop/start ... --yes; older CLIs without --yes could cause stop/start failures.

📋 Implementation Plan

Plan: Workspace lifecycle hooks + stop Coder workspaces on archive

Context / Why

Archiving a workspace in Mux currently only updates metadata (archivedAt) and (optionally) interrupts an active AI stream. For Coder-backed workspaces this means the underlying Coder VM can keep running (and costing money) after the workspace is hidden.

Goal: add a general-purpose workspace lifecycle hook system so Mux can run runtime/service-specific actions on lifecycle events. Then use it for the Coder integration so that archiving a Mux workspace stops the underlying Coder workspace.

Product requirements (from user)

  • Stop scope: only stop Mux-created dedicated Coder workspaces (i.e. runtimeConfig.coder.existingWorkspace !== true).
  • Sync vs async: stopping should be synchronous and UX should show progress while it’s happening.
  • Enablement: behind a setting, default ON.

Evidence (repo facts)

  • Archival today is metadata-only + stream interrupt:
    • src/node/services/workspaceService.tsarchive() sets archivedAt, emits updated metadata; interrupts stream if streaming.
  • Coder runtime is implemented as RuntimeConfig.type === "ssh" with runtimeConfig.coder present:
    • src/node/runtime/runtimeFactory.ts creates CoderSSHRuntime when SSH config has coder.
    • src/node/runtime/CoderSSHRuntime.ts uses coderService.getWorkspaceStatus() and coder ssh --wait=yes to auto-start; no stop call exists.
  • Coder CLI wrapper exists but has no stop method:
    • src/node/services/coderService.ts has getWorkspaceStatus(), createWorkspace(), deleteWorkspace().
  • UI already shows an “Archiving…” loading state while the archive RPC is in flight:
    • src/browser/components/ProjectSidebar.tsx tracks archivingWorkspaceIds.
    • src/browser/components/WorkspaceListItem.tsx renders secondary-row text Archiving... when isArchiving.

Recommended approach (general-purpose hook registry)

Implement a small, typed backend hook registry (WorkspaceLifecycleHooks) that supports blocking (pre-) lifecycle hooks.

  • WorkspaceService.archive() will call beforeArchive hooks before persisting archivedAt.
  • The Coder integration registers a beforeArchive hook that:
    1. checks the workspace is a Coder-backed runtime and is not existingWorkspace,
    2. checks the global setting (default true),
    3. stops the Coder workspace via coder stop, with timeout,
    4. returns an error to block archival if stopping fails.

This keeps WorkspaceService free of runtime-specific logic while still allowing runtime/service-specific behavior.

Net new LoC estimate (product code)

~300–420 LoC

Implementation details (in order)

1) Add global setting: stopCoderWorkspaceOnArchive (default ON)

Why: users need a way to disable the new behavior (and revert to fast metadata-only archive).

  1. Type: src/common/types/project.ts

    • Add optional field to ProjectsConfig:
      stopCoderWorkspaceOnArchive?: boolean;
  2. Config parse/save: src/node/config.ts

    • loadConfigOrDefault() parse stopCoderWorkspaceOnArchive?: unknown using parseOptionalBoolean.
    • saveConfig() persists the field only when false (since default is true).
  3. ORPC schema + router:

    • src/common/orpc/schemas/api.ts under export const config:
      • Add to getConfig.output:
        stopCoderWorkspaceOnArchive: z.boolean(),
      • Add a targeted update endpoint (mirrors updateMuxGatewayPrefs):
        updateCoderPrefs: {
          input: z.object({ stopCoderWorkspaceOnArchive: z.boolean() }),
          output: z.void(),
        }
    • src/node/orpc/router.tsconfig.getConfig handler should return:
      • stopCoderWorkspaceOnArchive: config.stopCoderWorkspaceOnArchive ?? true
    • Add config.updateCoderPrefs handler that editConfig() sets:
      • stopCoderWorkspaceOnArchive: input.stopCoderWorkspaceOnArchive ? undefined : false
        • (store false only; true uses default semantics)
Notes on default-ON persistence
  • Keeping the value as undefined when enabled avoids rewriting ~/.mux/config.json for the default case.
  • All runtime checks should treat undefined as true.

2) Add general-purpose lifecycle hook registry (backend)

File: src/node/services/workspaceLifecycleHooks.ts (new)

Proposed API shape (blocking pre-archive hook only for now, but structured to extend later):

import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
import type { Result } from "@/common/types/result";

export type BeforeArchiveContext = {
  workspaceId: string;
  metadata: FrontendWorkspaceMetadata;
};

export type BeforeArchiveHook = (ctx: BeforeArchiveContext) => Promise<Result<void, string>>;

export class WorkspaceLifecycleHooks {
  private beforeArchiveHooks: BeforeArchiveHook[] = [];

  registerBeforeArchive(hook: BeforeArchiveHook): void {
    this.beforeArchiveHooks.push(hook);
  }

  async runBeforeArchive(ctx: BeforeArchiveContext): Promise<Result<void, string>> {
    for (const hook of this.beforeArchiveHooks) {
      const result = await hook(ctx);
      if (!result.success) {
        return result;
      }
    }
    return { success: true, data: undefined };
  }
}

Defensive behavior:

  • Hooks run sequentially.
  • Any hook may block archival by returning Err("...").
  • Hooks should not throw; but WorkspaceLifecycleHooks should still wrap each call in try/catch and convert throws to Err with a safe message.

3) Wire hooks into WorkspaceService.archive()

File: src/node/services/workspaceService.ts

  1. Add a private field + setter (matches existing pattern: setTerminalService, setMCPServerManager, etc.):

    private workspaceLifecycleHooks: WorkspaceLifecycleHooks | null = null;
    setWorkspaceLifecycleHooks(hooks: WorkspaceLifecycleHooks): void { ... }
  2. Update archive(workspaceId) flow:

    • Keep stream interruption logic as-is.
    • Before editConfig():
      • Load metadata via await this.getInfo(workspaceId).
      • If metadata is null, return Err("Workspace not found").
      • Call await this.workspaceLifecycleHooks?.runBeforeArchive({ workspaceId, metadata }).
      • If it returns Err, surface that error to the UI and do not set archivedAt.

Pseudo-diff (high level):

// after interruptStream
const metadata = await this.getInfo(workspaceId);
if (!metadata) return Err("Workspace not found");

const hookResult = await this.workspaceLifecycleHooks?.runBeforeArchive({ workspaceId, metadata });
if (hookResult && !hookResult.success) {
  return Err(hookResult.error);
}

await this.config.editConfig(... set archivedAt ...);
... emit metadata ...

4) Implement coder stop support

File: src/node/services/coderService.ts

Add a method using the existing runCoderCommand() + interpretCoderResult() helpers (with timeout):

async stopWorkspace(
  workspaceName: string,
  options?: { timeoutMs?: number; signal?: AbortSignal }
): Promise<Result<void, string>> {
  const timeoutMs = options?.timeoutMs ?? 60_000;
  const result = await this.runCoderCommand(["stop", workspaceName, "--yes"], {
    timeoutMs,
    signal: options?.signal,
  });

  const interpreted = interpretCoderResult(result);
  if (!interpreted.ok) {
    return Err(`coder stop failed: ${interpreted.error}`);
  }

  return Ok(undefined);
}

Implementation notes:

  • Consider checking getWorkspaceStatus() first and early-return if already stopped, deleted, or deleting.
  • Treat not_found as success (nothing to stop).
  • Keep output/error messages user-readable (archive UI will surface them).

5) Coder integration: register a beforeArchive hook that stops dedicated workspaces

Where to implement the hook logic:

  • Preferred: new file src/node/runtime/coderLifecycleHooks.ts (keeps Coder-specific code near the runtime without bloating CoderSSHRuntime.ts).

Hook factory:

export function createStopCoderOnArchiveHook(args: {
  coderService: CoderService;
  shouldStopOnArchive: () => boolean;
}): BeforeArchiveHook {
  return async ({ metadata }) => {
    const runtime = metadata.runtimeConfig;
    if (runtime.type !== "ssh" || !runtime.coder) return Ok(undefined);

    if (runtime.coder.existingWorkspace === true) return Ok(undefined);
    if (!args.shouldStopOnArchive()) return Ok(undefined);

    const coderWorkspaceName = runtime.coder.workspaceName?.trim();
    if (!coderWorkspaceName) return Ok(undefined);

    const status = await args.coderService.getWorkspaceStatus(coderWorkspaceName);
    if (status.kind === "not_found") return Ok(undefined);
    if (status.kind === "ok" && (status.status === "stopped" || status.status === "deleted" || status.status === "deleting")) {
      return Ok(undefined);
    }

    const stopResult = await args.coderService.stopWorkspace(coderWorkspaceName);
    if (!stopResult.success) {
      return Err(stopResult.error);
    }

    return Ok(undefined);
  };
}

Register it in src/node/services/serviceContainer.ts:

  • Instantiate WorkspaceLifecycleHooks.
  • Register the hook with:
    • shouldStopOnArchive: () => (this.config.loadConfigOrDefault().stopCoderWorkspaceOnArchive ?? true)
  • Call this.workspaceService.setWorkspaceLifecycleHooks(hooks).

6) Frontend setting toggle + “progress UI”

6a) Toggle in Settings (default ON)

File: src/browser/components/Settings/sections/GeneralSection.tsx (or a new small section if preferred)

  • On mount, load api.config.getConfig() and store stopCoderWorkspaceOnArchive in component state.
  • Add a checkbox/toggle UI labeled e.g. “Stop Coder workspace when archiving”.
  • On change, call api.config.updateCoderPrefs({ stopCoderWorkspaceOnArchive: checked }).

6b) Progress UI during archive

This is already mostly present:

  • ProjectSidebar sets archivingWorkspaceIds while awaiting onArchiveWorkspace().
  • WorkspaceListItem renders a secondary row with “Archiving…”.

Minimal additional UX (optional but nice):

  • If the workspace is Coder-backed + setting enabled, change the secondary text to “Stopping Coder workspace…”.
    • Simplest implementation: in ProjectSidebar, change archivingWorkspaceIds from Set<string> to Map<string, "archiving" | "stopping"> (or store a label string).
    • Then pass a archivingLabel prop to WorkspaceListItem.

7) Update Storybook mocks

File: src/browser/stories/mocks/orpc.ts

  • Update the mocked config.getConfig() response to include stopCoderWorkspaceOnArchive.
  • Add a mock implementation for config.updateCoderPrefs.

8) Tests

Add focused unit tests to cover behavior without requiring the real Coder CLI:

  1. WorkspaceLifecycleHooks

    • Runs hooks in order.
    • Stops on first Err.
    • Converts thrown exceptions into Err.
  2. WorkspaceService.archive

    • When a beforeArchive hook returns Err, archive returns Err and does not persist archivedAt.
    • When hook returns Ok, archive proceeds.
  3. Coder stop hook (unit-level)

    • For existingWorkspace=true → does not call coderService.stopWorkspace.
    • For missing runtime.coder.workspaceName → no-op.
    • For setting disabled → no-op.
    • For running workspace → calls stopWorkspace.

Suggested location:

  • Extend src/node/services/workspaceService.test.ts with a new describe("archive lifecycle hooks").
  • Add a small new test file for the hook registry if needed.

9) Docs

File: docs/runtime/coder.mdx

  • Add a short note in “Notes” explaining:
    • Dedicated (Mux-created) Coder workspaces can be stopped automatically when archiving, controlled by the new setting.

Alternative approach (not recommended): extend the Runtime interface

Instead of a registry, add Runtime.onWorkspaceArchived?() and have WorkspaceService.archive() instantiate a runtime and call it.

Why not: for Coder it forces runtime instantiation + SSH transport wiring just to call coder stop, and it pushes a UI concept (archive) into the low-level runtime interface.

Net new LoC estimate (product code): ~220–350 LoC (similar overall, but less clean separation)


Generated with mux • Model: openai:gpt-5.2 • Thinking: xhigh • Cost: $13.02

@ThomasK33
Copy link
Member Author

@codex review

Add unit tests for WorkspaceLifecycleHooks ordering/error handling, verify WorkspaceService.archive persists archivedAt only after successful beforeArchive hooks, and cover createStopCoderOnArchiveHook stop behavior.

Document the stop-on-archive default + config flag for Coder runtimes.
Fix ESLint issues by removing unnecessary escaping in a template literal and by avoiding async arrow functions that never await in tests.
Clarifies that archiving refers to Mux workspaces, while stopping refers to the underlying dedicated Coder workspace.
WorkspaceLifecycleHooks now supports afterUnarchive hooks and WorkspaceService.unarchive runs them after persisting unarchivedAt.

Add CoderService.startWorkspace plus a Coder afterUnarchive hook that starts mux-created dedicated Coder workspaces (best-effort, gated by stopCoderWorkspaceOnArchive).

Update unit tests and docs.
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 26869fe236

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copy link
Member Author

@codex review

Addressed the note about out-of-order writes on the Settings toggle by serializing/coalescing updateCoderPrefs calls.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7f12c59de2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copy link
Member Author

@codex review

Updated the toggle persistence logic to drain pending updates while a write is in-flight, ensuring the latest selection is persisted.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9971ece632

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copy link
Member Author

@codex review

chatgpt-codex-connector[bot]

This comment was marked as resolved.

Copy link
Member Author

Addressed the latest Codex note about interrupting active streams when archive later fails: archive() now runs beforeArchive hooks first, and only then best-effort interrupts the stream on success (with a regression test).\n\n@codex review

@chatgpt-codex-connector
Copy link

Codex Review: Didn't find any major issues. 👍

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant