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:
@@ -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.8–12.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.8–12.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 1–2 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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.8–12.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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user