Skip to content

Choose where to create a workroom: this Mac, Docker or Apple container #309

Closed

Description

@joelmoss

🤖 Left by Claude Code.

Summary

Let users create workrooms in a local container, on Docker or Apple's container: a Linux sandbox per workroom, on the user's own Mac.

These are not remote workrooms. The container runs on the Mac, so it stops when the Mac sleeps. The remote headline, "the work keeps running when the laptop closes" (docs/designs/remote-workrooms.md:90), stays with the cloud providers (boxd #256/#258, exe.dev #259). The release gate #260 is unchanged.

Out of scope:

Why

Scope

1. Ship the image

  • Build once, test, then publish the same image. CI builds the FIXTURE=0 image once per architecture (amd64 + arm64) and runs host-image-test.sh against those exact images; the script must take an image reference instead of building its own. Only then does CI push them to ghcr as one multi-arch manifest and record its digest.
  • Each app build pins that digest, so an app version always runs the image it was tested with. remoteHostImage remains as the override for development.
  • Pull explicitly, then run with --pull=never. Today run has no --pull flag (ContainerHostDriver.swift:315), so a missing local image falls back to a Docker Hub pull of library/workroom-host.
  • Show pull progress and failures in the UI, not as a raw CLI error.

2. One driver per runtime, routed by each workroom's record

  • Today RemoteHosts builds one ContainerHostDriver (RemoteWorkrooms.swift:340-349). RemoteWorkrooms.create takes that concrete type (:136) and hard-codes driver: "container" (:165,176,212,239).
  • Keep one driver per kind instead. "container" keeps meaning Docker, so existing records work; add "apple-container". Adopt, sweep and reconnect route by each descriptor's kind.
  • Pin the Docker context. At create, store docker context show in the Docker record, and pass --context on every command for that workroom. A missing context gives a clear error, and the sweep covers each recorded context. Without this, switching between OrbStack and Docker Desktop makes workrooms seem to vanish.
  • A base per runtime. The project's host keeps one base per (runtime, Docker context), and create uses or makes the base matching the chosen target. Today a project has one base, and its runtime is never checked (RemoteWorkrooms.swift:139-150).
  • Run the runtime CLIs through StatusCommandRunning, as BoxdHostDriver does, so tests can drive them with a stub CLI.

3. Support Apple container

These gaps come from Apple's command reference, not yet tried on a real install. The runtime differences go in a small dialect inside ContainerHostDriver (argument builders plus output parsing), not a second driver.

Driver uses (Docker) Apple container
run --restart unless-stopped (:315) no --restart; see §4
ps -a --filter … --format '{{…}}', images -aq --filter (sweep, :238, :254) no --filter; --format takes only json/table/yaml/toml. Parse JSON in Swift
image inspect --format '{{index .Config.Labels …}}' (:259) read labels from JSON
rmi image delete
commit --change "LABEL …" (derive, :136) commit has no label option: container commit <id> <reference>. Name each derived image workroom-<bundle id>-<epoch>-<uuid>. The rollback and the sweep find images by that prefix, and the sweep reads age from the epoch. A missing age must never count as old (today's old() treats missing as old, :231)
shortImageID (12-hex / sha256:) Apple's output format is unverified; it is part of the dialect
daemon always running container system start asks whether to install a kernel, so the app never runs it. A stopped service shows as a disabled reason, with the command for the user to run

4. Start a stopped container only when it is opened

  • Apple has no restart policy, and a container stopped by hand needs the same handling. When the user opens a workroom (terminal, Changes or Files), the app starts its container if it is stopped, then connects. This applies to both runtimes.
  • Background status probes must not start containers. They reach every healthy remote workroom (Models.swift:294-299, AppStore+WorkroomStatus.swift:67-68) through connectRemote, so a probe of a stopped container reports "stopped" instead.

5. Choose where to create a workroom

  • The sidebar's New Workroom becomes a submenu (This Mac / Docker / Apple container) once more than one place is enabled. With only This Mac enabled, as in every stable build until Phase 4 gate: the success criteria on two real providers #260, it stays a plain item.
  • The user picks the runtime; the app never picks one for them.
  • Docker covers Docker Desktop, OrbStack and Colima, found in the usual install paths, because a GUI app's PATH contains none of them.
  • An entry that can't be used is shown disabled, with its reason in its title. Reasons include: not installed, service stopped (container system start), macOS too old, not Apple silicon, or no credentials (§6).
  • Every workroom records its target, and the UI shows it.
  • The Remote entries (boxd, exe.dev) are not part of this issue. Each one lands with its driver (boxd live-fork derivation, as an accelerator behind deriveFromBase #258, exe.dev driver: the second real provider #259) on top of the driver-per-kind routing in §2.

6. GitHub credentials for local containers

  • When signed in to Codaset and the Codaset App is installed for the repository's owner, use the broker as remote workrooms do: enrolment and App-scoped tokens.
  • Otherwise, use a Mac relay:
    • wr-agent credential get forwards to the app over a reverse forward on the app's ssh connection, the BrokerReverseForwards mechanism.
    • The app answers only for github.com, from gh auth token or the keychain.
    • Nothing is persisted in the container.
    • This gives a container agent the same GitHub access a This Mac workroom already has.
  • Remote workrooms stay broker-only.

7. Resource limits

Set CPU and memory caps per container, with a default and a Settings option. Each Apple container is a lightweight VM, so measure before picking the defaults.

8. Release

  • Ships together with the Remote targets: until Phase 4 gate: the success criteria on two real providers #260 passes, Docker and Apple container stay behind remoteWorkroomsPreview and the Nightly check (RemoteWorkrooms.swift:15-19).
  • This Mac is always available.
  • The local entries must not promise that work survives a closed lid.

Tests

  • Stub-CLI unit tests in CI for both dialects:
    • argument building
    • JSON parsing
    • name-prefix sweep with age from the name
    • a derive racing a sweep
    • error mapping
  • Regression contract for existing Docker workrooms:
    • An old descriptor ("container", no context, single base) still adopts and reconnects as Docker on the current context.
    • The Docker sweep's rules are unchanged.
    • Proof: a decoding and routing test in RemoteHostsTests, a stub test of the Docker sweep's commands, and the run.sh fixture suites passing unchanged.
  • A unit test that a sidebar status refresh leaves an unopened, stopped container stopped.
  • An XCUITest of the submenu: it shows only when more than one place is enabled, and a disabled entry shows its reason.
  • AppleContainerIntegrationTests, opt-in with WR_APPLE_CONTAINER_TESTS=1:
    • covers create, derive, stop and reconnect, delete and sweep on a real Apple container
    • added to the pre-release checklist, since CI's macOS runners can't run Apple containers.

Implementation tasks

From the eng review, in dependency order: T1 first, then T2-T8 as the parallelization allows.

  • T1 (P1, human: ~1d / CC: ~30min) — Core — One driver per kind, route by descriptor, keep "container" = Docker
    • Files: Core/RemoteWorkrooms.swift, Core/ContainerHostDriver.swift, Core/Models.swift
    • Verify: make app-test (RemoteHostsTests), run.sh suites
  • T2 (P1, human: ~4h / CC: ~15min) — Core — Pin the Docker context per workroom
    • Files: Core/ContainerHostDriver.swift, Core/Models.swift
    • Verify: stub-CLI test that --context appears on every command
  • T3 (P1, human: ~1d / CC: ~30min) — Core — Base per (runtime, context)
    • Files: Core/RemoteWorkrooms.swift, Core/Models.swift
    • Verify: old-descriptor test, plus a test that mixed runtimes in one project derive from the right base
  • T4 (P1, human: ~1.5d / CC: ~45min) — Core — Apple dialect: run args, JSON parsing, named commits with age, name-prefix sweep, image delete, never system start
    • Files: Core/ContainerHostDriver.swift
    • Verify: ContainerDialectTests (stub CLI), including the derive-vs-sweep race
  • T5 (P1, human: ~3h / CC: ~15min) — Core — Start on explicit open only
    • Files: Core/RemoteWorkrooms.swift, Core/AppStore.swift
    • Verify: unit test that a status refresh leaves a stopped container stopped
  • T6 (P1, human: ~3d / CC: ~1h) — wr-agent + Broker — Broker with a Mac-relay fallback for local containers
    • Files: vcs/crates/wr-agent/src/broker.rs, Core/Broker/BrokerReverseForwards.swift, Core/RemoteProvisioning.swift
    • Verify: wr-agent unit tests for the helper source, plus a relay test in RemoteProvisioningTests
  • T7 (P1, human: ~1d / CC: ~30min) — CI — Build once, test those images, publish the multi-arch manifest, embed the digest
    • Files: .github/workflows/*.yml, vcs/scripts/ssh-fixture/host-image-test.sh, macapp build settings
    • Verify: CI run on a tag; the digest in the app matches the pushed manifest
  • T8 (P2, human: ~4h / CC: ~15min) — Views — New Workroom submenu with disabled reasons
    • Files: Views/ProjectSidebar.swift, Core/AppStore.swift
    • Verify: XCUITest (-only-testing) of the submenu states
  • T9 (P2, human: ~4h / CC: ~15min) — Tests — Regression contract tests
    • Files: WorkroomAppTests/RemoteHostsTests.swift, WorkroomAppTests/ContainerDialectTests.swift
    • Verify: break the old-descriptor routing and confirm the tests go red
  • T10 (P2, human: ~1d / CC: ~30min) — Tests — AppleContainerIntegrationTests (opt-in), added to the pre-release checklist
    • Files: WorkroomAppTests/AppleContainerIntegrationTests.swift, docs
    • Verify: WR_APPLE_CONTAINER_TESTS=1 run on an Apple-silicon Mac; it answers open questions 1-3
  • T11 (P3, human: ~2h / CC: ~10min) — Core — Measure Apple VM memory, then set the default caps
    • Files: Core/ContainerHostDriver.swift, Core/DefaultsKeys.swift
    • Verify: a documented measurement

Acceptance

  • A fresh Mac with only Docker Desktop, signed in only to gh, with no repo checkout, creates, opens, derives and deletes a local container workroom.
  • The same on a Mac with only Apple container.
  • A Mac with both runtimes can hold workrooms of one project on each.
  • After a Mac restart, opening a container workroom brings it back on both runtimes. A status refresh starts nothing.
  • Switching Docker contexts doesn't break existing Docker workrooms.
  • Existing Nightly Docker workrooms keep working after the update.
  • The sweep removes only this build's leftovers on both runtimes, and never a fresh derive.
  • The published image digest is the one the smoke test ran against, and the app pins it.
  • An entry that can't be used is shown disabled with its reason. The pull-failure case shows a message that says what to do.

Open questions

These need a probe on a real install, through the opt-in suite:

  1. Does Apple container's published port survive a container restart?
  2. What does container commit print, and what does container list --format json look like, including labels?
  3. Can Apple container pull from ghcr?

Related: #228, #231, #252, #253, #258, #259, #260, #288, #299, #307, #308.

Activity

  1. 28 remaining items

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions