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>
191 lines
6.8 KiB
TypeScript
191 lines
6.8 KiB
TypeScript
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>
|
|
);
|
|
}
|