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
@@ -0,0 +1,241 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import MigrateContainerModal from "./MigrateContainerModal";
import type { ContainerMigration } from "../../hooks/useContainerMigration";
import type { ContainerStaleness } from "../../lib/types";
/** Modal focuses via rAF so the panel is laid out first; jsdom needs a flush. */
async function flushFocus() {
await act(async () => {
vi.advanceTimersByTime(20);
});
}
const STALE: ContainerStaleness = {
stale: true,
known: true,
base_image_id: "sha256:aaa",
current_base_image_id: "sha256:bbb",
snapshot_created_at: "2026-03-01T09:00:00Z",
missing_paths: ["/usr/bin/socat"],
missing_features: ["Auth bridge tunnel (socat)", "Mission Control"],
apt_delta: ["socat", "bubblewrap"],
npm_global_delta: [],
verbatim_paths: [],
outdated_package_count: 61,
probe_error: null,
};
function migration(overrides: Partial<ContainerMigration> = {}): ContainerMigration {
return {
staleness: STALE,
probing: false,
running: false,
recovered: false,
interrupted: null,
report: null,
log: [],
phaseMessage: null,
busy: false,
start: vi.fn(async () => {}),
resume: vi.fn(async () => {}),
keep: vi.fn(async () => {}),
rollback: vi.fn(async () => {}),
dismiss: vi.fn(),
refresh: vi.fn(async () => {}),
...overrides,
};
}
async function renderModal(
staleness: ContainerStaleness | null = STALE,
overrides: Partial<ContainerMigration> = {},
) {
const m = migration({ staleness, ...overrides });
const onClose = vi.fn();
render(
<MigrateContainerModal
projectName="api-server"
staleness={staleness}
migration={m}
onClose={onClose}
/>,
);
await flushFocus();
return { m, onClose };
}
describe("MigrateContainerModal", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["requestAnimationFrame", "setTimeout"] });
});
afterEach(() => {
vi.useRealTimers();
});
describe("pre-flight", () => {
it("leads with what is kept, as a statement rather than a choice", async () => {
await renderModal();
const kept = screen.getByText("Kept automatically");
expect(kept).toBeInTheDocument();
expect(screen.getByText(/no signing in again/i)).toBeInTheDocument();
expect(screen.getByText(/every saved session transcript/i)).toBeInTheDocument();
expect(screen.getByText(/are Docker volumes/i)).toBeInTheDocument();
// Reassurance comes first: it is above the replay section in the DOM.
const replay = screen.getByText(/Reinstalled from the new base's repos/);
expect(kept.compareDocumentPosition(replay)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
// And it is a statement — there is no switch attached to it.
const keptSection = kept.closest("section");
expect(keptSection?.querySelector('[role="switch"]')).toBeNull();
});
it("hides the verbatim-copy section when nothing user-authored was found", async () => {
await renderModal({ ...STALE, verbatim_paths: [] });
expect(screen.queryByText(/Copied across as-is/i)).not.toBeInTheDocument();
});
it("shows the verbatim-copy section with its paths when there are some", async () => {
await renderModal({
...STALE,
verbatim_paths: ["/usr/local/bin/deploy.sh", "/etc/pki/corp.crt"],
});
expect(screen.getByText("Copied across as-is (2)")).toBeInTheDocument();
expect(screen.getByText("/usr/local/bin/deploy.sh")).toBeInTheDocument();
expect(screen.getByText("/etc/pki/corp.crt")).toBeInTheDocument();
});
it("counts the apt packages and states the rollback's disk cost", async () => {
await renderModal();
expect(
screen.getByText("Reinstalled from the new base's repos (2)"),
).toBeInTheDocument();
expect(screen.getByText("socat")).toBeInTheDocument();
expect(screen.getByText("bubblewrap")).toBeInTheDocument();
expect(screen.getByText(/3.812.3 GB/)).toBeInTheDocument();
expect(
screen.getByText(/Rollback restores the system layer only/i),
).toBeInTheDocument();
});
it("lists the gains as the inverse of the missing features", async () => {
await renderModal();
expect(screen.getByText("You will gain")).toBeInTheDocument();
expect(screen.getByText(/Auth bridge tunnel \(socat\)/)).toBeInTheDocument();
expect(screen.getByText(/Mission Control/)).toBeInTheDocument();
expect(
screen.getByText(
/61 packages the current base carries at a different version/i,
),
).toBeInTheDocument();
});
it("passes the three options through when the run is started", async () => {
const { m } = await renderModal({
...STALE,
verbatim_paths: ["/usr/local/bin/deploy.sh"],
});
fireEvent.click(
screen.getByRole("switch", {
name: /Keep a rollback image until I confirm/i,
}),
);
fireEvent.click(
screen.getByRole("button", { name: "Update container base" }),
);
expect(m.start).toHaveBeenCalledWith({
replay_packages: true,
copy_paths: true,
keep_rollback: false,
});
});
it("does not ask to copy paths when there are none to copy", async () => {
const { m } = await renderModal({ ...STALE, verbatim_paths: [] });
fireEvent.click(
screen.getByRole("button", { name: "Update container base" }),
);
expect(m.start).toHaveBeenCalledWith({
replay_packages: true,
copy_paths: false,
keep_rollback: true,
});
});
it("does not start anything on cancel", async () => {
const { m, onClose } = await renderModal();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(m.start).not.toHaveBeenCalled();
});
});
describe("mid-run", () => {
const RUNNING: Partial<ContainerMigration> = {
running: true,
log: ["Snapshotting container…", "Creating container on the new base…"],
phaseMessage: "Creating container on the new base…",
};
it("streams the phase message and the output", async () => {
await renderModal(STALE, RUNNING);
expect(screen.getByRole("status").textContent).toBe(
"Creating container on the new base…",
);
const log = screen.getByTestId("migration-log");
expect(log.textContent).toContain("Snapshotting container…");
expect(log.textContent).toContain("Creating container on the new base…");
});
it("can be dismissed without cancelling the run", async () => {
const { m, onClose } = await renderModal(STALE, RUNNING);
// A run takes minutes; blocking the app for it would be wrong, so the
// dialog closes and the work carries on.
expect(
screen.getByText(/keeps running if you close it/i),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Hide" }));
expect(onClose).toHaveBeenCalledTimes(1);
// Nothing on the migration was touched — closing is not cancelling.
expect(m.start).not.toHaveBeenCalled();
expect(m.rollback).not.toHaveBeenCalled();
expect(m.dismiss).not.toHaveBeenCalled();
});
it("still closes on Escape and on the header ✕ while running", async () => {
const { m, onClose } = await renderModal(STALE, RUNNING);
fireEvent.click(screen.getByRole("button", { name: "Close dialog" }));
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(2);
expect(m.dismiss).not.toHaveBeenCalled();
});
});
describe("outcome", () => {
it("shows the report in place of the pre-flight once it lands", async () => {
await renderModal(STALE, {
report: {
phase: "partial",
packages_requested: ["socat", "bubblewrap"],
packages_installed: ["socat"],
packages_failed: [
{ name: "bubblewrap", reason: "held back by apt-mark" },
],
paths_copied: [],
features_restored: ["Auth bridge tunnel (socat)"],
rollback_available: true,
message: "",
},
});
expect(screen.getByText(/Updated, but not completely/i)).toBeInTheDocument();
expect(screen.queryByText("Kept automatically")).not.toBeInTheDocument();
expect(screen.getByText(/held back by apt-mark/)).toBeInTheDocument();
});
});
});
@@ -0,0 +1,310 @@
import { useEffect, useRef, useState } from "react";
import type { ContainerStaleness, MigrationOptions } from "../../lib/types";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import Toggle from "../ui/Toggle";
import { SwitchRow } from "../ui/Field";
import MigrationReportCard from "./MigrationReportCard";
import type { ContainerMigration } from "../../hooks/useContainerMigration";
import {
KEPT_AUTOMATICALLY,
KEPT_WHY,
LOST_WITHOUT_REPLAY,
MID_RUN_SAFETY,
REPLAY_COST,
ROLLBACK_DISK_COST,
ROLLBACK_SCOPE,
formatSnapshotDate,
} from "./migrationCopy";
interface Props {
projectName: string;
staleness: ContainerStaleness | null;
migration: ContainerMigration;
onClose: () => void;
}
function Section({
title,
children,
control,
}: {
title: string;
children: React.ReactNode;
control?: React.ReactNode;
}) {
return (
<section className="border border-[var(--border-color)] rounded-[var(--radius-panel)] bg-[var(--bg-secondary)] px-3.5 py-3">
{control ? (
<SwitchRow label={title} control={control} />
) : (
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">{title}</h3>
)}
<div className="mt-2 space-y-1.5">{children}</div>
</section>
);
}
function BulletList({ items, mono = false }: { items: string[]; mono?: boolean }) {
return (
<ul className="space-y-1 pl-4 list-disc marker:text-[var(--text-disabled)]">
{items.map((item) => (
<li
key={item}
className={`text-xs leading-snug text-[var(--text-secondary)] ${
mono ? "font-mono break-all" : ""
}`}
>
{item}
</li>
))}
</ul>
);
}
/**
* Pre-flight, progress and outcome for a base-image migration, in one dialog.
*
* Order matters here. The reassurance comes first — almost nothing painful is
* at risk, because the two volumes re-attach untouched — and only then the
* short list of things that genuinely have to be put back. Leading with the
* options would read as "pick which of your data to lose".
*
* Once the run starts the dialog stays **dismissible**: this takes minutes, and
* a modal that blocks the whole app for the duration is worse than no progress
* UI at all. Closing it hides a view; the work and its log live in the hook.
*/
export default function MigrateContainerModal({
projectName,
staleness,
migration,
onClose,
}: Props) {
const [replayPackages, setReplayPackages] = useState(true);
const [copyPaths, setCopyPaths] = useState(true);
const [keepRollback, setKeepRollback] = useState(true);
const logRef = useRef<HTMLDivElement>(null);
const { running, report, log, phaseMessage, busy } = migration;
const aptDelta = staleness?.apt_delta ?? [];
const npmDelta = staleness?.npm_global_delta ?? [];
const verbatim = staleness?.verbatim_paths ?? [];
const gains = staleness?.missing_features ?? [];
const snapshot = formatSnapshotDate(staleness?.snapshot_created_at ?? null);
// Follow the tail of the apt output, the way a terminal would.
useEffect(() => {
const el = logRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [log.length]);
const start = () => {
const options: MigrationOptions = {
replay_packages: replayPackages,
copy_paths: copyPaths && verbatim.length > 0,
keep_rollback: keepRollback,
};
void migration.start(options);
};
// ---- Outcome ------------------------------------------------------------
if (report) {
return (
<Modal
title={`Update container base — ${projectName}`}
onClose={onClose}
widthClassName="w-[34rem]"
footer={
<Button size="md" variant="ghost" onClick={onClose}>
Close
</Button>
}
>
<MigrationReportCard
report={report}
busy={busy}
onKeep={() => void migration.keep().then(onClose)}
onRollback={() => void migration.rollback().then(onClose)}
onDismiss={() => {
migration.dismiss();
onClose();
}}
/>
</Modal>
);
}
// ---- Progress -----------------------------------------------------------
if (running) {
return (
<Modal
title={`Updating container base — ${projectName}`}
description="This keeps running if you close it. You can carry on using the app."
onClose={onClose}
widthClassName="w-[34rem]"
footer={
<Button size="md" variant="ghost" onClick={onClose}>
Hide
</Button>
}
>
<div className="space-y-3">
<p
role="status"
aria-live="polite"
className="text-[13px] text-[var(--text-primary)]"
>
{phaseMessage ?? "Starting…"}
</p>
<div
ref={logRef}
data-testid="migration-log"
className="h-56 overflow-y-auto px-2.5 py-2 font-mono text-[11px] leading-relaxed text-[var(--text-secondary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] whitespace-pre-wrap break-all select-text"
>
{log.length === 0 ? "Waiting for the first step…" : log.join("\n")}
</div>
<p className="text-xs text-[var(--text-secondary)] leading-snug">
{MID_RUN_SAFETY}
</p>
</div>
</Modal>
);
}
// ---- Pre-flight ---------------------------------------------------------
return (
<Modal
title={`Update container base — ${projectName}`}
description={
snapshot
? `Rebuilds this container on the current base image. It is running on a saved image from ${snapshot}.`
: "Rebuilds this container on the current base image."
}
onClose={onClose}
widthClassName="w-[36rem]"
footer={
<>
<Button size="md" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button size="md" variant="primary" onClick={start}>
Update container base
</Button>
</>
}
>
<div className="space-y-3">
{/* 1. Reassurance first. Not a choice — a statement of fact. */}
<Section title="Kept automatically">
<BulletList items={KEPT_AUTOMATICALLY} />
<p className="text-xs text-[var(--text-secondary)] leading-snug">{KEPT_WHY}</p>
<p className="text-xs text-[var(--text-secondary)] leading-snug">
{LOST_WITHOUT_REPLAY}
</p>
</Section>
{/* 2. The apt replay. */}
<Section
title={`Reinstalled from the new base's repos (${aptDelta.length})`}
control={
<Toggle
label="Reinstall system packages from the new base's repositories"
checked={replayPackages}
onChange={setReplayPackages}
/>
}
>
{aptDelta.length === 0 ? (
<p className="text-xs text-[var(--text-secondary)]">
No extra apt packages were found on this container.
</p>
) : (
<BulletList items={aptDelta} mono />
)}
{npmDelta.length > 0 && (
<>
<p className="text-xs text-[var(--text-secondary)] pt-1">
Global npm packages ({npmDelta.length}):
</p>
<BulletList items={npmDelta} mono />
</>
)}
<p className="text-xs text-[var(--text-secondary)]">{REPLAY_COST}</p>
</Section>
{/* 3. Verbatim copies — usually nothing, so usually not shown at all. */}
{verbatim.length > 0 && (
<Section
title={`Copied across as-is (${verbatim.length})`}
control={
<Toggle
label="Copy user-authored files across as-is"
checked={copyPaths}
onChange={setCopyPaths}
/>
}
>
<p className="text-xs text-[var(--text-secondary)]">
Content under <code className="font-mono">/usr/local</code>,{" "}
<code className="font-mono">/opt</code>,{" "}
<code className="font-mono">/srv</code> and non-bind-mounted{" "}
<code className="font-mono">/workspace</code> that belongs to no
package, so it cannot be reinstalled from a repository.
</p>
<BulletList items={verbatim} mono />
</Section>
)}
{/* 4. The rollback image, with its real disk cost stated. */}
<Section
title="Keep a rollback image until I confirm"
control={
<Toggle
label="Keep a rollback image until I confirm"
checked={keepRollback}
onChange={setKeepRollback}
tone="caution"
/>
}
>
<p className="text-xs text-[var(--text-secondary)] leading-snug">
{ROLLBACK_DISK_COST}
</p>
<p className="text-xs text-[var(--text-secondary)] leading-snug">
{ROLLBACK_SCOPE}
</p>
</Section>
{gains.length > 0 && (
<section className="border border-[var(--success)]/40 bg-[var(--success-muted)] rounded-[var(--radius-panel)] px-3.5 py-3">
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
You will gain
</h3>
<ul className="mt-1.5 space-y-1">
{gains.map((feature) => (
<li
key={feature}
className="text-xs leading-snug text-[var(--text-secondary)]"
>
<span aria-hidden="true" className="text-[var(--success)]">
+{" "}
</span>
{feature}
</li>
))}
</ul>
{/* "A different version", not "behind" — the count measures drift
from the base, not a guarantee that each one is an upgrade. */}
{(staleness?.outdated_package_count ?? 0) > 0 && (
<p className="mt-1.5 text-xs text-[var(--text-secondary)]">
Plus {staleness?.outdated_package_count} package
{staleness?.outdated_package_count === 1 ? "" : "s"} the current
base carries at a different version, security updates among them.
</p>
)}
</section>
)}
</div>
</Modal>
);
}
@@ -0,0 +1,190 @@
import { useState } from "react";
import type { MigrationReport } from "../../lib/types";
import Button from "../ui/Button";
import StatusIndicator from "../ui/StatusIndicator";
import {
ROLLBACK_SCOPE,
aptRetryCommand,
failureReportText,
} from "./migrationCopy";
interface Props {
report: MigrationReport;
/** Disables the action row while confirm/rollback is in flight. */
busy?: boolean;
onKeep: () => void;
onRollback: () => void;
/** Only offered when there is nothing to keep or roll back. */
onDismiss: () => void;
}
/**
* The outcome of a migration, rendered identically in the Overview banner and
* in the modal so a user who closed the modal is not shown a different story.
*
* A **partial** is the case this component exists for. The user arrived here
* because containers degrade silently — a run that quietly dropped `socat` and
* called itself a success would be exactly the same bug in a new place. So a
* partial is painted as a warning, names every package and the reason it
* failed, and hands over the literal `apt-get` line to finish the job.
*/
export default function MigrationReportCard({
report,
busy = false,
onKeep,
onRollback,
onDismiss,
}: Props) {
const [copied, setCopied] = useState<"command" | "detail" | null>(null);
const partial = report.phase === "partial";
const failed = report.phase === "failed";
const rolledBack = report.phase === "rolled_back";
const copy = async (what: "command" | "detail", text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(what);
setTimeout(() => setCopied(null), 2000);
} catch {
// Clipboard can be denied; the text is selectable on screen either way.
}
};
// Partial and failed are painted as failures. A partial that reads as a
// success is precisely how a container ends up silently degraded.
const tone = partial || failed ? "error" : rolledBack ? "off" : "ok";
const heading = partial
? "Updated, but not completely"
: failed
? "Update failed"
: rolledBack
? "Rolled back"
: "Container base updated";
return (
<div className="space-y-3">
<div className="flex items-baseline gap-2">
<StatusIndicator tone={tone} label={heading} className="text-[13px] font-semibold" />
</div>
{report.phase === "succeeded" && (
<p className="text-[13px] text-[var(--text-secondary)]">
{report.packages_installed.length} package
{report.packages_installed.length === 1 ? "" : "s"} reinstalled,{" "}
{report.features_restored.length} feature
{report.features_restored.length === 1 ? "" : "s"} restored.
{report.paths_copied.length > 0
? ` ${report.paths_copied.length} path${report.paths_copied.length === 1 ? "" : "s"} copied across.`
: ""}
</p>
)}
{failed && (
<p className="text-[13px] text-[var(--text-secondary)]">
{report.message ||
"Update failed. Your container has been restored to its previous state."}
</p>
)}
{rolledBack && (
<p className="text-[13px] text-[var(--text-secondary)]">
{report.message || "The previous system layer has been put back."}
</p>
)}
{partial && (
<div className="space-y-2.5">
<p className="text-[13px] text-[var(--text-primary)]">
{report.packages_installed.length} of{" "}
{report.packages_requested.length} packages went back on.{" "}
<strong>
{report.packages_failed.length} did not
</strong>
, so this container is still missing something it had before.
</p>
<div
className="rounded-[var(--radius-control)] border border-[var(--error)]/40 bg-[var(--error-muted)] px-3 py-2 select-text"
data-testid="migration-failures"
>
<ul className="space-y-1.5">
{report.packages_failed.map((failure) => (
<li key={failure.name} className="text-xs leading-snug">
<span className="font-mono font-semibold text-[var(--text-primary)]">
{failure.name}
</span>
<span className="text-[var(--text-secondary)]"> {failure.reason}</span>
</li>
))}
</ul>
</div>
{report.packages_failed.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs text-[var(--text-secondary)]">
Finish by hand in a shell inside the container:
</p>
<code className="block px-2.5 py-1.5 font-mono text-xs text-[var(--text-primary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] overflow-x-auto whitespace-pre select-text">
{aptRetryCommand(report.packages_failed)}
</code>
<div className="flex flex-wrap gap-1.5">
<Button
onClick={() =>
copy("command", aptRetryCommand(report.packages_failed))
}
>
{copied === "command" ? "Copied ✓" : "Copy apt-get line"}
</Button>
<Button
onClick={() =>
copy("detail", failureReportText(report.packages_failed))
}
>
{copied === "detail" ? "Copied ✓" : "Copy failure details"}
</Button>
</div>
</div>
)}
</div>
)}
{report.features_restored.length > 0 && !failed && (
<div>
<h4 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
Restored
</h4>
<p className="mt-0.5 text-xs text-[var(--text-secondary)]">
{report.features_restored.join(", ")}
</p>
</div>
)}
{report.message && !failed && !rolledBack && (
<p className="text-xs text-[var(--text-secondary)] select-text">{report.message}</p>
)}
{report.rollback_available && (
<p className="text-xs text-[var(--text-secondary)] leading-snug">
{ROLLBACK_SCOPE}
</p>
)}
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
{report.rollback_available ? (
<>
<Button size="md" variant="primary" disabled={busy} onClick={onKeep}>
Keep
</Button>
<Button size="md" variant="danger" disabled={busy} onClick={onRollback}>
Roll back
</Button>
</>
) : (
<Button size="md" disabled={busy} onClick={onDismiss}>
Dismiss
</Button>
)}
</div>
</div>
);
}
@@ -0,0 +1,306 @@
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import ContainerMigrationBanner from "./ContainerMigrationBanner";
import type { ContainerMigration } from "../../../hooks/useContainerMigration";
import type {
ContainerStaleness,
MigrationReport,
} from "../../../lib/types";
const FRESH: ContainerStaleness = {
stale: false,
known: true,
base_image_id: "sha256:aaa",
current_base_image_id: "sha256:aaa",
snapshot_created_at: "2026-03-01T09:00:00Z",
missing_paths: [],
missing_features: [],
apt_delta: [],
npm_global_delta: [],
verbatim_paths: [],
outdated_package_count: 0,
probe_error: null,
};
const STALE: ContainerStaleness = {
...FRESH,
stale: true,
current_base_image_id: "sha256:bbb",
missing_paths: ["/usr/bin/socat", "/usr/bin/bwrap"],
missing_features: [
"Host-browser opening",
"Auth bridge tunnel (socat)",
"Mission Control",
],
apt_delta: ["socat", "bubblewrap"],
outdated_package_count: 61,
};
function migration(overrides: Partial<ContainerMigration> = {}): ContainerMigration {
return {
staleness: null,
probing: false,
running: false,
recovered: false,
interrupted: null,
report: null,
log: [],
phaseMessage: null,
busy: false,
start: vi.fn(async () => {}),
resume: vi.fn(async () => {}),
keep: vi.fn(async () => {}),
rollback: vi.fn(async () => {}),
dismiss: vi.fn(),
refresh: vi.fn(async () => {}),
...overrides,
};
}
function renderBanner(m: ContainerMigration, canMigrate = true) {
const onOpen = vi.fn();
const { container } = render(
<ContainerMigrationBanner migration={m} canMigrate={canMigrate} onOpen={onOpen} />,
);
return { onOpen, container };
}
describe("ContainerMigrationBanner", () => {
it("renders nothing when the container is on the current base", () => {
const { container } = renderBanner(migration({ staleness: FRESH }));
expect(container).toBeEmptyDOMElement();
});
it("renders nothing before the probe has returned", () => {
const { container } = renderBanner(migration({ staleness: null }));
expect(container).toBeEmptyDOMElement();
});
it("leads with the missing features rather than image digests", () => {
renderBanner(migration({ staleness: STALE }));
expect(screen.getByText(/Container base is out of date/i)).toBeInTheDocument();
expect(
screen.getByText(
/Host-browser opening, Auth bridge tunnel \(socat\) and Mission Control/,
),
).toBeInTheDocument();
expect(
screen.getByText(/61 packages differ from the versions on the current base/i),
).toBeInTheDocument();
// Digests are evidence, not the message.
expect(screen.queryByText(/sha256/)).not.toBeInTheDocument();
});
it("does not claim the packages are behind, only that they differ", () => {
renderBanner(migration({ staleness: STALE }));
// `outdated_package_count` is a drift measure; the backend explicitly does
// not promise every one of them is newer.
expect(screen.queryByText(/behind on security updates/i)).not.toBeInTheDocument();
});
it("says the container was probed when there is no base-image label", () => {
// `stale` is always false when `known` is false — an unknown lineage is not
// a claim of staleness — but the probe's own findings still have to show.
renderBanner(
migration({ staleness: { ...STALE, known: false, stale: false } }),
);
expect(screen.getByText(/probed directly/i)).toBeInTheDocument();
expect(screen.getByText(/The probe found these missing/i)).toBeInTheDocument();
// No version comparison happened, so none is implied.
expect(screen.queryByText(/Running on a saved image/i)).not.toBeInTheDocument();
expect(screen.queryByText(/out of date/i)).not.toBeInTheDocument();
});
it("stays quiet for an unlabelled container the probe found nothing wrong with", () => {
const { container } = renderBanner(
migration({
staleness: {
...FRESH,
known: false,
stale: false,
outdated_package_count: 3,
},
}),
);
expect(container).toBeEmptyDOMElement();
});
it("disables the action and explains why while the container is running", () => {
renderBanner(migration({ staleness: STALE }), false);
expect(
screen.getByRole("button", { name: /Update container base/i }),
).toBeDisabled();
expect(screen.getByText(/Stop the container to update its base/i)).toBeInTheDocument();
});
it("keeps reporting an in-flight run after the modal is closed", () => {
renderBanner(
migration({
staleness: STALE,
running: true,
phaseMessage: "Reinstalling socat…",
}),
);
expect(screen.getByText(/Updating container base/i)).toBeInTheDocument();
expect(screen.getByText("Reinstalling socat…")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Show progress/i })).toBeInTheDocument();
});
it("surfaces a run recovered from a crash", () => {
renderBanner(migration({ staleness: STALE, running: true, recovered: true }));
expect(
screen.getByText(/A container base update was already running/i),
).toBeInTheDocument();
expect(
screen.getByText(/still in progress when the app last closed/i),
).toBeInTheDocument();
});
it("does not let an interrupted migration hide behind a plain staleness notice", () => {
const m = migration({
staleness: STALE,
interrupted: {
phase: "interrupted",
from_image_id: "sha256:aaa",
to_base_id: "sha256:bbb",
started_at: "2026-08-09T10:00:00Z",
report: null,
rollback_image: "triple-c-snapshot-p1:pre-migration-1754733600",
staging_path: null,
options: { replay_packages: true, copy_paths: false, keep_rollback: true },
plan: null,
},
});
renderBanner(m);
expect(
screen.getByText(/A container base update was interrupted/i),
).toBeInTheDocument();
expect(screen.getByText(/part-way onto the new base/i)).toBeInTheDocument();
// The plain "Update container base…" call to action must not be what is
// offered here — the container is mid-swap, so it is resume or roll back.
expect(
screen.queryByRole("button", { name: /Update container base/i }),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Resume update" }));
expect(m.resume).toHaveBeenCalledTimes(1);
expect(screen.getByRole("button", { name: "Roll back" })).toBeInTheDocument();
});
it("offers no rollback for an interrupted run that kept no rollback image", () => {
renderBanner(
migration({
staleness: STALE,
interrupted: {
phase: "interrupted",
from_image_id: "sha256:aaa",
to_base_id: "sha256:bbb",
started_at: "2026-08-09T10:00:00Z",
report: null,
rollback_image: null,
staging_path: null,
options: { replay_packages: true, copy_paths: false, keep_rollback: false },
plan: null,
},
}),
);
expect(screen.queryByRole("button", { name: "Roll back" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Resume update" })).toBeInTheDocument();
});
describe("the report", () => {
const CLEAN: MigrationReport = {
phase: "succeeded",
packages_requested: ["socat", "bubblewrap"],
packages_installed: [
"socat",
"bubblewrap",
"ca-certificates",
"openssl",
"curl",
"jq",
"ripgrep",
"unzip",
],
packages_failed: [],
paths_copied: [],
features_restored: [
"Host-browser opening",
"Auth bridge tunnel (socat)",
"Sandbox mode (bubblewrap)",
"Mission Control",
],
rollback_available: true,
message: "",
};
const PARTIAL: MigrationReport = {
phase: "partial",
packages_requested: ["socat", "bubblewrap", "libfoo-dev"],
packages_installed: ["socat"],
packages_failed: [
{ name: "bubblewrap", reason: "held back by apt-mark" },
{ name: "libfoo-dev", reason: "no installation candidate in noble" },
],
paths_copied: [],
features_restored: ["Auth bridge tunnel (socat)"],
rollback_available: true,
message: "",
};
it("reports a clean run with counts and both choices", () => {
renderBanner(migration({ staleness: FRESH, report: CLEAN }));
expect(screen.getByText(/8 packages reinstalled/i)).toBeInTheDocument();
expect(screen.getByText(/4 features restored/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Keep" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Roll back" })).toBeInTheDocument();
});
it("names every failed package and why, and does not read as a success", () => {
renderBanner(migration({ staleness: STALE, report: PARTIAL }));
expect(screen.getByText(/Updated, but not completely/i)).toBeInTheDocument();
expect(screen.getByText("bubblewrap")).toBeInTheDocument();
expect(screen.getByText(/held back by apt-mark/)).toBeInTheDocument();
expect(screen.getByText("libfoo-dev")).toBeInTheDocument();
expect(
screen.getByText(/no installation candidate in noble/),
).toBeInTheDocument();
// And the exact line that finishes the job by hand.
expect(
screen.getByText("sudo apt-get install -y bubblewrap libfoo-dev"),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Copy apt-get line/i }),
).toBeInTheDocument();
});
it("says a failed run has already been restored, and offers no rollback", () => {
renderBanner(
migration({
staleness: STALE,
report: {
phase: "failed",
packages_requested: [],
packages_installed: [],
packages_failed: [],
paths_copied: [],
features_restored: [],
rollback_available: false,
message:
"Update failed at replay. Your container has been restored to its previous state.",
},
}),
);
expect(screen.getByText(/Update failed at replay/i)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Roll back" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
});
it("does not describe rollback as a time machine", () => {
renderBanner(migration({ staleness: FRESH, report: CLEAN }));
expect(
screen.getByText(/Rollback restores the system layer only/i),
).toBeInTheDocument();
expect(screen.getByText(/Volumes are never touched/i)).toBeInTheDocument();
});
});
});
@@ -0,0 +1,232 @@
import type { ContainerMigration } from "../../../hooks/useContainerMigration";
import Button from "../../ui/Button";
import StatusIndicator from "../../ui/StatusIndicator";
import MigrationReportCard from "../MigrationReportCard";
import { ROLLBACK_SCOPE, formatSnapshotDate, joinFeatures } from "../migrationCopy";
interface Props {
migration: ContainerMigration;
/** Migration mirrors Reset's gate: the container has to be stopped. */
canMigrate: boolean;
onOpen: () => void;
}
const SHELL =
"border rounded-[var(--radius-panel)] px-3.5 py-3 space-y-2";
/**
* The Overview answer to "why is this container behaving oddly?".
*
* It leads with the *features* that are missing, not image digests: a user does
* not know or care that `sha256:abc…` differs from `sha256:def…`, they care
* that host-browser opening and the auth bridge do not work. Digests are the
* evidence, not the message.
*
* It also has to survive the run: an in-flight migration, an interrupted one,
* and the report are all shown here, because the modal is dismissable and the
* outcome must not vanish with it.
*/
export default function ContainerMigrationBanner({
migration,
canMigrate,
onOpen,
}: Props) {
const { staleness, running, recovered, interrupted, report, phaseMessage, busy } =
migration;
// The report outranks staleness: after a run, the outcome is the news.
if (report) {
return (
<section
className={`${SHELL} ${
report.phase === "partial" || report.phase === "failed"
? "border-[var(--error)]/40 bg-[var(--error-muted)]"
: "border-[var(--border-color)] bg-[var(--bg-secondary)]"
}`}
aria-label="Container base update result"
>
<MigrationReportCard
report={report}
busy={busy}
onKeep={() => void migration.keep()}
onRollback={() => void migration.rollback()}
onDismiss={migration.dismiss}
/>
</section>
);
}
if (running) {
return (
<section
className={`${SHELL} border-[var(--warning)]/40 bg-[var(--warning-muted)]`}
aria-label="Container base update in progress"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<StatusIndicator
tone="busy"
label={
recovered
? "A container base update was already running"
: "Updating container base"
}
className="text-[13px] font-semibold"
/>
<p className="mt-1 text-xs text-[var(--text-secondary)] truncate">
{phaseMessage ?? "Starting…"}
</p>
{recovered && (
<p className="mt-1 text-xs text-[var(--text-secondary)]">
It was still in progress when the app last closed. Picking it back up.
</p>
)}
</div>
<Button size="md" onClick={onOpen}>
Show progress
</Button>
</div>
</section>
);
}
// Nothing is driving this one. It outranks staleness because the container is
// sitting mid-swap, and the one thing it must never do is look like a normal
// out-of-date container that the user can take or leave.
if (interrupted) {
return (
<section
className={`${SHELL} border-[var(--error)]/40 bg-[var(--error-muted)]`}
aria-label="Container base update was interrupted"
>
<StatusIndicator
tone="error"
label="A container base update was interrupted"
className="text-[13px] font-semibold"
/>
<p className="text-xs text-[var(--text-secondary)] leading-snug">
It started{" "}
{formatSnapshotDate(interrupted.started_at) ?? "earlier"} and the app
closed before it finished, so this container is part-way onto the new
base. Resuming replays the same plan it was given.
</p>
<p className="text-xs text-[var(--text-secondary)] leading-snug">
{ROLLBACK_SCOPE}
</p>
<div className="flex flex-wrap gap-1.5">
<Button
size="md"
variant="primary"
disabled={busy}
onClick={() => void migration.resume()}
>
Resume update
</Button>
{interrupted.rollback_image && (
<Button
size="md"
variant="danger"
disabled={busy}
onClick={() => void migration.rollback()}
>
Roll back
</Button>
)}
</div>
</section>
);
}
if (!staleness) return null;
// `stale` is deliberately false whenever `known` is false — an unestablished
// lineage is not a claim of staleness. But a container with no base-image
// label is exactly the old container most likely to be missing things, and
// the probe says so directly. So the probe's own findings are grounds to
// speak up even though the version comparison never happened.
const probeFoundGaps =
!staleness.known &&
(staleness.missing_features.length > 0 || staleness.missing_paths.length > 0);
if (!staleness.stale && !probeFoundGaps) return null;
const snapshot = formatSnapshotDate(staleness.snapshot_created_at);
const features = joinFeatures(staleness.missing_features);
return (
<section
className={`${SHELL} border-[var(--warning)]/40 bg-[var(--warning-muted)]`}
aria-label="Container base is out of date"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<StatusIndicator
tone="error"
label={
staleness.known
? "Container base is out of date"
: "Container is missing things the current base ships"
}
className="text-[13px] font-semibold"
/>
<p className="text-xs text-[var(--text-secondary)] leading-snug">
{staleness.known
? snapshot
? `Running on a saved image from ${snapshot}.`
: "Running on a saved image older than the current base."
: "This container predates base-image tracking, so it was probed directly."}
</p>
{staleness.missing_features.length > 0 && (
<p className="text-xs leading-snug text-[var(--text-primary)]">
{staleness.known ? "Missing: " : "The probe found these missing: "}
<span className="text-[var(--text-secondary)]">{features}.</span>
</p>
)}
{staleness.missing_features.length === 0 &&
staleness.missing_paths.length > 0 && (
<p className="text-xs leading-snug text-[var(--text-primary)]">
{staleness.known ? "Missing: " : "The probe found these missing: "}
<span className="font-mono text-[var(--text-secondary)]">
{staleness.missing_paths.join(", ")}
</span>
</p>
)}
{/* Deliberately "differ" rather than "behind": the count is a drift
measure, not a promise that every one of them is newer. */}
{staleness.outdated_package_count > 0 && (
<p className="text-xs text-[var(--text-secondary)] leading-snug">
{staleness.outdated_package_count} package
{staleness.outdated_package_count === 1 ? "" : "s"} differ from the
versions on the current base, where security updates land.
</p>
)}
{staleness.probe_error && (
<p className="text-xs text-[var(--text-secondary)] leading-snug">
Some checks did not complete: {staleness.probe_error}
</p>
)}
{!canMigrate && (
<p className="text-xs text-[var(--text-secondary)] leading-snug">
Stop the container to update its base.
</p>
)}
</div>
<Button
size="md"
variant="primary"
disabled={!canMigrate}
onClick={onOpen}
className="flex-shrink-0"
>
Update container base
</Button>
</div>
</section>
);
}
@@ -12,6 +12,8 @@ import PermissionModeControl, {
permissionModePatch,
} from "../PermissionModeControl";
import CapabilityTiles from "./CapabilityTiles";
import ContainerMigrationBanner from "./ContainerMigrationBanner";
import type { ContainerMigration } from "../../../hooks/useContainerMigration";
import SaveIndicator from "../../ui/SaveIndicator";
import Button from "../../ui/Button";
import { formatAge } from "./format";
@@ -31,6 +33,11 @@ interface Props {
saveState: SaveState;
actions: ReturnType<typeof useProjectActions>;
onOpenTab: (tab: ProjectHomeTabId) => void;
/** Base-image staleness, run state and report. Owned by `ProjectHome`. */
migration: ContainerMigration;
/** Migration mirrors Reset's gate: only offered on a stopped container. */
canMigrate: boolean;
onOpenMigration: () => void;
}
export default function OverviewTab({
@@ -39,6 +46,9 @@ export default function OverviewTab({
saveState,
actions,
onOpenTab,
migration,
canMigrate,
onOpenMigration,
}: Props) {
const [sessions, setSessions] = useState<ClaudeSession[]>([]);
const [tasks, setTasks] = useState<ScheduledTask[]>([]);
@@ -120,6 +130,14 @@ export default function OverviewTab({
</div>
</section>
{/* A container missing socat and bwrap is a capability statement, so the
out-of-date warning sits directly above the capability inventory. */}
<ContainerMigrationBanner
migration={migration}
canMigrate={canMigrate}
onOpen={onOpenMigration}
/>
<CapabilityTiles
project={project}
onManageInTerminal={(command) => actions.openTerminalWithCommand(command)}
@@ -4,11 +4,13 @@ import { useAppState } from "../../../store/appState";
import { useProjectActions } from "../../../hooks/useProjectActions";
import { useProjects } from "../../../hooks/useProjects";
import { useProjectSave } from "../../../hooks/useSaveState";
import { useContainerMigration } from "../../../hooks/useContainerMigration";
import { ProjectStatusIndicator } from "../../ui/StatusIndicator";
import Button from "../../ui/Button";
import OverflowMenu from "../../ui/OverflowMenu";
import ConfirmRemoveModal from "../ConfirmRemoveModal";
import ConfirmResetModal from "../ConfirmResetModal";
import MigrateContainerModal from "../MigrateContainerModal";
import OverviewTab from "./OverviewTab";
import SessionsTab from "./SessionsTab";
import AutomationTab from "./AutomationTab";
@@ -43,6 +45,7 @@ export default function ProjectHome({ projectId, active }: Props) {
const [tab, setTab] = useState<ProjectHomeTabId>("overview");
const [confirmRemove, setConfirmRemove] = useState(false);
const [confirmReset, setConfirmReset] = useState(false);
const [showMigration, setShowMigration] = useState(false);
const { runningSince, progress } = useAppState(
useShallow((s) => ({
runningSince: s.runningSince[projectId],
@@ -64,6 +67,11 @@ export default function ProjectHome({ projectId, active }: Props) {
const { save, saveState } = useProjectSave(
project ?? ({ id: projectId, name: "" } as never),
);
// Owned here, not in the modal: the run outlives the dialog, and the Overview
// banner has to keep showing progress and the report after it is dismissed.
const migration = useContainerMigration(
project ?? ({ id: projectId, name: "", container_id: null } as never),
);
const uptime = useMemo(() => formatUptime(runningSince), [runningSince]);
@@ -81,6 +89,16 @@ export default function ProjectHome({ projectId, active }: Props) {
const isTransitioning =
project.status === "starting" || project.status === "stopping";
const isStopped = project.status === "stopped" || project.status === "error";
// Rebuilding on a new base swaps the container out, so it gates exactly like
// Reset does — with the extra condition that there is a container to migrate.
// An interrupted migration is excluded too: its action is Resume, on the
// Overview banner, not a fresh pre-flight.
const canMigrate =
isStopped &&
!actions.busy &&
!migration.running &&
!migration.interrupted &&
!!project.container_id;
return (
<div className={`flex flex-col h-full min-h-0 ${active ? "" : "hidden"}`}>
@@ -147,6 +165,11 @@ export default function ProjectHome({ projectId, active }: Props) {
onSelect: actions.handleBackup,
disabled: actions.backingUp || !project.container_id,
},
{
label: "Update container base…",
onSelect: () => setShowMigration(true),
disabled: !canMigrate,
},
{
label: "Reset container…",
onSelect: () => setConfirmReset(true),
@@ -200,6 +223,9 @@ export default function ProjectHome({ projectId, active }: Props) {
saveState={saveState}
actions={actions}
onOpenTab={setTab}
migration={migration}
canMigrate={canMigrate}
onOpenMigration={() => setShowMigration(true)}
/>
)}
{tab === "sessions" && <SessionsTab project={project} actions={actions} />}
@@ -213,6 +239,16 @@ export default function ProjectHome({ projectId, active }: Props) {
)}
</div>
{showMigration && (
<MigrateContainerModal
projectName={project.name}
staleness={migration.staleness}
migration={migration}
// Closing is not cancelling — the run keeps going and the Overview
// banner keeps reporting it.
onClose={() => setShowMigration(false)}
/>
)}
{confirmReset && (
<ConfirmResetModal
projectName={project.name}
@@ -0,0 +1,80 @@
/**
* Shared wording for container base-image migration.
*
* The banner, the pre-flight modal and the report all have to make the same
* promise about what survives, or the feature reads as another Reset. It is
* written once here so the three surfaces cannot drift apart.
*/
import type { PackageFailure } from "../../lib/types";
/**
* What re-attaches untouched. These are not copied, rebuilt or re-authenticated
* — they live on the two Docker volumes, which the new container mounts as-is.
*/
export const KEPT_AUTOMATICALLY = [
"Your claude login and ~/.claude.json — no signing in again",
"Skills, agents, commands, hooks, plugins and MCP config",
"Every saved session transcript, so past sessions still resume",
"Scheduler tasks and their logs",
"SSH keys, git config and shell history",
"Claude Code itself, plus Rust/cargo, uv and ruff in your home directory",
];
export const KEPT_WHY =
"/home/claude and ~/.claude are Docker volumes. They detach from the old container and re-attach to the new one unchanged.";
export const LOST_WITHOUT_REPLAY =
"What a new base does not carry over is the root-level system packages you installed with apt. Those are the only thing this update has to put back.";
/**
* Said plainly everywhere rollback is offered. Rollback is not a time machine:
* it swaps the system layer back and leaves both volumes exactly where the
* migrated session left them.
*/
export const ROLLBACK_SCOPE =
"Rollback restores the system layer only. Your volumes are never touched, so anything Claude wrote to your home directory or a mounted workspace during the migrated session stays as it is.";
export const ROLLBACK_DISK_COST =
"A rollback image is close to a full second copy of the container — snapshots here run 3.812.3 GB and share almost nothing with the new base, so it costs nearly its full size on disk. It is deleted the moment you press Keep.";
/** Shown mid-run, where rollback is not a button but is still the safety net. */
export const MID_RUN_SAFETY =
"If this fails, the container is put back on its previous system layer automatically. Your volumes are not touched at any point.";
export const REPLAY_COST =
"Needs network access and usually takes 12 minutes.";
/** `1 Mar` — short enough to sit inline in the banner sentence. */
export function formatSnapshotDate(iso: string | null): string | null {
if (!iso) return null;
const ms = Date.parse(iso);
if (Number.isNaN(ms)) return null;
return new Date(ms).toLocaleDateString(undefined, {
day: "numeric",
month: "short",
});
}
/** Join a list into prose: "a, b and c". Used for the missing-features line. */
export function joinFeatures(features: string[]): string {
if (features.length === 0) return "";
if (features.length === 1) return features[0];
return `${features.slice(0, -1).join(", ")} and ${features[features.length - 1]}`;
}
/** The exact line to paste into a shell to finish a partial migration by hand. */
export function aptRetryCommand(failures: PackageFailure[]): string {
return `sudo apt-get install -y ${failures.map((f) => f.name).join(" ")}`;
}
/** Plain-text form of a partial report, for the copy button. */
export function failureReportText(failures: PackageFailure[]): string {
const lines = failures.map((f) => `${f.name}: ${f.reason}`);
return [
"Packages that could not be reinstalled:",
...lines,
"",
aptRetryCommand(failures),
].join("\n");
}
@@ -0,0 +1,262 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useContainerMigration } from "./useContainerMigration";
import type {
ContainerStaleness,
MigrationReport,
MigrationState,
Project,
} from "../lib/types";
const getContainerStaleness = vi.fn();
const getMigrationState = vi.fn();
const migrateProjectToBase = vi.fn();
const confirmMigration = vi.fn();
const rollbackMigration = vi.fn();
const pushToast = vi.fn();
let progress: string | undefined;
vi.mock("../lib/tauri-commands", () => ({
getContainerStaleness: (...a: unknown[]) => getContainerStaleness(...a),
getMigrationState: (...a: unknown[]) => getMigrationState(...a),
migrateProjectToBase: (...a: unknown[]) => migrateProjectToBase(...a),
confirmMigration: (...a: unknown[]) => confirmMigration(...a),
rollbackMigration: (...a: unknown[]) => rollbackMigration(...a),
}));
vi.mock("../store/appState", () => ({
useAppState: Object.assign(
(selector: (s: unknown) => unknown) =>
selector({ pushToast, containerProgress: { p1: progress } }),
{
getState: () => ({ setContainerProgress: () => {} }),
},
),
}));
const STALE: ContainerStaleness = {
stale: true,
known: true,
base_image_id: "sha256:aaa",
current_base_image_id: "sha256:bbb",
snapshot_created_at: "2026-03-01T09:00:00Z",
missing_paths: ["/usr/bin/socat"],
missing_features: ["Auth bridge tunnel (socat)"],
apt_delta: ["socat"],
npm_global_delta: [],
verbatim_paths: [],
outdated_package_count: 61,
probe_error: null,
};
const FRESH: ContainerStaleness = {
...STALE,
stale: false,
base_image_id: "sha256:bbb",
missing_paths: [],
missing_features: [],
apt_delta: [],
outdated_package_count: 0,
};
const CLEAN: MigrationReport = {
phase: "succeeded",
packages_requested: ["socat"],
packages_installed: ["socat"],
packages_failed: [],
paths_copied: [],
features_restored: ["Auth bridge tunnel (socat)"],
rollback_available: true,
message: "",
};
const OPTIONS = {
replay_packages: true,
copy_paths: false,
keep_rollback: true,
};
function state(overrides: Partial<MigrationState> = {}): MigrationState {
return {
phase: "in-progress",
from_image_id: "sha256:aaa",
to_base_id: "sha256:bbb",
started_at: "2026-08-09T10:00:00Z",
report: null,
rollback_image: "triple-c-snapshot-p1:pre-migration-1754733600",
staging_path: null,
options: OPTIONS,
plan: null,
...overrides,
};
}
const project = { id: "p1", name: "api-server", container_id: "c1", status: "stopped" } as Project;
describe("useContainerMigration", () => {
beforeEach(() => {
vi.clearAllMocks();
progress = undefined;
getContainerStaleness.mockResolvedValue(STALE);
getMigrationState.mockResolvedValue(null);
});
afterEach(() => {
vi.useRealTimers();
});
it("probes staleness for a container that exists", async () => {
const { result } = renderHook(() => useContainerMigration(project));
await waitFor(() => expect(result.current.staleness).toEqual(STALE));
expect(getContainerStaleness).toHaveBeenCalledWith("p1");
});
it("does not probe a project whose container was never created", async () => {
renderHook(() =>
useContainerMigration({ ...project, container_id: null } as Project),
);
await waitFor(() => expect(getMigrationState).toHaveBeenCalled());
expect(getContainerStaleness).not.toHaveBeenCalled();
});
it("shows an absent banner rather than an error one when the probe fails", async () => {
getContainerStaleness.mockRejectedValue(new Error("no such container"));
const { result } = renderHook(() => useContainerMigration(project));
await waitFor(() => expect(result.current.probing).toBe(false));
expect(result.current.staleness).toBeNull();
});
it("passes the options through and keeps the report", async () => {
migrateProjectToBase.mockResolvedValue(CLEAN);
const { result } = renderHook(() => useContainerMigration(project));
await waitFor(() => expect(result.current.staleness).toEqual(STALE));
getContainerStaleness.mockResolvedValue(FRESH);
await act(async () => {
await result.current.start({
replay_packages: true,
copy_paths: false,
keep_rollback: true,
});
});
expect(migrateProjectToBase).toHaveBeenCalledWith("p1", {
replay_packages: true,
copy_paths: false,
keep_rollback: true,
});
expect(result.current.report).toEqual(CLEAN);
expect(result.current.running).toBe(false);
});
it("turns a rejected migrate call into a failed report, not a silent nothing", async () => {
migrateProjectToBase.mockRejectedValue(new Error("docker daemon went away"));
const { result } = renderHook(() => useContainerMigration(project));
await act(async () => {
await result.current.start({
replay_packages: true,
copy_paths: false,
keep_rollback: true,
});
});
expect(result.current.report?.phase).toBe("failed");
expect(result.current.report?.message).toMatch(/docker daemon went away/);
expect(result.current.report?.rollback_available).toBe(false);
});
it("clears the report and re-probes once the migration is kept", async () => {
migrateProjectToBase.mockResolvedValue(CLEAN);
confirmMigration.mockResolvedValue(undefined);
const { result } = renderHook(() => useContainerMigration(project));
await act(async () => {
await result.current.start({
replay_packages: true,
copy_paths: false,
keep_rollback: true,
});
});
getContainerStaleness.mockResolvedValue(FRESH);
await act(async () => {
await result.current.keep();
});
expect(confirmMigration).toHaveBeenCalledWith("p1");
expect(result.current.report).toBeNull();
await waitFor(() => expect(result.current.staleness).toEqual(FRESH));
});
it("says out loud that a rollback left the volumes alone", async () => {
rollbackMigration.mockResolvedValue(undefined);
const { result } = renderHook(() => useContainerMigration(project));
await act(async () => {
await result.current.rollback();
});
expect(rollbackMigration).toHaveBeenCalledWith("p1");
expect(pushToast).toHaveBeenCalledWith(
expect.objectContaining({
kind: "success",
detail: expect.stringMatching(/Volumes were not touched/i),
}),
);
});
describe("crash recovery", () => {
it("adopts a run that was still in progress, and polls it to a report", async () => {
getMigrationState.mockResolvedValue(state());
const { result } = renderHook(() => useContainerMigration(project));
await waitFor(() => expect(result.current.running).toBe(true));
expect(result.current.recovered).toBe(true);
getMigrationState.mockResolvedValue(
state({ phase: "awaiting-confirmation", report: CLEAN }),
);
await waitFor(() => expect(result.current.report).toEqual(CLEAN), {
timeout: 5000,
});
expect(result.current.running).toBe(false);
});
it("surfaces a finished migration that was never acknowledged", async () => {
getMigrationState.mockResolvedValue(
state({ phase: "awaiting-confirmation", report: CLEAN }),
);
const { result } = renderHook(() => useContainerMigration(project));
await waitFor(() => expect(result.current.report).toEqual(CLEAN));
expect(result.current.running).toBe(false);
});
it("surfaces an interrupted migration instead of leaving it invisible", async () => {
getMigrationState.mockResolvedValue(state({ phase: "interrupted" }));
const { result } = renderHook(() => useContainerMigration(project));
await waitFor(() => expect(result.current.interrupted).not.toBeNull());
// Nothing is driving it, so it is not "running" and has no report.
expect(result.current.running).toBe(false);
expect(result.current.report).toBeNull();
});
it("resumes an interrupted migration with the options it was given", async () => {
getMigrationState.mockResolvedValue(state({ phase: "interrupted" }));
migrateProjectToBase.mockResolvedValue(CLEAN);
const { result } = renderHook(() => useContainerMigration(project));
await waitFor(() => expect(result.current.interrupted).not.toBeNull());
await act(async () => {
await result.current.resume();
});
// The deltas cannot be recomputed after the swap, so the recorded plan's
// options are replayed verbatim rather than re-derived.
expect(migrateProjectToBase).toHaveBeenCalledWith("p1", OPTIONS);
expect(result.current.interrupted).toBeNull();
expect(result.current.report).toEqual(CLEAN);
});
it("ignores an unrecognised phase from a future build rather than crashing", async () => {
getMigrationState.mockResolvedValue(state({ phase: "quantum-tunnelling" }));
const { result } = renderHook(() => useContainerMigration(project));
await waitFor(() => expect(result.current.staleness).toEqual(STALE));
expect(result.current.running).toBe(false);
expect(result.current.interrupted).toBeNull();
expect(result.current.report).toBeNull();
});
});
});
+293
View File
@@ -0,0 +1,293 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type {
ContainerStaleness,
MigrationOptions,
MigrationReport,
MigrationState,
Project,
} from "../lib/types";
import {
MIGRATION_PHASE_AWAITING_CONFIRMATION,
MIGRATION_PHASE_IN_PROGRESS,
MIGRATION_PHASE_INTERRUPTED,
} from "../lib/types";
import * as commands from "../lib/tauri-commands";
import { useAppState } from "../store/appState";
/**
* Unsettled phases from `MigrationState.phase` (hyphenated, unlike the
* outcome phases on `MigrationReport`). Compared as strings on purpose: the
* backend types this loosely so an unrecognised value from a future build
* cannot crash the UI, and neither can it here — an unknown phase simply
* surfaces nothing rather than throwing.
*/
const IN_PROGRESS = MIGRATION_PHASE_IN_PROGRESS;
const INTERRUPTED = MIGRATION_PHASE_INTERRUPTED;
const AWAITING = MIGRATION_PHASE_AWAITING_CONFIRMATION;
export interface ContainerMigration {
/** Null until the first probe returns, or when the container has never been created. */
staleness: ContainerStaleness | null;
probing: boolean;
/** True while a migration is running — whether we started it or found it. */
running: boolean;
/** True when the run in progress was recovered from disk, not started here. */
recovered: boolean;
/**
* A migration the app died in the middle of. It is not running and it has no
* report: the container is mid-swap until someone resumes or rolls it back.
*/
interrupted: MigrationState | null;
/** Re-enter an interrupted migration. The backend continues the same run. */
resume: () => Promise<void>;
/** The settled report, kept until the user keeps, rolls back or dismisses it. */
report: MigrationReport | null;
/** Progress lines from `container-progress`, oldest first. */
log: string[];
/** The most recent progress line, or null before the first one arrives. */
phaseMessage: string | null;
/** True while confirm/rollback is in flight. */
busy: boolean;
start: (options: MigrationOptions) => Promise<void>;
keep: () => Promise<void>;
rollback: () => Promise<void>;
/** Clear a report we cannot act on (failed / rolled back). Local only. */
dismiss: () => void;
refresh: () => Promise<void>;
}
/**
* Container base-image migration for one project.
*
* Three things have to survive a closed modal: the run itself, the progress
* log, and the report. A migration takes minutes, so the modal is a *view* onto
* this hook rather than the thing that owns the work — closing it must not
* cancel anything. The hook lives in `ProjectHome`, above both the modal and
* the Overview banner, so either surface can be showing at any point.
*
* A migration the app died in the middle of is picked up from
* `getMigrationState` on mount — as `interrupted`, which is offered for resume,
* or as `awaiting-confirmation`, whose report is put back on screen. Without
* that, a half-migrated container would look identical to a healthy one, which
* is the exact failure mode this whole feature exists to fix.
*/
export function useContainerMigration(project: Project): ContainerMigration {
const projectId = project.id;
const [staleness, setStaleness] = useState<ContainerStaleness | null>(null);
const [probing, setProbing] = useState(false);
const [running, setRunning] = useState(false);
const [recovered, setRecovered] = useState(false);
const [interrupted, setInterrupted] = useState<MigrationState | null>(null);
const [report, setReport] = useState<MigrationReport | null>(null);
const [log, setLog] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const pushToast = useAppState((s) => s.pushToast);
const progress = useAppState((s) => s.containerProgress[projectId]);
// Guards a late response from an earlier project overwriting a newer one.
const generation = useRef(0);
const refresh = useCallback(async () => {
const gen = ++generation.current;
if (!project.container_id) {
setStaleness(null);
return;
}
setProbing(true);
try {
const next = await commands.getContainerStaleness(projectId);
if (gen === generation.current) setStaleness(next);
} catch {
// A probe that cannot reach the container is "we do not know", which is
// an absent banner rather than an error one — the same call is retried
// whenever the container's status changes.
if (gen === generation.current) setStaleness(null);
} finally {
if (gen === generation.current) setProbing(false);
}
}, [projectId, project.container_id]);
// Probe staleness when the container settles into a new state. The probe runs
// two filesystem walks and is explicitly not for polling, so it is skipped
// mid-transition and mid-run — a reading taken while the container is being
// swapped describes neither the old system layer nor the new one.
const settled = project.status !== "starting" && project.status !== "stopping";
useEffect(() => {
if (running || !settled) return;
void refresh();
}, [refresh, settled, running]);
// Crash recovery: adopt whatever the backend still has on record.
useEffect(() => {
let cancelled = false;
commands
.getMigrationState(projectId)
.then((state) => {
if (cancelled || !state) return;
if (state.phase === IN_PROGRESS) {
// Something is still driving it; watch rather than restart.
setRunning(true);
setRecovered(true);
} else if (state.phase === INTERRUPTED) {
// Nothing is driving it. The container is mid-swap and will stay that
// way until someone resumes — so this must be visible, not silent.
setInterrupted(state);
} else if (state.phase === AWAITING && state.report) {
setReport(state.report);
}
})
.catch(() => {
/* No recorded state is the normal case. */
});
return () => {
cancelled = true;
};
}, [projectId]);
// A recovered run has no promise to await, so poll it to completion.
useEffect(() => {
if (!running || !recovered) return;
let cancelled = false;
const timer = setInterval(() => {
commands
.getMigrationState(projectId)
.then((state: MigrationState | null) => {
if (cancelled || state?.phase === IN_PROGRESS) return;
setRunning(false);
setRecovered(false);
// A cleared record means it was confirmed or rolled back elsewhere.
if (!state) {
void refresh();
return;
}
if (state.phase === INTERRUPTED) {
setInterrupted(state);
return;
}
if (state.report) setReport(state.report);
void refresh();
})
.catch(() => {
/* Keep polling; a transient IPC failure is not an outcome. */
});
}, 2500);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [running, recovered, projectId, refresh]);
// Accumulate the shared progress line into a scrollback the modal can show.
// The store collapses repeats, so identical consecutive apt lines appear once.
useEffect(() => {
if (!running || !progress) return;
setLog((prev) =>
prev[prev.length - 1] === progress ? prev : [...prev, progress],
);
}, [progress, running]);
const start = useCallback(
async (options: MigrationOptions) => {
setLog([]);
setReport(null);
setRecovered(false);
setInterrupted(null);
setRunning(true);
try {
const result = await commands.migrateProjectToBase(projectId, options);
setReport(result);
} catch (e) {
// A rejected call means the backend never produced a report. Synthesise
// the failed shape so the report surface — not a toast that scrolls
// away — is still what tells the user.
setReport({
phase: "failed",
packages_requested: [],
packages_installed: [],
packages_failed: [],
paths_copied: [],
features_restored: [],
rollback_available: false,
message: String(e),
});
} finally {
setRunning(false);
useAppState.getState().setContainerProgress(projectId, null);
void refresh();
}
},
[projectId, refresh],
);
/**
* Re-enter an interrupted migration. The backend continues that run rather
* than starting a new one, and the recorded options are replayed as-is — the
* deltas cannot be recomputed once the container has already been swapped.
*/
const resume = useCallback(async () => {
const pending = interrupted;
if (!pending) return;
await start(pending.options);
}, [interrupted, start]);
const keep = useCallback(async () => {
setBusy(true);
try {
await commands.confirmMigration(projectId);
setReport(null);
await refresh();
} catch (e) {
pushToast({
kind: "error",
message: `Could not discard the rollback image for “${project.name}`,
detail: String(e),
});
} finally {
setBusy(false);
}
}, [projectId, project.name, refresh, pushToast]);
const rollback = useCallback(async () => {
setBusy(true);
try {
await commands.rollbackMigration(projectId);
setReport(null);
setInterrupted(null);
pushToast({
kind: "success",
message: `${project.name}” is back on its previous system layer.`,
detail:
"Volumes were not touched, so anything written to your home directory or workspace during the update is still there.",
});
await refresh();
} catch (e) {
pushToast({
kind: "error",
message: `Rollback failed for “${project.name}`,
detail: String(e),
});
} finally {
setBusy(false);
}
}, [projectId, project.name, refresh, pushToast]);
const dismiss = useCallback(() => setReport(null), []);
return {
staleness,
probing,
running,
recovered,
interrupted,
report,
log,
phaseMessage: log.length > 0 ? log[log.length - 1] : null,
busy,
start,
resume,
keep,
rollback,
dismiss,
refresh,
};
}
+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;
}