Migrate a project onto a new base image without losing its volumes

Projects were pinned to the image they were first created from. Both
create paths preferred triple-c-snapshot-<id>:latest whenever it
existed, and container_needs_recreation compared the container's live
image against the triple-c.image label — which create_container wrote
from the same image it created from. A tautology that could never fire.
The only escape was Reset, which calls remove_project_volumes and
destroys the login, skills and transcripts.

Measured consequences on this host: real projects are missing socat (so
the auth bridge cannot tunnel) and bubblewrap (so sandbox mode does not
work), plus Mission Control and triple-c-sso-refresh, and sit 61
packages behind the base including ca-certificates, openssl and curl.

Detection. create_container now writes triple-c.base-image-id (the image
ID, not RepoDigests, which local-built and custom images do not have)
and triple-c.create-image. container_needs_recreation takes the expected
create-image and compares against the latter, so the check means
something. base-image-id is deliberately NOT compared: a base bump would
otherwise silently recreate from the snapshot, consuming the "you should
migrate" signal without migrating. Staleness is surfaced, never acted on
automatically.

Migration keeps the volumes. /home/claude and ~/.claude are volumes and
the image's copy is seed-only — permanently masked after first mount —
so the login, ~/.claude.json, skills, transcripts, scheduler tasks, SSH
keys, cargo, uv, ruff and Claude Code itself re-attach untouched. Only
root-level state is rebuilt: apt packages are replayed against the new
base rather than copied, so no stale libc is dragged forward, and
/usr/local, /opt and the non-bind-mounted parts of /workspace are copied
verbatim with tar --skip-old-files so they can never clobber a newer
base binary.

docker diff is not used: on a snapshot-derived container it reports only
changes since the last commit. Raw image-vs-image diffing is filtered
through dpkg ownership because it otherwise lies — 8,677 raw path
differences on a real project reduced to 2 genuinely user-authored
files, both loose /workspace-root files.

Crash safety. snapshot:latest keeps pointing at the old image until the
final commit, so any crash before it self-heals on next start. Later
crashes are caught by reconcile_project_statuses. The rollback pin is a
docker tag: 0.057s and 0 bytes. Rollback restores the system layer only
— volumes are never touched — and the UI says so rather than implying a
time machine.

Fixes an infinite recreation loop shipped with the MCP removal. docker
commit propagates labels to the image, so a container created from a
snapshot inherited its non-empty triple-c.mcp-fingerprint and the
one-shot shim recreated it again on every start, forever. Lineage labels
are now always written explicitly.

Documents the second, separate bug this uncovered: Dockerfile changes
under /home/claude never reach an existing project, migration or not,
because the volume masks them. Anything that must stay upgradable
belongs in /usr/local/bin or /opt, or must be seeded by entrypoint.sh.

145 Rust tests, 227 frontend tests, both builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 18:19:12 -07:00
co-authored by Claude Opus 5
parent cc5f691677
commit d42b741337
26 changed files with 5704 additions and 58 deletions
+42 -1
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, PlaywrightDetection } from "./types";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, PlaywrightDetection, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState } from "./types";
// Docker
export const checkDocker = () => invoke<boolean>("check_docker");
@@ -200,3 +200,44 @@ export const submitClaudeTokenCode = (code: string) =>
export const cancelClaudeToken = () => invoke<void>("cancel_claude_token");
export const hasClaudeToken = () => invoke<boolean>("has_claude_token");
export const clearClaudeToken = () => invoke<void>("clear_claude_token");
// Container base-image migration — move a project onto the current base image
// without deleting its volumes. Reset is the destructive alternative: it wipes
// ~/.claude, the OAuth credential, installed skills and every transcript.
//
// Flow: getContainerStaleness (read-only, ~6s — two filesystem probes, so call
// it on demand rather than polling) → migrateProjectToBase → the project sits
// in "awaiting-confirmation" while the user tries it → confirmMigration or
// rollbackMigration.
//
// Rollback restores the **system layer only**. Both named volumes are untouched
// throughout, so anything written to $HOME during the migrated session — a new
// login, new skills, new transcripts — survives a rollback.
//
// Progress arrives on the existing `container-progress` event.
/** Read-only. Runs two container/image filesystem probes; not for polling. */
export const getContainerStaleness = (projectId: string) =>
invoke<ContainerStaleness>("get_container_staleness", { projectId });
/** Runs the whole migration and resolves with its report. Long-running — the
* apt replay alone was measured at ~70s for 8 packages. Calling it again while
* a migration is `interrupted` resumes that one instead of starting a new one. */
export const migrateProjectToBase = (projectId: string, options: MigrationOptions) =>
invoke<MigrationReport>("migrate_project_to_base", { projectId, options });
/** Accept the migration: drops the rollback tag and the staged payload, and
* clears the record. Idempotent. */
export const confirmMigration = (projectId: string) =>
invoke<void>("confirm_migration", { projectId });
/** Undo the migration: recreates the container from its pre-migration image.
* Fails if the migration kept no rollback image (`keep_rollback: false`). */
export const rollbackMigration = (projectId: string) =>
invoke<void>("rollback_migration", { projectId });
/** The persisted record, or null when no migration is in flight. Worth calling
* after `reconcileProjectStatuses` at startup: a migration interrupted by an
* app crash shows up here as phase "interrupted". */
export const getMigrationState = (projectId: string) =>
invoke<MigrationState | null>("get_migration_state", { projectId });