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 });
+141
View File
@@ -478,3 +478,144 @@ export interface ClaudeTokenOutputEvent {
project_id: string;
chunk: string;
}
// ── Container base-image migration ───────────────────────────────────────────
//
// A project's container is created from its own `triple-c-snapshot-<id>:latest`
// image and re-committed on every recreation, so it stays on the base image it
// was first built from forever. Migration moves it onto the *current* base
// **without touching either named volume** — unlike Reset, which deletes them
// and takes the login, skills and transcripts with it.
//
// Because `/home/claude` is a volume and the image's copy of it is masked after
// the first mount, almost nothing needs replaying: Claude Code itself, cargo,
// uv, ruff, `~/.claude.json`, the OAuth credential, skills, transcripts,
// scheduled tasks and SSH keys all re-attach for free. What is genuinely lost
// on an image swap is confined to the writable layer: root-level apt installs,
// `npm -g` packages, `/usr/local`, `/opt`, `/srv`, and non-bind-mounted
// `/workspace` content. Those are exactly what `MigrationOptions` replays.
//
// Mirrors Rust `models/migration.rs` (serde snake_case).
/** How a finished migration attempt ended. Mirrors Rust `MigrationPhase`. */
export type MigrationPhase = "succeeded" | "partial" | "failed" | "rolled_back";
/** One package that could not be replayed onto the new base. */
export interface PackageFailure {
name: string;
/** Tail of the package manager's own error output. */
reason: string;
}
/** Why a project is worth migrating, and what migrating would carry across.
*
* An empty array always means "nothing found", never "not checked" —
* `probe_error` is the single place a failed inspection is reported. */
export interface ContainerStaleness {
/** The container's lineage is not the current base. Always false when
* `known` is false: an unknown lineage is not a claim of staleness. */
stale: boolean;
/** Whether the lineage could be established at all. False means the
* container predates the `triple-c.base-image-id` label — "unknown, probe
* instead", not "stale". */
known: boolean;
base_image_id: string | null;
current_base_image_id: string | null;
/** `Created` of the project's snapshot image, RFC 3339. */
snapshot_created_at: string | null;
/** Concrete paths the base ships and this container lacks, e.g. `/usr/bin/socat`. */
missing_paths: string[];
/** Human labels for the same, e.g. "Auth bridge tunnel (socat)". */
missing_features: string[];
/** apt packages the project added on top of the base; migration replays these. */
apt_delta: string[];
/** Global npm packages the base does not ship. */
npm_global_delta: string[];
/** Non-package paths under /usr/local, /opt, /srv and /workspace that would
* be carried across. Empty when nothing user-authored was found — which is
* the common case. */
verbatim_paths: string[];
/** dpkg packages the base carries at a different version. A drift measure,
* not a promise that every one is newer. */
outdated_package_count: number;
/** Set when the container/image could not be inspected; everything else is
* then at its default. */
probe_error: string | null;
}
/** What a migration should replay. All default to false. */
export interface MigrationOptions {
/** Replay the apt and `npm -g` deltas onto the new base. */
replay_packages: boolean;
/** Copy the verbatim payload (/usr/local, /opt, /srv, non-bind-mounted /workspace). */
copy_paths: boolean;
/** Keep the `:pre-migration-<ts>` rollback tag after the migration reports
* success, so it can still be undone. Costs roughly a whole snapshot on disk
* (3.812.3 GB on real projects) because snapshots share almost no layers
* with the current base. When false the tag is dropped as soon as the
* migration is known to have worked, and `rollback_available` is false. */
keep_rollback: boolean;
}
/** The outcome of one migration attempt. */
export interface MigrationReport {
phase: MigrationPhase;
packages_requested: string[];
packages_installed: string[];
packages_failed: PackageFailure[];
paths_copied: string[];
/** Human labels for base features the container gained. */
features_restored: string[];
/** A `:pre-migration-<ts>` image still exists, so `rollbackMigration` works. */
rollback_available: boolean;
/** One paragraph fit to show the user verbatim. */
message: string;
}
/** In-flight phases of `MigrationState.phase`. Distinct from `MigrationPhase`,
* which describes *outcomes*.
*
* These are **hyphenated**, matching the `triple-c.migration-state=in-progress`
* container label so there is exactly one spelling in the system. Compare
* against the constants below rather than writing the literals — that is what
* they are for. */
export type MigrationStatePhase =
| "in-progress"
| "interrupted"
| "awaiting-confirmation";
/** A migration is running right now. Poll `getMigrationState` until it changes. */
export const MIGRATION_PHASE_IN_PROGRESS = "in-progress";
/** The app died after the container was swapped. Offer resume (call
* `migrateProjectToBase` again — it picks the interrupted run up) or rollback. */
export const MIGRATION_PHASE_INTERRUPTED = "interrupted";
/** Finished; `report` is populated. Offer confirm or rollback. */
export const MIGRATION_PHASE_AWAITING_CONFIRMATION = "awaiting-confirmation";
/** What a migration decided to do, frozen at pre-flight time so a resume
* replays the same thing (the deltas cannot be recomputed after the swap). */
export interface MigrationPlan {
apt_packages: string[];
npm_packages: string[];
verbatim_paths: string[];
missing_paths: string[];
}
/** Persisted host-side migration record. Present only while a migration is in
* flight or waiting for a decision; `confirmMigration` and `rollbackMigration`
* both clear it. */
export interface MigrationState {
/** One of `MigrationStatePhase`; typed loosely because an unrecognised value
* from a future build must not crash the UI. */
phase: string;
from_image_id: string | null;
to_base_id: string | null;
started_at: string;
report: MigrationReport | null;
/** The `:pre-migration-<ts>` tag holding the old system layer, if kept. */
rollback_image: string | null;
/** Host path of the staged payload tar, while one exists. */
staging_path: string | null;
options: MigrationOptions;
plan: MigrationPlan | null;
}