diff --git a/docs/upgrade-live-dispatch.md b/docs/upgrade-live-dispatch.md new file mode 100644 index 00000000..772cba8e --- /dev/null +++ b/docs/upgrade-live-dispatch.md @@ -0,0 +1,99 @@ +# Upgrade verbs — live dispatch proof (server-binary on the mini) + +The hermetic suite (`packages/amico-run/test/upgrade*.test.ts`) is the +CI-green-blocking evidence for #526. **This checklist is the review-blocking +live proof**: ONE real `amico upgrade server-binary` dispatch on the mini +(Aaron's launchd `co.harmoniqs.amicode-server`, port 4096), the appended JSONL +receipt cited in the PR body. Separability per the spec: the PR cannot merge +without the live receipt, but CI does not wait on it. + +**The implementer of #526 does not run this** — the parent (walk) dispatches it +after merge, on the machine that owns the surface. + +## Preconditions (verify each, one line of output) + +```bash +# 1. the machine is the server (this IS the mini) and the service is alive +launchctl print gui/$(id -u)/co.harmoniqs.amicode-server | grep -E 'state|pid' +curl -fsS 'http://127.0.0.1:4096/session?limit=1' >/dev/null && echo healthy + +# 2. the fork checkout is clean and on local/amicode (a dirty or diverged +# checkout aborts aborted-diverged — the human resolves it, never the verb) +git -C ~/armonia/repos/opencode status --porcelain # must print NOTHING +git -C ~/armonia/repos/opencode rev-parse --abbrev-ref HEAD # local/amicode + +# 3. bun is on PATH (the build step needs it) and the amico bundle is current +bun --version +cd ~/armonia/repos/amicode && git pull --ff-only && pnpm --filter @amicode/amico-run build +``` + +## The dispatch + +```bash +cd ~/armonia/repos/amicode +node packages/amico-run/dist/amico.js doctor # sanity: server-binary reads STALE (the reason to upgrade) +node packages/amico-run/dist/amico.js upgrade server-binary +``` + +Defaults, no stubs: `bun install` + `bun run build --single` in +`~/armonia/repos/opencode/packages/opencode`, artifact at +`packages/opencode/dist/opencode-/bin/opencode`, freeze into +`~/.amico/server/bin/opencode` (+ sidecar, `opencode.prev` preserved), kick via +`launchctl kickstart -k gui/$(id -u)/co.harmoniqs.amicode-server`, then poll +`GET /session?limit=1` **and** the running process's binary sha vs the sidecar +(120 s, one re-kick retry). Use `--ref ` only to pin a specific rev. + +The verb prints its step trace to stderr and the receipt (single-line JSON) to +stdout; expect several minutes for `bun install` + the single-binary compile. + +## The receipt the PR cites + +```bash +tail -1 ~/.amico/server/upgrade-receipts/upgrade-receipts.jsonl | python3 -m json.tool +``` + +The proof passes iff that receipt shows: + +- `"verb": "server-binary"`, `"outcome": "upgraded"`, `"verification": true` +- `pre[0].verdict == "stale"` and `post[0].verdict == "current"` +- `source_digests.artifact_sha256 == source_digests.frozen_sha256`, and + `fork_head_after == fork_head_at_ref` (the clean-but-behind ff, if any, ran) +- `detail` walks the chain: smoke → freeze (`preserved current binary as + opencode.prev`) → kick → verify (`verified: healthy + running sha == sidecar`) + → `deleted opencode.prev` + +Independent cross-checks (never trust the receipt's own flag): + +```bash +node packages/amico-run/dist/amico.js doctor # server-binary must now read current +shasum -a 256 ~/.amico/server/bin/opencode # == the sidecar AND receipt frozen_sha256 +ls ~/.amico/server/bin/ # opencode + opencode.sha256, NO opencode.prev +``` + +Idempotence spot-check (optional but cheap): re-run the verb — it must exit 0 +with a `no-op` receipt and touch nothing. + +## If it goes wrong + +The verb restores automatically: a `restored` receipt means the previous binary +is back, healthy, sidecar rewritten — surface honestly `stale` again. A +`restore-failed` receipt means the server is DOWN: `opencode.prev` is retained +(deliberately — the only good copy); escalate by hand +(`launchctl print gui/$(id -u)/co.harmoniqs.amicode-server`, the server log, +then the amico-server.sh header runbook). The receipt records why; the +watchdog/morning-brief is the failure-delivery path, never silence. + +## Known live caveats (stated, not blocking) + +- **staged-skills reads stale forever on the mini while internal-only skills + (fleet, develop, implement-issue, …) remain staged**: doctor #525's + extras-are-drift predicate vs `stage-internal-skills.sh`'s deliberate + no-delete staging disagree. The `skills` verb preserves internal skills + (per-skill exact re-stage of the VSIX set, set-level no-delete) and reports + the residual drift honestly in its post record — it never deletes fleet + skills. Resolving the predicate/design conflict is a doctor-slice decision, + not the verb's. +- Single-operator lock: every invocation path (SSH, panel) runs as the launchd + user; a second concurrent verb exits `aborted-locked`. +- Receipts land in `~/.amico/server/upgrade-receipts/upgrade-receipts.jsonl` + (append-only; `--root-receipts` redirects for fixtures). diff --git a/packages/amico-run/src/amico.ts b/packages/amico-run/src/amico.ts index 9b8e3fca..124b4d6b 100644 --- a/packages/amico-run/src/amico.ts +++ b/packages/amico-run/src/amico.ts @@ -24,6 +24,10 @@ function usage(): string { ["sandbox --packages A,B,…", "generate a per-problem Julia env (amico-run subcommand)"], ["estimate | --spec ", "v0 size estimate → JSON suggestion signal, never a route (Δ10 #34)"], ["doctor [--json] [--root-…]", "studio binding + fleet surface inventory — six records, verdicts (#402, #525)"], + [ + "upgrade [--root-…]", + "receipt-emitting idempotent upgrade runbooks — pre-flight gate, lock, JSONL receipts (#526)", + ], [ "pasqal devices | submit --device --artifact

[--confirm ]", "Pasqal device path — list/select + gated submit (#160)", @@ -89,6 +93,18 @@ export async function main(argv: string[]): Promise { return report.exit; } + // ── the upgrade verbs (#526, spec D2): the four upgrade chains as + // receipt-emitting runbooks. Pre-flight composes the SAME doctor v2 + // probes (current → no-op; unknown → abort; stale/integrity → proceed); + // single-operator lock; append-only JSONL receipts; the server-binary + // restore path. Same {json, code} shape as the spine verbs. ── + case "upgrade": { + const { upgradeVerb } = await import("./upgrade.js"); + const { json, code } = await upgradeVerb(rest); + console.log(JSON.stringify(json)); + return code; + } + // ── the Pasqal device path (#160): device selection + gated submission, // reading status ONLY from the connections cache and submitting through the // amico-pasqal launcher. Same {json, code} shape as the spine verbs. ── diff --git a/packages/amico-run/src/surfaces.ts b/packages/amico-run/src/surfaces.ts index de80683c..b2cac6de 100644 --- a/packages/amico-run/src/surfaces.ts +++ b/packages/amico-run/src/surfaces.ts @@ -130,7 +130,9 @@ async function readFileSafe(p: string): Promise { } } -async function fileSha(p: string): Promise { +/** sha256 (hex) of a file's bytes, or null when unreadable. Exported for the + * upgrade verbs (#526) — they digest the same evidence doctor reads. */ +export async function fileSha(p: string): Promise { try { return sha256hex(await readFile(p)); } catch { @@ -182,8 +184,9 @@ export function parseBuildDate(version: string): Date | null { } /** Deterministic content digest of a directory: sha256 over the sorted - * relative-path + file-bytes pairs. mtime-free by construction. */ -async function dirDigest(dir: string): Promise { + * relative-path + file-bytes pairs. mtime-free by construction. Exported for + * the upgrade verbs + their idempotence harness (#526). */ +export async function dirDigest(dir: string): Promise { const files: string[] = []; const walk = async (rel: string) => { let entries: import("node:fs").Dirent[]; diff --git a/packages/amico-run/src/upgrade.ts b/packages/amico-run/src/upgrade.ts new file mode 100644 index 00000000..579ec911 --- /dev/null +++ b/packages/amico-run/src/upgrade.ts @@ -0,0 +1,1042 @@ +// upgrade.ts — `amico upgrade ` (#526, spec-20260823-094507-fleet-dev-tools +// D2): the four upgrade chains as receipt-emitting, idempotent runbooks. +// +// amico upgrade server-binary [--skip-build ] [--ref ] [--kick-command ] +// [--health-command ] [--no-kick] [--root-server

] +// amico upgrade extension [--package-command ] [--install-command ] +// [--root-vscext ] [--root-repo-amicode ] +// amico upgrade agents [--root-config ] [--root-staging ] [--root-repo-amicode ] +// amico upgrade skills [--root-staging ] [--root-vscext ] +// +// SHARED CONTRACT (all verbs): +// - Pre-flight: the doctor v2 probe of the verb's surface(s), composed from +// the SAME SurfaceContext (surfaces.ts). `current` → exit-0 no-op receipt; +// `unknown` → aborted-unknown (never upgrade what you cannot judge); +// `stale` / `integrity-failure` → proceed (integrity-failure IS the fix case). +// - Lock: single-operator, flock semantics — O_EXCL lockfile carrying the +// holder PID; a dead holder's lock is stolen (crash-release: an abandoned +// lock dies with its process). A live holder → aborted-locked. The lock +// lives at /.lock. +// - Receipts: append-only JSONL at /upgrade-receipts.jsonl — +// verb, timestamp, pre/post surface records, source digests, verification, +// outcome ∈ upgraded | no-op | restored | restore-failed | aborted-. +// Default root-receipts derives from --root-server (live: +// ~/.amico/server/upgrade-receipts), so fixture roots are hermetic by +// construction; --root-receipts overrides it outright. +// - Verification: never a self-reported flag — the post record comes from a +// fresh doctor probe, and the fixture suite re-runs doctor independently +// and matches it field-for-field. +// +// STUB COMMAND CONTRACT (the hermetic seams; tokens + env, both available): +// {frozen} {running} {prev} {server} {version} {vsix} {repo} (path tokens) +// AMICO_UPGRADE_FROZEN_BIN / _RUNNING_BIN / _PREV_BIN / _ROOT_SERVER / +// AMICO_UPGRADE_ROOT_VSCEXT / _TARGET_VERSION / _REPO_AMICODE / _VSIX / +// AMICO_UPGRADE_PHASE ∈ kick | verify | verify-retry | restore-kick | restore +// +// The KICK STUB's contract (spec D2): make the health command succeed AND +// make the running-binary evidence match the frozen artifact — e.g. +// --kick-command 'cp "$AMICO_UPGRADE_FROZEN_BIN" "$AMICO_UPGRADE_RUNNING_BIN"' +// The HEALTH STUB shapes the verify phase: exit 0 = healthy; the phase env +// lets a stub fail the upgrade's verify while passing the restore's. +// +// LIVE DISPATCH PROOF — the exact commands for the real server-binary upgrade +// on the mini, and what the receipt must show, live in +// docs/upgrade-live-dispatch.md (this slice does NOT run the live upgrade). +import { execFile } from "node:child_process"; +import { mkdir, open, readFile, rm, copyFile, readdir, stat, writeFile, chmod } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + surfaceInventory, + newestExtensionDir, + dirDigest, + fileSha, + versionPrefix, + defaultSurfaceContext, + type SurfaceContext, + type SurfaceRecord, + type SurfaceName, +} from "./surfaces.js"; +import type { VerbResult } from "./verbs.js"; + +export type UpgradeSurface = "server-binary" | "extension" | "agents" | "skills"; + +export type UpgradeOutcome = + | "upgraded" + | "no-op" + | "restored" + | "restore-failed" + | `aborted-${string}`; + +export interface UpgradeReceipt { + receipt_version: 1; + verb: UpgradeSurface; + timestamp: string; + outcome: UpgradeOutcome; + /** pre-flight doctor record(s) for the verb's surface(s); null when the verb + * refused before probing (aborted-locked, usage) */ + pre: SurfaceRecord[] | null; + /** post-execution doctor record(s); null when nothing executed (aborts) */ + post: SurfaceRecord[] | null; + source_digests: Record; + /** true | false | "deferred" (server-binary --no-kick); null when aborted */ + verification: boolean | "deferred" | null; + /** step traces + failure reasons — the human story behind the outcome */ + detail: string[]; +} + +const USAGE = + "amico upgrade [--root-…] " + + "[--skip-build

] [--ref ] [--kick-command ] [--health-command ] [--no-kick] " + + "[--package-command ] [--install-command ] [--root-receipts

]"; + +const SURFACES: readonly UpgradeSurface[] = ["server-binary", "extension", "agents", "skills"]; + +const HEALTH_TIMEOUT_DEFAULT_MS = 120_000; + +// ── the single-operator lock (flock semantics via O_EXCL + PID liveness) ───── + +export interface UpgradeLock { + acquired: boolean; + reason?: string; + release: () => Promise; +} + +function pidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (e) { + // EPERM = exists but owned by another user (single-operator assumption: + // same launchd user — defensive) → treat as alive + return (e as NodeJS.ErrnoException).code === "EPERM"; + } +} + +/** Acquire the upgrade lock. macOS has no flock(1) — the same semantics come + * from an O_EXCL lockfile carrying the holder's PID: a live holder blocks + * (aborted-locked), a dead holder's file is stolen (crash-release is free). */ +export async function acquireUpgradeLock(receiptsDir: string): Promise { + await mkdir(receiptsDir, { recursive: true }); + const lockPath = join(receiptsDir, ".lock"); + for (let attempt = 0; attempt < 3; attempt++) { + try { + const fh = await open(lockPath, "wx"); + await fh.writeFile(`${process.pid}\n`, "utf8"); + await fh.close(); + return { acquired: true, release: () => rm(lockPath, { force: true }) }; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e; + const raw = await readFile(lockPath, "utf8").catch(() => null); + const pid = raw ? Number(raw.trim()) : NaN; + if (Number.isInteger(pid) && pid > 0 && pidAlive(pid)) { + return { + acquired: false, + reason: `another upgrade holds the lock (pid ${pid}): ${lockPath}`, + release: () => Promise.resolve(), + }; + } + // stale (crashed holder, or empty/unparseable) — steal and retry. + // Two racers stealing: both unlink (ENOENT tolerated), then only one + // wins the next O_EXCL create; the loser loops and re-checks. + await rm(lockPath, { force: true }); + } + } + return { + acquired: false, + reason: `could not acquire the upgrade lock after stealing a stale one: ${lockPath}`, + release: () => Promise.resolve(), + }; +} + +// ── receipts (append-only JSONL) ───────────────────────────────────────────── + +async function appendReceipt(receiptsDir: string, receipt: UpgradeReceipt): Promise { + await mkdir(receiptsDir, { recursive: true }); + const { appendFile } = await import("node:fs/promises"); + await appendFile(join(receiptsDir, "upgrade-receipts.jsonl"), `${JSON.stringify(receipt)}\n`, "utf8"); +} + +// ── shell runner for stub/live commands (tokens + env contract) ────────────── + +interface ShellOpts { + env?: Record; + cwd?: string; + timeoutMs?: number; +} + +/** token → env-var aliases for the {token} substitution in stub/live commands */ +const TOKEN_ENV: Record = { + frozen: "AMICO_UPGRADE_FROZEN_BIN", + running: "AMICO_UPGRADE_RUNNING_BIN", + prev: "AMICO_UPGRADE_PREV_BIN", + server: "AMICO_UPGRADE_ROOT_SERVER", + version: "AMICO_UPGRADE_TARGET_VERSION", + vscext: "AMICO_UPGRADE_ROOT_VSCEXT", + vsix: "AMICO_UPGRADE_VSIX", + repo: "AMICO_UPGRADE_REPO_AMICODE", +}; + +async function runShell(cmd: string, opts: ShellOpts = {}): Promise<{ code: number; stdout: string; stderr: string }> { + const env = opts.env ?? {}; + const script = cmd.replace(/\{(\w+)\}/g, (m, tok: string) => + Object.prototype.hasOwnProperty.call(env, TOKEN_ENV[tok] ?? `AMICO_UPGRADE_${tok.toUpperCase()}`) + ? (env[TOKEN_ENV[tok] ?? `AMICO_UPGRADE_${tok.toUpperCase()}`] as string) + : m, + ); + return new Promise((resolve) => { + execFile( + "sh", + ["-c", script], + { timeout: opts.timeoutMs ?? 300_000, cwd: opts.cwd, env: { ...process.env, ...env } }, + (err, stdout, stderr) => { + const code = err && typeof (err as { code?: number }).code === "number" ? (err as { code: number }).code : err ? 1 : 0; + resolve({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") }); + }, + ); + }); +} + +async function runGit(repo: string, args: string[]): Promise<{ code: number; stdout: string; stderr: string }> { + return new Promise((resolve) => { + execFile("git", ["-C", repo, ...args], { timeout: 60_000 }, (err, stdout, stderr) => { + const code = err && typeof (err as { code?: number }).code === "number" ? (err as { code: number }).code : err ? 1 : 0; + resolve({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") }); + }); + }); +} + +// ── argument parsing (mirrors doctor's root flags — keep the names in sync) ── + +const ROOT_FLAGS: Record = { + "--root-server": "rootServer", + "--root-vscext": "rootVscext", + "--root-config": "rootConfig", + "--root-repo-amicode": "rootRepoAmicode", + "--root-repo-fork": "rootRepoFork", + "--root-staging": "rootStaging", +}; + +interface ParsedVerbArgs { + surface: UpgradeSurface; + roots: Partial; + runningBinary: string | null; + rootReceipts: string | null; + flags: Record; + bools: Set; +} + +export function parseUpgradeArgs(argv: string[]): { ok: true; args: ParsedVerbArgs } | { ok: false; message: string } { + const surface = argv[0]; + if (!surface || surface.startsWith("--")) return { ok: false, message: `usage: ${USAGE}` }; + const canonical = RECORD_ALIASES[surface] ?? surface; + if (!SURFACES.includes(canonical as UpgradeSurface)) { + return { ok: false, message: `unknown surface "${surface}" — ${USAGE}` }; + } + // normalize argv[0] to the canonical verb so downstream sees one vocabulary + argv = [canonical, ...argv.slice(1)]; + const VALUE_FLAGS = new Set([ + "--skip-build", + "--ref", + "--kick-command", + "--health-command", + "--verify-timeout-ms", + "--package-command", + "--install-command", + "--root-receipts", + ...Object.keys(ROOT_FLAGS), + "--running-binary", + ]); + const BOOL_FLAGS = new Set(["--no-kick"]); + const roots: Partial = {}; + const flags: Record = {}; + const bools = new Set(); + let runningBinary: string | null = null; + let rootReceipts: string | null = null; + for (let i = 1; i < argv.length; i++) { + const a = argv[i]; + if (BOOL_FLAGS.has(a)) { + bools.add(a); + } else if (ROOT_FLAGS[a]) { + const v = argv[++i]; + if (!v) return { ok: false, message: `${a} requires a path` }; + (roots as Record)[ROOT_FLAGS[a]] = v; + } else if (a === "--running-binary") { + const v = argv[++i]; + if (!v) return { ok: false, message: "--running-binary requires a path" }; + runningBinary = v; + } else if (a === "--root-receipts") { + const v = argv[++i]; + if (!v) return { ok: false, message: "--root-receipts requires a path" }; + rootReceipts = v; + } else if (VALUE_FLAGS.has(a)) { + const v = argv[++i]; + if (!v) return { ok: false, message: `${a} requires a value` }; + flags[a] = v; + } else { + return { ok: false, message: `unknown upgrade flag: ${a} — ${USAGE}` }; + } + } + if (runningBinary !== null) roots.runningBinary = runningBinary; + return { ok: true, args: { surface: canonical as UpgradeSurface, roots, runningBinary, rootReceipts, flags, bools } }; +} + +// ── shared verb scaffolding ────────────────────────────────────────────────── + +interface VerbCtx { + args: ParsedVerbArgs; + roots: Partial; + receiptsDir: string; + detail: string[]; + log: (line: string) => void; +} + +function defaultRootServer(): string { + return process.env.AMICO_SERVER_DIR ?? join(homedir(), ".amico", "server"); +} + +function exitCodeFor(receipt: UpgradeReceipt): number { + if (receipt.outcome === "upgraded" || receipt.outcome === "no-op") { + return receipt.verification === false ? 1 : 0; + } + return 1; // restored, restore-failed, aborted-* +} + +function baseReceipt(verb: UpgradeSurface, detail: string[]): UpgradeReceipt { + return { + receipt_version: 1, + verb, + timestamp: new Date().toISOString(), + outcome: "aborted-error", + pre: null, + post: null, + source_digests: {}, + verification: null, + detail, + }; +} + +/** The doctor probe, composed over the same injected context (the verbs' + * pre-flight and verification both run through surfaceInventory). */ +function probe(ctx: VerbCtx): Promise<{ surfaces: SurfaceRecord[] }> { + return surfaceInventory(ctx.roots); +} + +/** Pre-flight gate shared by every verb: current → no-op; any unknown → abort; + * stale/integrity-failure → proceed (integrity-failure IS the fix case). */ +function gatePreflight(records: SurfaceRecord[]): { decision: "proceed" } | { decision: "no-op" } | { decision: "abort"; reason: string } { + if (records.some((r) => r.verdict === "unknown")) { + return { decision: "abort", reason: "aborted-unknown" }; + } + if (records.every((r) => r.verdict === "current")) return { decision: "no-op" }; + return { decision: "proceed" }; +} + +async function finish(ctx: VerbCtx, receipt: UpgradeReceipt): Promise { + await appendReceipt(ctx.receiptsDir, receipt).catch((e) => { + ctx.log(`receipt append failed (${e instanceof Error ? e.message : String(e)}) — the JSON below is the record`); + }); + return { json: receipt, code: exitCodeFor(receipt) }; +} + +// ── the verbs ──────────────────────────────────────────────────────────────── + +interface VerbBodyResult { + outcome: UpgradeOutcome; + verification: boolean | "deferred" | null; + post: SurfaceRecord[] | null; + sourceDigests: Record; +} + +type VerbBody = (ctx: VerbCtx) => Promise; + +async function runVerb( + surface: UpgradeSurface, + argv: string[], + ownedSurfaces: SurfaceName[], + body: VerbBody, +): Promise { + const parsed = parseUpgradeArgs(argv); + if (!parsed.ok) return { json: { verb: surface, ok: false, errors: [parsed.message] }, code: 64 }; + const detail: string[] = []; + const log = (line: string) => { + detail.push(line); + console.error(`amico upgrade ${surface}: ${line}`); // progress → stderr; stdout is the receipt + }; + // live defaults fill every root the caller didn't inject (fixtures inject + // all of them; the live CLI passes none — the missing-merge was the live- + // dispatch bug: ctx.roots.rootRepoFork! crashed on undefined) + const defaults = defaultSurfaceContext(); + const roots = { ...defaults, ...parsed.args.roots } as SurfaceContext; + const rootServer = roots.rootServer; + const receiptsDir = parsed.args.rootReceipts ?? join(rootServer, "upgrade-receipts"); + const ctx: VerbCtx = { args: parsed.args, roots, receiptsDir, detail, log }; + + const receipt = baseReceipt(surface, detail); + + // 1. the single-operator lock — before anything else touches the machine + const lock = await acquireUpgradeLock(receiptsDir); + if (!lock.acquired) { + receipt.outcome = "aborted-locked"; + receipt.detail.push(lock.reason ?? "lock refused"); + return finish(ctx, receipt); + } + try { + // 2. pre-flight: the doctor probe of THIS verb's surface(s) + log(`pre-flight: probing ${ownedSurfaces.join(", ")}`); + const pre = await probe(ctx); + const preRecords = ownedSurfaces.map((s) => pre.surfaces.find((r) => r.surface === s)!); + receipt.pre = preRecords; + const gate = gatePreflight(preRecords); + if (gate.decision === "abort") { + receipt.outcome = gate.reason as UpgradeOutcome; + log(`pre-flight ${gate.reason}: never upgrade what you cannot judge`); + return finish(ctx, receipt); + } + if (gate.decision === "no-op") { + receipt.outcome = "no-op"; + receipt.verification = true; + receipt.post = preRecords; // nothing mutated — the pre state IS the post state + log("pre-flight current — nothing to do"); + return finish(ctx, receipt); + } + + // 3. execute + verify + const result = await body(ctx); + receipt.outcome = result.outcome; + receipt.verification = result.verification; + receipt.post = result.post; + receipt.source_digests = result.sourceDigests; + return finish(ctx, receipt); + } catch (e) { + receipt.outcome = "aborted-error"; + receipt.detail.push(`unexpected error: ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`); + return finish(ctx, receipt); + } finally { + await lock.release(); + } +} + +// ── skills: the staging re-stage (the server script's re-stage step, as a verb) + +const skillsVerb = (argv: string[]): Promise => + runVerb("skills", argv, ["staged-skills"], async (ctx): Promise => { + const { rootVscext, rootStaging } = { + rootVscext: ctx.roots.rootVscext!, + rootStaging: ctx.roots.rootStaging!, + }; + const newest = await newestExtensionDir(rootVscext); + if (newest === null) { + throw new Error(`no VSIX skills source (no harmoniqs.amicode-* dir under ${rootVscext})`); + } + const sourceSkillsDir = join(newest.dir, "skills"); + const stagedDir = join(rootStaging, "skills"); + const sourceSkills = (await readdir(sourceSkillsDir, { withFileTypes: true })) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .sort(); + + // digest the shareable set before + the source set (the receipt's evidence) + const digestSet = async (dir: string): Promise> => { + const m = new Map(); + for (const s of sourceSkills) { + const d = await dirDigest(join(dir, s)); + if (d !== null) m.set(s, d); + } + return m; + }; + const before = await digestSet(stagedDir); + const sources = await digestSet(sourceSkillsDir); + const setDigestOf = (m: Map): string | null => + m.size === 0 ? null : `sha256:${[...m.entries()].sort(([a], [b]) => (a < b ? -1 : 1)).map(([n, d]) => `${n}:${d}`).join("|")}`; + + // the re-stage: per-skill exact replacement (rm + copy), SET-LEVEL NO + // DELETE — internal-only staged skills the VSIX never ships are the + // server's deliberate design (stage-internal-skills.sh) and are preserved. + // Per-skill replacement (not overlay-rsync) is required for "verify + // per-skill digests match the VSIX set" to be reachable at all. + ctx.log(`re-staging ${sourceSkills.length} skills from VSIX ${newest.suffix} → ${stagedDir}`); + const changed: string[] = []; + for (const skill of sourceSkills) { + const src = join(sourceSkillsDir, skill); + const dst = join(stagedDir, skill); + const srcDigest = sources.get(skill)!; + const dstDigest = before.get(skill); + if (dstDigest === srcDigest) continue; // already converged — untouched + changed.push(skill); + await rm(dst, { recursive: true, force: true }); + await mkdir(dst, { recursive: true }); + await copyTree(src, dst); + } + + // verify: every VSIX-set skill's staged digest matches the source + const after = await digestSet(stagedDir); + const mismatches = sourceSkills.filter((s) => after.get(s) !== sources.get(s)); + const verification = mismatches.length === 0; + if (!verification) ctx.log(`verification FAILED: skills not converged: ${mismatches.join(", ")}`); + else ctx.log(`verified: all ${sourceSkills.length} VSIX-set skills byte-match the staged set`); + + // the post record from a FRESH doctor probe (never self-reported) + const post = await probe(ctx); + const outcome: UpgradeOutcome = changed.length > 0 ? "upgraded" : "no-op"; + return { + outcome, + verification, + post: [post.surfaces.find((r) => r.surface === "staged-skills")!], + sourceDigests: { + vsix_set: setDigestOf(sources), + staged_before: setDigestOf(before), + staged_after: setDigestOf(after), + }, + }; + }); + +async function copyTree(src: string, dst: string): Promise { + const entries = await readdir(src, { withFileTypes: true }); + for (const e of entries) { + const s = join(src, e.name); + const d = join(dst, e.name); + if (e.isDirectory()) { + await mkdir(d, { recursive: true }); + await copyTree(s, d); + } else if (e.isFile()) { + await copyFile(s, d); + } + } +} + +// ── agents: wraps deploy-agents.mjs — ONE invocation, BOTH receipt stores ──── + +const agentsVerb = (argv: string[]): Promise => + runVerb("agents", argv, ["agent-cards-global", "agent-cards-staging"], async (ctx): Promise => { + const rootRepoAmicode = ctx.roots.rootRepoAmicode!; + const rootConfig = ctx.roots.rootConfig!; + const rootStaging = ctx.roots.rootStaging!; + + // the script ships IN the amicode checkout (that is the live contract); + // a checkout without it is an environment problem, never a fallback to + // some sibling copy — the script's SOURCE_DIR must resolve inside the + // checkout the doctor probes read. + const script = join(rootRepoAmicode, "scripts", "deploy-agents.mjs"); + try { + await stat(script); + } catch { + ctx.log(`deploy-agents.mjs not found in the amicode checkout: ${script}`); + return { outcome: "aborted-environment", verification: null, post: null, sourceDigests: {} }; + } + + const globalDir = join(rootConfig, "agents"); + const stagingDir = join(rootStaging, ".opencode", "agents"); + ctx.log(`deploying agent cards: ${script} --global ${globalDir} --staging ${stagingDir}`); + const deployed = await new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => { + execFile( + process.execPath, + [script, "--global", globalDir, "--staging", stagingDir], + { timeout: 120_000, cwd: rootRepoAmicode }, + (err, stdout, stderr) => { + const code = err && typeof (err as { code?: number }).code === "number" ? (err as { code: number }).code : err ? 1 : 0; + resolve({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") }); + }, + ); + }); + if (deployed.code !== 0) { + ctx.log(`deploy-agents.mjs exited ${deployed.code}: ${firstLine(deployed.stderr || deployed.stdout)}`); + return { + outcome: "aborted-deploy", + verification: false, + post: await postRecords(ctx, ["agent-cards-global", "agent-cards-staging"]), + sourceDigests: {}, + }; + } + ctx.log(firstLine(deployed.stdout) || "deploy-agents.mjs completed"); + + // BOTH receipt stores are now written (the script wrote the contract-path + // .deploy-receipt.json; runVerb appends the JSONL). Verify through a + // FRESH doctor probe: both records current — the digest diff AND the + // contract receipt's freshness, judged by the same engine doctor uses. + const post = await postRecords(ctx, ["agent-cards-global", "agent-cards-staging"]); + const verification = post.every((r) => r.verdict === "current"); + if (!verification) { + ctx.log(`verification FAILED: ${post.map((r) => `${r.surface}=${r.verdict}`).join(", ")}`); + } else { + ctx.log("verified: both agent-card deployments byte-match sources with a fresh receipt"); + } + const contractReceipt = join(rootRepoAmicode, "packages", "extension", "agents", ".deploy-receipt.json"); + return { + outcome: "upgraded", + verification, + post, + sourceDigests: { deploy_receipt: await fileSha(contractReceipt) }, + }; + }); + +async function postRecords(ctx: VerbCtx, names: SurfaceName[]): Promise { + const post = await probe(ctx); + return names.map((n) => post.surfaces.find((r) => r.surface === n)!); +} + +function firstLine(s: string): string { + return s.split("\n").map((l) => l.trim()).filter(Boolean).join(" ").slice(0, 300); +} + +// ── extension: git discipline → package → install → version-sorted cleanup ── + +const extensionVerb = (argv: string[]): Promise => + runVerb("extension", argv, ["extension"], async (ctx): Promise => { + const rootRepoAmicode = ctx.roots.rootRepoAmicode!; + const rootVscext = ctx.roots.rootVscext!; + const packageCommand = ctx.args.flags["--package-command"] ?? "pnpm run package"; + const installCommand = + ctx.args.flags["--install-command"] ?? "code --install-extension {vsix}"; + const usingPackageStub = ctx.args.flags["--package-command"] !== undefined; + const usingInstallStub = ctx.args.flags["--install-command"] !== undefined; + + // (1) git discipline: fetch, clean tree, HEAD ancestor of origin/main. + // Divergence or dirt → aborted-diverged (never reset, never merge — the + // human resolves). Clean-but-behind → ff-only pull FIRST (a fast-forward + // of a clean tree is not a merge); ff failure → aborted-diverged. + const fetch = await runGit(rootRepoAmicode, ["fetch", "origin"]); + if (fetch.code !== 0) { + ctx.log(`amicode fetch failed: ${firstLine(fetch.stderr)}`); + return { outcome: "aborted-unknown", verification: null, post: null, sourceDigests: {} }; + } + const headBefore = (await runGit(rootRepoAmicode, ["rev-parse", "HEAD"])).stdout.trim(); + const status = await runGit(rootRepoAmicode, ["status", "--porcelain"]); + if (status.code !== 0 || status.stdout.trim().length > 0) { + ctx.log( + status.code !== 0 + ? `git status failed: ${firstLine(status.stderr)}` + : `working tree dirty (never reset, never merge — the human resolves):\n${status.stdout.trim()}`, + ); + return { outcome: "aborted-diverged", verification: null, post: null, sourceDigests: {} }; + } + const originMain = (await runGit(rootRepoAmicode, ["rev-parse", "--verify", "origin/main"])).stdout.trim(); + if (!originMain) { + ctx.log("origin/main not found after fetch"); + return { outcome: "aborted-unknown", verification: null, post: null, sourceDigests: {} }; + } + const ancestor = await runGit(rootRepoAmicode, ["merge-base", "--is-ancestor", "HEAD", "origin/main"]); + if (ancestor.code !== 0) { + ctx.log(`HEAD ${headBefore.slice(0, 12)} is NOT an ancestor of origin/main ${originMain.slice(0, 12)} — diverged; the human resolves`); + return { outcome: "aborted-diverged", verification: null, post: null, sourceDigests: {} }; + } + const headAt = (await runGit(rootRepoAmicode, ["rev-parse", "HEAD"])).stdout.trim(); + if (headAt !== originMain) { + ctx.log(`clean-but-behind: fast-forwarding ${headAt.slice(0, 12)} → ${originMain.slice(0, 12)} (ff-only)`); + const ff = await runGit(rootRepoAmicode, ["merge", "--ff-only", "origin/main"]); + if (ff.code !== 0) { + ctx.log(`ff-only fast-forward failed: ${firstLine(ff.stderr)}`); + return { outcome: "aborted-diverged", verification: null, post: null, sourceDigests: {} }; + } + } + + // the FETCHED version — the verification target (never the built one: a + // drifted checkout must not self-verify) + const pkgRaw = await runGit(rootRepoAmicode, ["show", "origin/main:packages/extension/package.json"]); + let fetchedVersion: string | null = null; + if (pkgRaw.code === 0) { + try { + fetchedVersion = (JSON.parse(pkgRaw.stdout) as { version?: string }).version ?? null; + } catch { + fetchedVersion = null; + } + } + if (fetchedVersion === null) { + ctx.log("origin/main packages/extension/package.json unreadable or has no version"); + return { outcome: "aborted-unknown", verification: null, post: null, sourceDigests: {} }; + } + ctx.log(`target: extension ${fetchedVersion} (origin/main ${originMain.slice(0, 12)})`); + + // (2) environment: the live toolchain only matters on the live path — + // stub commands are the hermetic seam + if (!usingPackageStub) { + const pnpm = await runShell("pnpm --version"); + if (pnpm.code !== 0) { + ctx.log("pnpm not available on PATH (the live package step needs it)"); + return { outcome: "aborted-environment", verification: null, post: null, sourceDigests: {} }; + } + } + if (!usingInstallStub) { + const code = await runShell("code --version"); + if (code.code !== 0) { + ctx.log("VS Code CLI (`code`) not available on PATH (the live install step needs it)"); + return { outcome: "aborted-environment", verification: null, post: null, sourceDigests: {} }; + } + } + + const extDir = join(rootRepoAmicode, "packages", "extension"); + const vsix = join(extDir, "amicode.vsix"); + + // (3) package + ctx.log(`packaging: ${packageCommand} (cwd ${extDir})`); + const pkg = await runShell(packageCommand, { + cwd: extDir, + env: stubEnv({ repo: rootRepoAmicode, version: fetchedVersion }), + }); + if (pkg.code !== 0) { + ctx.log(`package step failed (exit ${pkg.code}): ${firstLine(pkg.stderr || pkg.stdout)}`); + return { outcome: "aborted-package", verification: null, post: null, sourceDigests: { fetched_version: fetchedVersion } }; + } + if (!usingInstallStub) { + try { + await stat(vsix); + } catch { + ctx.log(`package step produced no vsix at ${vsix}`); + return { outcome: "aborted-package", verification: null, post: null, sourceDigests: { fetched_version: fetchedVersion } }; + } + } + + // (4) install (the VS Code CLI live path / --install-command stub) + ctx.log(`installing: ${installCommand}`); + const inst = await runShell(installCommand, { + cwd: rootRepoAmicode, + env: stubEnv({ + repo: rootRepoAmicode, + version: fetchedVersion, + vscext: rootVscext, + vsix, + }), + }); + if (inst.code !== 0) { + ctx.log(`install step failed (exit ${inst.code}): ${firstLine(inst.stderr || inst.stdout)}`); + return { outcome: "aborted-install", verification: null, post: null, sourceDigests: { fetched_version: fetchedVersion } }; + } + + // (5) stale-dir removal — VERSION-sorted, never mtime: keep exactly the + // fetched version's dir(s); every other harmoniqs.amicode-* dir is stale + // (older leftovers AND any ahead-of-source dir — the source of truth is + // origin/main, and the verb converges the installed set to it) + const entries = await readdir(rootVscext, { withFileTypes: true }); + const extDirs = entries.filter((e) => e.isDirectory() && /^harmoniqs\.amicode-/.test(e.name)).map((e) => e.name); + const keep = extDirs.filter((d) => versionPrefix(d.replace(/^harmoniqs\.amicode-/, "")) === fetchedVersion); + const stale = extDirs.filter((d) => !keep.includes(d)); + for (const d of stale) { + ctx.log(`removing stale extension dir (version-sorted): ${d}`); + await rm(join(rootVscext, d), { recursive: true, force: true }); + } + + // (6) verify: the installed newest version equals the FETCHED version — + // judged through a fresh doctor probe + const post = await postRecords(ctx, ["extension"]); + const record = post[0]; + const installedVersion = record.version ? versionPrefix(record.version) : null; + const verification = installedVersion === fetchedVersion; + ctx.log( + verification + ? `verified: installed ${installedVersion} = fetched origin/main ${fetchedVersion}` + : `verification FAILED: installed ${record.version} ≠ fetched ${fetchedVersion}`, + ); + return { + outcome: "upgraded", + verification, + post, + sourceDigests: { + amicode_head_before: headBefore, + amicode_head_after: originMain, + fetched_version: fetchedVersion, + installed_dir: record.version, + }, + }; + }); + +/** The stub/live command env contract (see the module header). */ +function stubEnv(vars: { repo?: string; version?: string; vscext?: string; vsix?: string; frozen?: string; running?: string; prev?: string; server?: string; phase?: string }): Record { + const env: Record = {}; + if (vars.repo !== undefined) env.AMICO_UPGRADE_REPO_AMICODE = vars.repo; + if (vars.version !== undefined) env.AMICO_UPGRADE_TARGET_VERSION = vars.version; + if (vars.vscext !== undefined) env.AMICO_UPGRADE_ROOT_VSCEXT = vars.vscext; + if (vars.vsix !== undefined) env.AMICO_UPGRADE_VSIX = vars.vsix; + if (vars.frozen !== undefined) env.AMICO_UPGRADE_FROZEN_BIN = vars.frozen; + if (vars.running !== undefined) env.AMICO_UPGRADE_RUNNING_BIN = vars.running; + if (vars.prev !== undefined) env.AMICO_UPGRADE_PREV_BIN = vars.prev; + if (vars.server !== undefined) env.AMICO_UPGRADE_ROOT_SERVER = vars.server; + if (vars.phase !== undefined) env.AMICO_UPGRADE_PHASE = vars.phase; + return env; +} + +// ── server-binary: the 9-step chain (build → freeze → sidecar → kick) ─────── + +const serverBinaryVerb = (argv: string[]): Promise => + runVerb("server-binary", argv, ["server-binary"], async (ctx): Promise => { + const rootServer = ctx.roots.rootServer!; + const rootRepoFork = ctx.roots.rootRepoFork!; + const ref = ctx.args.flags["--ref"] ?? "origin/local/amicode"; + const skipBuild = ctx.args.flags["--skip-build"]; + const noKick = ctx.args.bools.has("--no-kick"); + const kickCommand = + ctx.args.flags["--kick-command"] ?? "launchctl kickstart -k gui/$(id -u)/co.harmoniqs.amicode-server"; + const healthCommand = + ctx.args.flags["--health-command"] ?? 'curl -fsS "http://127.0.0.1:4096/session?limit=1"'; + const timeoutMs = Number(ctx.args.flags["--verify-timeout-ms"] ?? HEALTH_TIMEOUT_DEFAULT_MS); + const runningPath = ctx.args.runningBinary; + + const binDir = join(rootServer, "bin"); + const frozen = join(binDir, "opencode"); + const sidecar = `${frozen}.sha256`; + const prevBin = `${frozen}.prev`; + const digests: Record = {}; + + // (1) environment preflight — bun + node present, fork checkout exists. + // The bun check is the BUILD path's requirement; --skip-build freezes an + // existing artifact and never needs it. + try { + await stat(rootRepoFork); + } catch { + ctx.log(`fork checkout not found: ${rootRepoFork}`); + return { outcome: "aborted-environment", verification: null, post: null, sourceDigests: digests }; + } + ctx.log(`environment: node ${process.versions.node}, fork ${rootRepoFork}`); + if (!skipBuild) { + const bun = await runShell("bun --version"); + if (bun.code !== 0) { + ctx.log("bun not available on PATH (the build step needs it — or pass --skip-build )"); + return { outcome: "aborted-environment", verification: null, post: null, sourceDigests: digests }; + } + ctx.log(`environment: bun ${bun.stdout.trim()}`); + } + + // (2) fork state: fetch, clean tree, HEAD ancestor of the ref. Dirt or + // divergence → aborted-diverged (never reset, never merge — the human + // resolves). Clean-but-behind → ff-only to the ref (a fast-forward of a + // clean tree is not a merge). + const fetch = await runGit(rootRepoFork, ["fetch", "origin"]); + if (fetch.code !== 0) { + ctx.log(`fork fetch failed: ${firstLine(fetch.stderr)}`); + return { outcome: "aborted-unknown", verification: null, post: null, sourceDigests: digests }; + } + const headBefore = (await runGit(rootRepoFork, ["rev-parse", "HEAD"])).stdout.trim(); + digests.fork_head_before = headBefore; + const status = await runGit(rootRepoFork, ["status", "--porcelain"]); + if (status.code !== 0 || status.stdout.trim().length > 0) { + ctx.log( + status.code !== 0 + ? `git status failed: ${firstLine(status.stderr)}` + : `fork tree dirty (never reset, never merge — the human resolves):\n${status.stdout.trim()}`, + ); + return { outcome: "aborted-diverged", verification: null, post: null, sourceDigests: digests }; + } + const refSha = (await runGit(rootRepoFork, ["rev-parse", "--verify", ref])).stdout.trim(); + if (!refSha) { + ctx.log(`ref ${ref} not found in the fork after fetch`); + return { outcome: "aborted-unknown", verification: null, post: null, sourceDigests: digests }; + } + digests.fork_head_at_ref = refSha; + const ancestor = await runGit(rootRepoFork, ["merge-base", "--is-ancestor", "HEAD", ref]); + if (ancestor.code !== 0) { + ctx.log(`fork HEAD ${headBefore.slice(0, 12)} is NOT an ancestor of ${ref} ${refSha.slice(0, 12)} — diverged; the human resolves`); + return { outcome: "aborted-diverged", verification: null, post: null, sourceDigests: digests }; + } + if (headBefore !== refSha) { + ctx.log(`fork clean-but-behind: fast-forwarding ${headBefore.slice(0, 12)} → ${refSha.slice(0, 12)} (ff-only)`); + const ff = await runGit(rootRepoFork, ["merge", "--ff-only", ref]); + if (ff.code !== 0) { + ctx.log(`ff-only fast-forward failed: ${firstLine(ff.stderr)}`); + return { outcome: "aborted-diverged", verification: null, post: null, sourceDigests: digests }; + } + } + const headAfter = (await runGit(rootRepoFork, ["rev-parse", "HEAD"])).stdout.trim(); + digests.fork_head_after = headAfter; + digests.ref = ref; + + // (3+4) bun install + build --single (the LIVE path; --skip-build freezes + // an existing artifact — the fixture path) + let artifact: string; + if (skipBuild !== undefined) { + artifact = skipBuild; + try { + await stat(artifact); + } catch { + ctx.log(`--skip-build artifact not found: ${artifact}`); + return { outcome: "aborted-environment", verification: null, post: null, sourceDigests: digests }; + } + } else { + const inst = await runShell("bun install", { cwd: rootRepoFork, timeoutMs: 600_000 }); + if (inst.code !== 0) { + ctx.log(`bun install failed (exit ${inst.code}): ${firstLine(inst.stderr || inst.stdout)}`); + return { outcome: "aborted-build", verification: null, post: null, sourceDigests: digests }; + } + const pkgDir = join(rootRepoFork, "packages", "opencode"); + const build = await runShell("bun run build --single", { cwd: pkgDir, timeoutMs: 1_800_000 }); + if (build.code !== 0) { + ctx.log(`build --single failed (exit ${build.code}): ${firstLine(build.stderr || build.stdout)}`); + return { outcome: "aborted-build", verification: null, post: null, sourceDigests: digests }; + } + artifact = join(pkgDir, "dist", `opencode-${process.platform}-${process.arch}`, "bin", "opencode"); + try { + await stat(artifact); + } catch { + ctx.log(`build produced no binary at ${artifact}`); + return { outcome: "aborted-build", verification: null, post: null, sourceDigests: digests }; + } + } + + // (5) smoke-test the artifact BEFORE touching the surface + const smoke = await new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => { + execFile(artifact, ["--version"], { timeout: 60_000 }, (err, stdout, stderr) => { + const code = err && typeof (err as { code?: number }).code === "number" ? (err as { code: number }).code : err ? 1 : 0; + resolve({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") }); + }); + }); + if (smoke.code !== 0) { + ctx.log(`artifact --version smoke test failed (exit ${smoke.code}): ${firstLine(smoke.stderr)}`); + return { outcome: "aborted-build", verification: null, post: null, sourceDigests: digests }; + } + const artifactVersion = smoke.stdout.trim().split("\n").pop() ?? ""; + digests.artifact_sha256 = await fileSha(artifact); + digests.artifact_version = artifactVersion; + ctx.log(`smoke ok: ${artifact} --version → ${artifactVersion}`); + + // (6) freeze: preserve the current binary as opencode.prev, copy the new + // one, write the sidecar — never a live-path swap under a running server + await mkdir(binDir, { recursive: true }); + let prevSha: string | null = null; + try { + prevSha = await fileSha(frozen); + if (prevSha === null) throw new Error(`unreadable frozen binary: ${frozen}`); + await copyFile(frozen, prevBin); + ctx.log(`preserved current binary as opencode.prev (sha ${prevSha.slice(0, 12)})`); + } catch (e) { + ctx.log(`no usable current frozen binary (${e instanceof Error ? e.message : String(e)}) — first freeze (no opencode.prev)`); + prevSha = null; + } + await copyFile(artifact, frozen); + await chmod(frozen, 0o755); + const frozenSha = await fileSha(frozen); + if (frozenSha === null) { + ctx.log(`frozen binary unreadable after freeze: ${frozen} — aborting before kick`); + return { outcome: "aborted-error", verification: null, post: null, sourceDigests: digests }; + } + await writeFile(sidecar, `${frozenSha} opencode\n`); + digests.frozen_sha256 = frozenSha; + digests.prev_sha256 = prevSha; + ctx.log(`froze ${frozen} (sha ${frozenSha.slice(0, 12)}), sidecar written`); + + const kickEnv = (phase: string): Record => + stubEnv({ frozen, running: runningPath ?? undefined, prev: prevBin, server: rootServer, phase }); + + // (7) kick — launchctl kickstart is the durable mechanism (the /tmp kick + // script was a session hack, never the verb's path) + const kick = async (phase: string): Promise => { + ctx.log(`kick (${phase}): ${kickCommand}`); + const r = await runShell(kickCommand, { env: kickEnv(phase) }); + if (r.code !== 0) ctx.log(`kick exited ${r.code}: ${firstLine(r.stderr || r.stdout)}`); + }; + + // (8) verify: poll health AND the running process's binary sha == the + // frozen sha (both must hold); timeout; ONE re-kick retry, then restore. + const healthyAgainst = async (targetSha: string, phase: string): Promise => { + const h = await runShell(healthCommand, { env: kickEnv(phase), timeoutMs: 10_000 }); + if (h.code !== 0) return false; + const running = runningPath ?? (await defaultSurfaceContext().discoverRunning()); + if (!running) { + ctx.log("health ok but no running opencode serve process found (server-down)"); + return false; + } + const runningSha = await fileSha(running); + digests.running_sha256 = runningSha; + return runningSha === targetSha; + }; + const poll = async (targetSha: string, phase: string, budgetMs: number): Promise => { + const deadline = Date.now() + budgetMs; + const interval = budgetMs <= 5_000 ? 100 : 2_000; + for (;;) { + if (await healthyAgainst(targetSha, phase)) return true; + if (Date.now() >= deadline) return false; + await new Promise((res) => setTimeout(res, Math.min(interval, Math.max(1, deadline - Date.now())))); + } + }; + + if (noKick) { + ctx.log("--no-kick: freeze only — verification deferred; opencode.prev retained until a later verify passes"); + const post = await postRecords(ctx, ["server-binary"]); + return { outcome: "upgraded", verification: "deferred", post, sourceDigests: digests }; + } + + await kick("kick"); + let verified = await poll(frozenSha, "verify", timeoutMs); + if (!verified) { + ctx.log(`verify: unhealthy after ${timeoutMs}ms — one re-kick retry`); + await kick("verify-retry"); + verified = await poll(frozenSha, "verify-retry", timeoutMs); + } + + if (verified) { + // (9) success — delete opencode.prev (its verification passed) + ctx.log("verified: healthy + running sha == sidecar"); + await rm(prevBin, { force: true }); + ctx.log("deleted opencode.prev (verification passed)"); + const post = await postRecords(ctx, ["server-binary"]); + return { outcome: "upgraded", verification: true, post, sourceDigests: digests }; + } + + // restore: prev back over the frozen binary, sidecar REWRITTEN to prev's + // sha (else the surface would read integrity-failed forever), kick, and + // re-verify health + running sha == prev's sha. A VERIFIED restore + // deletes prev (its verification passed — the restore's own); a failed + // restore retains it and records restore-failed (the receipt records it; + // delivery is the watchdog's drift digest and the morning brief — never + // silence). + ctx.log("verification FAILED — restoring opencode.prev"); + if (prevSha === null) { + ctx.log("no opencode.prev to restore (first-ever freeze) — server left on the new, unverified binary"); + const post = await postRecords(ctx, ["server-binary"]); + return { outcome: "restore-failed", verification: false, post, sourceDigests: digests }; + } + await copyFile(prevBin, frozen); + await chmod(frozen, 0o755); + const restoredSha = await fileSha(frozen); + if (restoredSha === null) { + ctx.log(`restored binary unreadable: ${frozen} — prev retained; the watchdog/morning-brief is the delivery path`); + const post = await postRecords(ctx, ["server-binary"]); + return { outcome: "restore-failed", verification: false, post, sourceDigests: digests }; + } + await writeFile(sidecar, `${restoredSha} opencode\n`); // the sidecar REWRITE + digests.restored_sha256 = restoredSha; + ctx.log(`restored prev (sha ${restoredSha.slice(0, 12)}) — sidecar rewritten to match`); + await kick("restore-kick"); + const restoreVerified = await poll(restoredSha, "restore", timeoutMs); + if (restoreVerified) { + await rm(prevBin, { force: true }); + ctx.log("restore verified (healthy + running sha == prev's sha) — prev deleted"); + const post = await postRecords(ctx, ["server-binary"]); + return { outcome: "restored", verification: false, post, sourceDigests: digests }; + } + ctx.log("restore verification FAILED — server down; opencode.prev retained; the watchdog/morning-brief is the delivery path"); + const post = await postRecords(ctx, ["server-binary"]); + return { outcome: "restore-failed", verification: false, post, sourceDigests: digests }; + }); + +// ── the remaining verbs land in their own cycles; the router rejects them +// honestly until then (never a silent no-op) ───────────────────────────────── + +// ── the entry ──────────────────────────────────────────────────────────────── + +export async function upgradeVerb(argv: string[]): Promise { + const head = argv[0]; + if (!head || head.startsWith("--")) return { json: { verb: "upgrade", ok: false, errors: [`usage: ${USAGE}`] }, code: 64 }; + // Doctor record names alias their owning verb (spec D3: the panel passes + // the record name verbatim — "a fact the panel shows that doctor didn't + // say is a bug"; the aliasing belongs HERE, CLI-side). + const surface = RECORD_ALIASES[head] ?? head; + switch (surface) { + case "server-binary": + return serverBinaryVerb(argv); + case "extension": + return extensionVerb(argv); + case "agents": + return agentsVerb(argv); + case "skills": + return skillsVerb(argv); + default: + return { json: { verb: "upgrade", ok: false, errors: [`unknown surface "${head}" — ${USAGE}`] }, code: 64 }; + } +} + +/** doctor record name → owning verb (agent-cards has two records, one verb) */ +const RECORD_ALIASES: Record = { + "agent-cards-global": "agents", + "agent-cards-staging": "agents", +}; + +// re-export for the router's usage line + tests +export const UPGRADE_USAGE = USAGE; diff --git a/packages/amico-run/test/amico.test.ts b/packages/amico-run/test/amico.test.ts index 674809da..6a1b9ccf 100644 --- a/packages/amico-run/test/amico.test.ts +++ b/packages/amico-run/test/amico.test.ts @@ -5,7 +5,7 @@ // mcp-serve facade. Run: `pnpm --filter @amicode/amico-run test`. import { describe, it, expect, beforeAll } from "vitest"; import { execFile, execFileSync } from "node:child_process"; -import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fakeJulia, hermeticOpsEnv, readToml, tmpRoot, buildDoctorWorld, cleanupTracked } from "./helpers.js"; @@ -71,7 +71,7 @@ describe("amico router — help + unknown verb", () => { it("--help lists the full verb surface, exit 0", () => { const r = run(["--help"]); expect(r.code).toBe(0); - for (const v of ["run", "resolve", "sandbox", "catalog", "vault", "device", "note", "cloud", "mcp-serve"]) { + for (const v of ["run", "resolve", "sandbox", "catalog", "vault", "device", "note", "cloud", "doctor", "upgrade", "mcp-serve"]) { expect(r.stdout).toContain(`amico ${v}`); } }); @@ -315,3 +315,35 @@ describe("amico router — doctor v2 (surface inventory, #525)", () => { cleanupTracked(); }); }); + +describe("amico router — upgrade verbs (#526)", () => { + it("upgrade dispatches through the bundle: a current surface is a no-op with a receipt on stdout", () => { + const w = buildDoctorWorld(); + const r = run([ + "upgrade", "skills", + "--root-server", w.server, + "--root-vscext", w.vscext, + "--root-config", w.config, + "--root-repo-amicode", w.repoAmicode, + "--root-repo-fork", w.repoFork, + "--root-staging", w.staging, + ]); + expect(r.code).toBe(0); + const receipt = JSON.parse(r.stdout) as { verb: string; outcome: string; verification: boolean }; + expect(receipt.verb).toBe("skills"); + expect(receipt.outcome).toBe("no-op"); + expect(receipt.verification).toBe(true); + // the JSONL store landed under the (injected) root-server's receipts dir + const receipts = readFileSync(join(w.server, "upgrade-receipts", "upgrade-receipts.jsonl"), "utf8"); + expect(receipts.trim().split("\n").length).toBe(1); + expect(receipts).toContain('"outcome":"no-op"'); + cleanupTracked(); + }); + + it("upgrade with an unknown surface → usage error, exit 64", () => { + const r = run(["upgrade", "sidecar-bin"]); + expect(r.code).toBe(64); + expect(r.stderr + r.stdout).toMatch(/unknown surface/); + expect(r.stderr + r.stdout).toContain("sidecar-bin"); + }); +}); diff --git a/packages/amico-run/test/helpers.ts b/packages/amico-run/test/helpers.ts index 9343612a..6f16ff9c 100644 --- a/packages/amico-run/test/helpers.ts +++ b/packages/amico-run/test/helpers.ts @@ -253,6 +253,17 @@ export function bumpExtensionOnRemote(bare: string, version: string): void { }); } +/** Move the fork remote's local/amicode tip forward one commit — the + * checkout learns of it ONLY through a fetch (clean-but-behind fixtures, + * #526's fast-forward branch). */ +export function bumpForkHead(bare: string): void { + withBareClone(bare, "local/amicode", (clone) => { + writeFileSync(join(clone, "fork-update.txt"), "fork head moves forward\n"); + fixtureGit(clone, ["add", "-A"]); + fixtureGit(clone, ["commit", "-m", "fork head moves forward"]); + }); +} + export function addReleaseTagOnRemote(bare: string, tag: string): void { withBareClone(bare, "local/amicode", (clone) => { fixtureGit(clone, ["tag", tag]); diff --git a/packages/amico-run/test/upgrade-agents.test.ts b/packages/amico-run/test/upgrade-agents.test.ts new file mode 100644 index 00000000..22fe8a04 --- /dev/null +++ b/packages/amico-run/test/upgrade-agents.test.ts @@ -0,0 +1,194 @@ +// upgrade-agents.test.ts — the `amico upgrade agents` verb (#526, spec D2): +// wraps deploy-agents.mjs against the two agent-card destinations and writes +// BOTH receipt stores — the contract-path .deploy-receipt.json (doctor's +// freshness input) and the upgrade-receipts JSONL. Hermetic: temp roots, the +// REAL deploy-agents.mjs copied into the fixture amicode checkout (so its +// SOURCE_DIR resolves inside the fixture, never the real repo). +import { describe, test, expect } from "vitest"; +import { readFileSync, writeFileSync, rmSync, copyFileSync, mkdirSync, existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { surfaceInventory, dirDigest, fileSha, type SurfaceRecord } from "../src/surfaces.js"; +import { upgradeVerb } from "../src/upgrade.js"; +import { buildDoctorWorld, ctxForWorld, cleanupTracked, type DoctorWorld } from "./helpers.js"; + +const cleanup = cleanupTracked; + +// the REAL script, copied into the fixture checkout — the verb runs the +// checkout's copy (that is the live contract: the script ships in the repo) +const REAL_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "scripts", "deploy-agents.mjs"); + +function stageAgentsWorld(): DoctorWorld { + const w = buildDoctorWorld(); + mkdirSync(join(w.repoAmicode, "scripts"), { recursive: true }); + copyFileSync(REAL_SCRIPT, join(w.repoAmicode, "scripts", "deploy-agents.mjs")); + // stage stale: tamper one GLOBAL deployed card (per-card digest drift) + writeFileSync(join(w.config, "agents", "autodev.md"), "---\nmode: autodev\n---\n# TAMPERED\n"); + return w; +} + +function verbArgs(w: DoctorWorld, extra: string[] = []): string[] { + return [ + "agents", + "--root-server", w.server, + "--root-vscext", w.vscext, + "--root-config", w.config, + "--root-repo-amicode", w.repoAmicode, + "--root-repo-fork", w.repoFork, + "--root-staging", w.staging, + ...extra, + ]; +} + +const receiptsDir = (w: DoctorWorld): string => join(w.server, "upgrade-receipts"); +const lastReceipt = (w: DoctorWorld): Record => { + const lines = readFileSync(receiptsDir(w) + "/upgrade-receipts.jsonl", "utf8") + .split("\n") + .filter((l) => l.trim()); + expect(lines.length).toBeGreaterThan(0); + return JSON.parse(lines[lines.length - 1]); +}; +const bySurface = (records: SurfaceRecord[], name: string): SurfaceRecord => + records.find((r) => r.surface === name)!; + +describe("upgrade agents — stale deployment", () => { + test("tampered global card → upgraded: BOTH receipt stores written, both surfaces converged", async () => { + const w = stageAgentsWorld(); + const r = await upgradeVerb(verbArgs(w, ["--root-receipts", receiptsDir(w)])); + expect(r.code).toBe(0); + const receipt = r.json as Record; + expect(receipt.outcome).toBe("upgraded"); + expect(receipt.verification).toBe(true); + expect(receipt.verb).toBe("agents"); + + // pre: global stale (tampered), staging current — post: BOTH current + const pre = receipt.pre as SurfaceRecord[]; + expect(bySurface(pre, "agent-cards-global").verdict).toBe("stale"); + expect(bySurface(pre, "agent-cards-staging").verdict).toBe("current"); + const post = receipt.post as SurfaceRecord[]; + expect(bySurface(post, "agent-cards-global").verdict).toBe("current"); + expect(bySurface(post, "agent-cards-staging").verdict).toBe("current"); + + // BOTH receipt stores: the contract-path receipt (fresh, digests match sources)… + const contractPath = join(w.repoAmicode, "packages", "extension", "agents", ".deploy-receipt.json"); + expect(existsSync(contractPath)).toBe(true); + const contract = JSON.parse(readFileSync(contractPath, "utf8")) as { + deployed_at: string; + sources: { card: string; sha256: string }[]; + }; + expect(contract.deployed_at).toMatch(/^\d{4}-\d{2}-\d{2}T/); + for (const s of contract.sources) { + expect(s.sha256).toBe(`sha256:${await fileSha(join(w.repoAmicode, "packages", "extension", "agents", s.card))}`); + } + // …and the JSONL store (same outcome) + expect(lastReceipt(w).outcome).toBe("upgraded"); + + // the tampered card was actually repaired from source + const repaired = readFileSync(join(w.config, "agents", "autodev.md"), "utf8"); + const source = readFileSync(join(w.repoAmicode, "packages", "extension", "agents", "autodev.md"), "utf8"); + expect(repaired).toBe(source); + cleanup(); + }); + + test("verification independence: receipt.post equals an independent doctor re-run (both records)", async () => { + const w = stageAgentsWorld(); + await upgradeVerb(verbArgs(w, ["--root-receipts", receiptsDir(w)])); + const receipt = lastReceipt(w) as { post: SurfaceRecord[] }; + const independent = await surfaceInventory(ctxForWorld(w)); + expect(receipt.post).toEqual([ + bySurface(independent.surfaces, "agent-cards-global"), + bySurface(independent.surfaces, "agent-cards-staging"), + ]); + cleanup(); + }); +}); + +describe("upgrade agents — idempotence (the AC fixture)", () => { + test("run 2: exit 0 no-op; both agent dirs + the .deploy-receipt.json byte-unchanged", async () => { + const w = stageAgentsWorld(); + const args = verbArgs(w, ["--root-receipts", receiptsDir(w)]); + + const run1 = await upgradeVerb(args); + expect(run1.code).toBe(0); + expect((run1.json as Record).outcome).toBe("upgraded"); + + // the ENUMERATED digest set: both agent dirs + the contract receipt + const digestSet = async (): Promise => { + const g = await dirDigest(join(w.config, "agents")); + const s = await dirDigest(join(w.staging, ".opencode", "agents")); + const rc = await fileSha(join(w.repoAmicode, "packages", "extension", "agents", ".deploy-receipt.json")); + return `${g}/${s}/${rc}`; + }; + const after1 = await digestSet(); + + const run2 = await upgradeVerb(args); + expect(run2.code).toBe(0); + const receipt2 = run2.json as Record; + expect(receipt2.outcome).toBe("no-op"); + expect(receipt2.verification).toBe(true); + + // run 2 did NOT re-run deploy-agents.mjs (its receipt timestamp would move) + expect(await digestSet()).toBe(after1); + expect(lastReceipt(w).outcome).toBe("no-op"); + cleanup(); + }); +}); + +describe("upgrade agents — pre-flight gates + aborts", () => { + test("both deployments current → no-op, deploy script NOT run (receipt untouched)", async () => { + const w = stageAgentsWorld(); + const args = verbArgs(w, ["--root-receipts", receiptsDir(w)]); + await upgradeVerb(args); // converge + const contractPath = join(w.repoAmicode, "packages", "extension", "agents", ".deploy-receipt.json"); + const before = readFileSync(contractPath, "utf8"); + const r = await upgradeVerb(args); + expect(r.code).toBe(0); + expect((r.json as Record).outcome).toBe("no-op"); + expect(readFileSync(contractPath, "utf8")).toBe(before); // not rewritten + cleanup(); + }); + + test("missing source dir → aborted-unknown (missing-local-source is unknown)", async () => { + const w = stageAgentsWorld(); + rmSync(join(w.repoAmicode, "packages", "extension", "agents"), { recursive: true, force: true }); + const r = await upgradeVerb(verbArgs(w, ["--root-receipts", receiptsDir(w)])); + expect(r.code).toBe(1); + const receipt = r.json as Record; + expect(receipt.outcome).toBe("aborted-unknown"); + expect(receipt.post).toBeNull(); + cleanup(); + }); + + test("deploy-agents.mjs absent from the checkout → aborted-environment, nothing deployed", async () => { + const w = buildDoctorWorld(); + writeFileSync(join(w.config, "agents", "autodev.md"), "---\nmode: autodev\n---\n# TAMPERED\n"); + const tampered = readFileSync(join(w.config, "agents", "autodev.md"), "utf8"); + const r = await upgradeVerb(verbArgs(w, ["--root-receipts", receiptsDir(w)])); + expect(r.code).toBe(1); + const receipt = r.json as Record; + expect(receipt.outcome).toBe("aborted-environment"); + // nothing was deployed + expect(readFileSync(join(w.config, "agents", "autodev.md"), "utf8")).toBe(tampered); + cleanup(); + }); +}); + +// ── the record-name alias (spec D3: the panel passes doctor's record names verbatim) ── + +test("doctor record names agent-cards-global / agent-cards-staging alias the agents verb", async () => { + const w = buildDoctorWorld(); + const cleanup = () => cleanupTracked(); + try { + // stage drift so the aliased run has something to do + writeFileSync(join(w.config, "agents", "autodev.md"), "---\nmode: autodev\n---\n# TAMPERED\n"); + for (const alias of ["agent-cards-global", "agent-cards-staging"]) { + const argv = [...verbArgs(w), "--root-receipts", receiptsDir(w)]; + argv[0] = alias; // the panel sends the doctor record name as the surface + const r = await upgradeVerb(argv); + expect(r.code, `alias ${alias} must route to the agents verb, not "unknown surface"`).not.toBe(64); + expect((r.json as Record).verb).toBe("agents"); + } + } finally { + cleanup(); + } +}); diff --git a/packages/amico-run/test/upgrade-extension.test.ts b/packages/amico-run/test/upgrade-extension.test.ts new file mode 100644 index 00000000..f0059a61 --- /dev/null +++ b/packages/amico-run/test/upgrade-extension.test.ts @@ -0,0 +1,182 @@ +// upgrade-extension.test.ts — the `amico upgrade extension` verb (#526, spec +// D2): amicode-repo git discipline (fetch, clean, ancestor; clean-but-behind +// → ff-only pull; dirt/divergence → aborted-diverged), package, install (VS +// Code CLI live / --install-command stub), stale-dir removal version-sorted, +// verification against the FETCHED origin/main version. Hermetic: temp roots, +// stub package/install commands. +import { describe, test, expect } from "vitest"; +import { readFileSync, writeFileSync, rmSync, existsSync, readdirSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; +import { surfaceInventory, dirDigest, type SurfaceRecord } from "../src/surfaces.js"; +import { upgradeVerb } from "../src/upgrade.js"; +import { + buildDoctorWorld, + ctxForWorld, + cleanupTracked, + bumpExtensionOnRemote, + fixtureGit, + type DoctorWorld, +} from "./helpers.js"; + +const cleanup = cleanupTracked; +const DEAD_REMOTE = "/nonexistent/upgrade-fixture-remote.git"; + +// the stub command pair (the hermetic package+install seam): +// - package stub: succeeds (the fixture repo has no pnpm scripts to run) +// - install stub: materializes the target extension dir — exactly what the +// real `code --install-extension` does to --root-vscext +const PACKAGE_STUB = "true"; +const installStub = (): string => + 'mkdir -p "$AMICO_UPGRADE_ROOT_VSCEXT/harmoniqs.amicode-$AMICO_UPGRADE_TARGET_VERSION"'; + +function verbArgs(w: DoctorWorld, extra: string[] = []): string[] { + return [ + "extension", + "--root-server", w.server, + "--root-vscext", w.vscext, + "--root-config", w.config, + "--root-repo-amicode", w.repoAmicode, + "--root-repo-fork", w.repoFork, + "--root-staging", w.staging, + "--package-command", PACKAGE_STUB, + "--install-command", installStub(), + ...extra, + ]; +} + +const receiptsDir = (w: DoctorWorld): string => join(w.server, "upgrade-receipts"); +const lastReceipt = (w: DoctorWorld): Record => { + const lines = readFileSync(receiptsDir(w) + "/upgrade-receipts.jsonl", "utf8") + .split("\n") + .filter((l) => l.trim()); + expect(lines.length).toBeGreaterThan(0); + return JSON.parse(lines[lines.length - 1]); +}; +const bySurface = (records: SurfaceRecord[], name: string): SurfaceRecord => + records.find((r) => r.surface === name)!; +const headOf = (w: DoctorWorld): string => + execFileSync("git", ["-C", w.repoAmicode, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + +/** Stage the canonical stale-extension world: remote bumped to 0.2.7, the + * checkout still at 0.2.6 (clean but BEHIND — exercises the ff-only pull). */ +function stageBehind(w: DoctorWorld): void { + bumpExtensionOnRemote(w.remoteAmicode, "0.2.7"); +} + +describe("upgrade extension — stale (clean-but-behind)", () => { + test("behind checkout → ff-only pull, package, install, stale dirs removed, verified vs FETCHED version", async () => { + const w = buildDoctorWorld(); + stageBehind(w); + const headBefore = headOf(w); + + const r = await upgradeVerb(verbArgs(w, ["--root-receipts", receiptsDir(w)])); + expect(r.code).toBe(0); + const receipt = r.json as Record; + expect(receipt.outcome).toBe("upgraded"); + expect(receipt.verification).toBe(true); + + // pre stale (0.2.6 behind 0.2.7), post current + const pre = receipt.pre as SurfaceRecord[]; + expect(bySurface(pre, "extension").verdict).toBe("stale"); + const post = receipt.post as SurfaceRecord[]; + expect(bySurface(post, "extension").verdict).toBe("current"); + expect(bySurface(post, "extension").version).toContain("0.2.7"); + expect(bySurface(post, "extension").source_version).toBe("0.2.7"); + + // the checkout was fast-forwarded to origin/main (HEAD moved, ff-only) + expect(headOf(w)).not.toBe(headBefore); + const head = headOf(w); + const originMain = execFileSync("git", ["-C", w.repoAmicode, "rev-parse", "origin/main"], { + encoding: "utf8", + }).trim(); + expect(head).toBe(originMain); + + // the new dir exists; the stale dirs (0.2.6, 0.2.4-darwin-arm64) are gone — + // version-sorted removal, exactly one installed version remains + const dirs = readdirSync(w.vscext).filter((d) => /^harmoniqs\.amicode-/.test(d)); + expect(dirs.sort()).toEqual(["harmoniqs.amicode-0.2.7"]); + + // source digests carry the heads + versions + expect(receipt.source_digests).toMatchObject({ fetched_version: "0.2.7" }); + expect(lastReceipt(w).outcome).toBe("upgraded"); + cleanup(); + }); + + test("verification independence: receipt.post equals an independent doctor re-run", async () => { + const w = buildDoctorWorld(); + stageBehind(w); + await upgradeVerb(verbArgs(w, ["--root-receipts", receiptsDir(w)])); + const receipt = lastReceipt(w) as { post: SurfaceRecord[] }; + const independent = await surfaceInventory(ctxForWorld(w)); + expect(receipt.post).toEqual([bySurface(independent.surfaces, "extension")]); + cleanup(); + }); +}); + +describe("upgrade extension — idempotence (the AC fixture)", () => { + test("run 2: exit 0 no-op, the --root-vscext tree byte-unchanged", async () => { + const w = buildDoctorWorld(); + stageBehind(w); + const args = verbArgs(w, ["--root-receipts", receiptsDir(w)]); + + const run1 = await upgradeVerb(args); + expect(run1.code).toBe(0); + expect((run1.json as Record).outcome).toBe("upgraded"); + const after1 = await dirDigest(w.vscext); + + const run2 = await upgradeVerb(args); + expect(run2.code).toBe(0); + const receipt2 = run2.json as Record; + expect(receipt2.outcome).toBe("no-op"); + expect(receipt2.verification).toBe(true); + expect(await dirDigest(w.vscext)).toBe(after1); // the enumerated destination set + expect(lastReceipt(w).outcome).toBe("no-op"); + cleanup(); + }); +}); + +describe("upgrade extension — git discipline aborts", () => { + test("dirty tree → aborted-diverged, nothing packaged/installed", async () => { + const w = buildDoctorWorld(); + stageBehind(w); + writeFileSync(join(w.repoAmicode, "stray.txt"), "dirt\n"); + const r = await upgradeVerb(verbArgs(w, ["--root-receipts", receiptsDir(w)])); + expect(r.code).toBe(1); + const receipt = r.json as Record; + expect(receipt.outcome).toBe("aborted-diverged"); + expect(receipt.post).toBeNull(); + // no install happened: the extension dirs are untouched + const dirs = readdirSync(w.vscext).filter((d) => /^harmoniqs\.amicode-/.test(d)); + expect(dirs.sort()).toEqual(["harmoniqs.amicode-0.2.4-darwin-arm64", "harmoniqs.amicode-0.2.6"]); + expect(lastReceipt(w).outcome).toBe("aborted-diverged"); + cleanup(); + }); + + test("diverged checkout (local commit ahead) → aborted-diverged — never reset, never merge", async () => { + const w = buildDoctorWorld(); + stageBehind(w); + // a local commit the remote does not carry → HEAD not an ancestor of origin/main + writeFileSync(join(w.repoAmicode, "local-only.txt"), "local work\n"); + fixtureGit(w.repoAmicode, ["add", "-A"]); + fixtureGit(w.repoAmicode, ["commit", "-m", "local experiment"]); + const headBefore = headOf(w); + + const r = await upgradeVerb(verbArgs(w, ["--root-receipts", receiptsDir(w)])); + expect(r.code).toBe(1); + expect((r.json as Record).outcome).toBe("aborted-diverged"); + // the diverged checkout is the human's to resolve — the verb did not touch it + expect(headOf(w)).toBe(headBefore); + expect(existsSync(join(w.repoAmicode, "local-only.txt"))).toBe(true); + cleanup(); + }); + + test("unreachable remote → aborted-unknown (pre-flight: never judge blind)", async () => { + const w = buildDoctorWorld(); + fixtureGit(w.repoAmicode, ["remote", "set-url", "origin", DEAD_REMOTE]); + const r = await upgradeVerb(verbArgs(w, ["--root-receipts", receiptsDir(w)])); + expect(r.code).toBe(1); + expect((r.json as Record).outcome).toBe("aborted-unknown"); + cleanup(); + }); +}); diff --git a/packages/amico-run/test/upgrade-server-binary.test.ts b/packages/amico-run/test/upgrade-server-binary.test.ts new file mode 100644 index 00000000..0e651334 --- /dev/null +++ b/packages/amico-run/test/upgrade-server-binary.test.ts @@ -0,0 +1,322 @@ +// upgrade-server-binary.test.ts — the `amico upgrade server-binary` verb +// (#526, spec D2): the full 9-step chain — env preflight, fork git discipline +// (clean+ancestor or aborted-diverged), bun install + build --single (live +// only — fixtures use --skip-build), smoke --version, freeze with opencode.prev +// preserved, launchctl kickstart kick, poll health + running-sha==sidecar with +// 120s timeout + one re-kick retry, restore-from-prev with sidecar REWRITE on +// failure, prev deletion on success/verified-restore. Hermetic per the +// kick-stub contract: the kick stub copies the frozen binary to the +// --running-binary path; the health stub shapes the verify phases. +import { describe, test, expect } from "vitest"; +import { readFileSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { surfaceInventory, fileSha, type SurfaceRecord } from "../src/surfaces.js"; +import { upgradeVerb } from "../src/upgrade.js"; +import { + buildDoctorWorld, + ctxForWorld, + cleanupTracked, + trackTmp, + fakeBin, + fixtureGit, + bumpForkHead, + FUTURE_BUILD, + PAST_BUILD, + type DoctorWorld, +} from "./helpers.js"; + +const cleanup = cleanupTracked; +const DEAD_REMOTE = "/nonexistent/upgrade-sb-remote.git"; + +// ── the stub command contract ──────────────────────────────────────────────── + +/** THE KICK STUB (spec D2's contract): make the running-binary evidence match + * the frozen artifact — copy frozen → running. Serves the initial kick, the + * re-kick retry, AND the restore kick (frozen is then prev's bytes). */ +const KICK_STUB = 'cp "$AMICO_UPGRADE_FROZEN_BIN" "$AMICO_UPGRADE_RUNNING_BIN"'; + +/** health stubs: exit 0 = healthy; phase-aware for the restore fixture. */ +const HEALTH_OK = "true"; +const HEALTH_FAIL_VERIFY_ONLY = + 'case "$AMICO_UPGRADE_PHASE" in verify*) exit 1;; *) exit 0;; esac'; +const HEALTH_FAIL_ALWAYS = "exit 1"; + +function verbArgs(w: DoctorWorld, extra: string[]): string[] { + return [ + "server-binary", + "--root-server", w.server, + "--root-vscext", w.vscext, + "--root-config", w.config, + "--root-repo-amicode", w.repoAmicode, + "--root-repo-fork", w.repoFork, + "--root-staging", w.staging, + "--running-binary", w.running, + ...extra, + ]; +} + +const receiptsDir = (w: DoctorWorld): string => join(w.server, "upgrade-receipts"); +const lastReceipt = (w: DoctorWorld): Record => { + const lines = readFileSync(join(receiptsDir(w), "upgrade-receipts.jsonl"), "utf8") + .split("\n") + .filter((l) => l.trim()); + expect(lines.length).toBeGreaterThan(0); + return JSON.parse(lines[lines.length - 1]); +}; +const bySurface = (records: SurfaceRecord[], name: string): SurfaceRecord => + records.find((r) => r.surface === name)!; + +/** A fresh artifact for --skip-build: a fake binary printing a FUTURE build + * date (≥ the fixture fork's pinned HEAD commit date). */ +function freshArtifact(): string { + return fakeBin(trackTmp("upgrade-artifact-"), "opencode-new", FUTURE_BUILD); +} + +/** The canonical success-run argv (version-stale world → upgraded). */ +function successArgs(w: DoctorWorld, artifact: string): string[] { + return verbArgs(w, [ + "--root-receipts", receiptsDir(w), + "--skip-build", artifact, + "--kick-command", KICK_STUB, + "--health-command", HEALTH_OK, + "--verify-timeout-ms", "2000", + ]); +} + +/** Stage the VERSION-stale branch: frozen prints PAST_BUILD (< the fork's + * pinned HEAD commit date), running == frozen bytes, integrity intact. */ +function stageVersionStale(): DoctorWorld { + return buildDoctorWorld({ frozenVersion: PAST_BUILD }); +} + +// ── the success chain ──────────────────────────────────────────────────────── + +describe("upgrade server-binary — version-stale → upgraded (the full chain)", () => { + test("skip-build + kick/health stubs: freeze, kick, verify, prev deleted, receipt complete", async () => { + const w = stageVersionStale(); + const artifact = freshArtifact(); + const newSha = await fileSha(artifact); + + const r = await upgradeVerb(successArgs(w, artifact)); + expect(r.code).toBe(0); + const receipt = r.json as Record; + expect(receipt.outcome).toBe("upgraded"); + expect(receipt.verification).toBe(true); + + // pre: stale (version); post: current — both records in the receipt + expect(bySurface(receipt.pre, "server-binary").verdict).toBe("stale"); + expect(bySurface(receipt.post, "server-binary").verdict).toBe("current"); + + // the frozen binary IS the artifact; sidecar matches; running matches + expect(await fileSha(join(w.server, "bin", "opencode"))).toBe(newSha); + const sidecar = readFileSync(join(w.server, "bin", "opencode.sha256"), "utf8"); + expect(sidecar).toContain(newSha); + expect(await fileSha(w.running)).toBe(newSha); + + // success deletes opencode.prev (its verification passed) + expect(existsSync(join(w.server, "bin", "opencode.prev"))).toBe(false); + + // source digests: the artifact + the fork state + expect(receipt.source_digests.artifact_sha256).toBe(newSha); + expect(receipt.source_digests.frozen_sha256).toBe(newSha); + expect(lastReceipt(w).outcome).toBe("upgraded"); + cleanup(); + }); + + test("verification independence: receipt.post equals an independent doctor re-run", async () => { + const w = stageVersionStale(); + const r = await upgradeVerb(successArgs(w, freshArtifact())); + expect(r.code).toBe(0); + const receipt = r.json as Record; + const independent = await surfaceInventory(ctxForWorld(w)); + expect(receipt.post).toEqual([bySurface(independent.surfaces, "server-binary")]); + expect(bySurface(independent.surfaces, "server-binary").verdict).toBe("current"); + cleanup(); + }); +}); + +describe("upgrade server-binary — idempotence (the AC fixture, VERSION-stale branch)", () => { + test("run 2: exit 0 no-op; frozen binary + sidecar byte-unchanged (prev out of scope — run 1 deleted it)", async () => { + const w = stageVersionStale(); + const artifact = freshArtifact(); + const args = successArgs(w, artifact); + + const run1 = await upgradeVerb(args); + expect(run1.code).toBe(0); + expect((run1.json as Record).outcome).toBe("upgraded"); + // the ENUMERATED digest set: the frozen binary + the sidecar + const digestSet = async (): Promise => + `${await fileSha(join(w.server, "bin", "opencode"))}/${await fileSha(join(w.server, "bin", "opencode.sha256"))}`; + const after1 = await digestSet(); + + const run2 = await upgradeVerb(args); + expect(run2.code).toBe(0); + const receipt2 = run2.json as Record; + expect(receipt2.outcome).toBe("no-op"); + expect(receipt2.verification).toBe(true); + expect(bySurface(receipt2.pre, "server-binary").verdict).toBe("current"); + expect(await digestSet()).toBe(after1); + expect(lastReceipt(w).outcome).toBe("no-op"); + cleanup(); + }); +}); + +// ── the git discipline aborts ──────────────────────────────────────────────── + +describe("upgrade server-binary — fork git discipline", () => { + test("dirty fork → aborted-diverged; nothing frozen, no receipt lie", async () => { + const w = stageVersionStale(); + writeFileSync(join(w.repoFork, "stray.txt"), "dirt\n"); + const r = await upgradeVerb(successArgs(w, freshArtifact())); + expect(r.code).toBe(1); + const receipt = r.json as Record; + expect(receipt.outcome).toBe("aborted-diverged"); + expect(receipt.post).toBeNull(); + // the frozen binary was NOT touched + expect(readFileSync(join(w.server, "bin", "opencode.sha256"), "utf8")).toContain( + await fileSha(join(w.server, "bin", "opencode")), + ); + expect(lastReceipt(w).outcome).toBe("aborted-diverged"); + cleanup(); + }); + + test("diverged fork (local commit ahead) → aborted-diverged — never reset, never merge", async () => { + const w = stageVersionStale(); + writeFileSync(join(w.repoFork, "local-experiment.txt"), "local\n"); + fixtureGit(w.repoFork, ["add", "-A"]); + fixtureGit(w.repoFork, ["commit", "-m", "local experiment"]); + const r = await upgradeVerb(successArgs(w, freshArtifact())); + expect(r.code).toBe(1); + expect((r.json as Record).outcome).toBe("aborted-diverged"); + // the diverged checkout is the human's to resolve + expect(existsSync(join(w.repoFork, "local-experiment.txt"))).toBe(true); + cleanup(); + }); + + test("clean-but-behind fork is fast-forwarded to the ref before freezing", async () => { + const w = stageVersionStale(); + bumpForkHead(w.remoteFork); // remote moves; the checkout stays clean-behind + const artifact = freshArtifact(); + const r = await upgradeVerb(successArgs(w, artifact)); + expect(r.code).toBe(0); + const receipt = r.json as Record; + expect(receipt.outcome).toBe("upgraded"); + expect(receipt.source_digests.fork_head_after).toBe(receipt.source_digests.fork_head_at_ref); + // the checkout's HEAD == origin/local/amicode (fast-forwarded, not reset) + expect( + (await import("node:child_process")).execFileSync("git", + ["-C", w.repoFork, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(), + ).toBe( + (await import("node:child_process")).execFileSync("git", + ["-C", w.repoFork, "rev-parse", "origin/local/amicode"], { encoding: "utf8" }).trim(), + ); + cleanup(); + }); + + test("unreachable fork remote → aborted-unknown (pre-flight)", async () => { + const w = stageVersionStale(); + fixtureGit(w.repoFork, ["remote", "set-url", "origin", DEAD_REMOTE]); + const r = await upgradeVerb(successArgs(w, freshArtifact())); + expect(r.code).toBe(1); + expect((r.json as Record).outcome).toBe("aborted-unknown"); + cleanup(); + }); +}); + +// ── --no-kick: freeze only ─────────────────────────────────────────────────── + +describe("upgrade server-binary — --no-kick (freeze only)", () => { + test("verification deferred, prev RETAINED (until a later verify passes), exit 0", async () => { + const w = stageVersionStale(); + const artifact = freshArtifact(); + const args = verbArgs(w, [ + "--root-receipts", receiptsDir(w), + "--skip-build", artifact, + "--no-kick", + ]); + const r = await upgradeVerb(args); + expect(r.code).toBe(0); + const receipt = r.json as Record; + expect(receipt.outcome).toBe("upgraded"); + expect(receipt.verification).toBe("deferred"); + // frozen is the new artifact, sidecar matches… + expect(await fileSha(join(w.server, "bin", "opencode"))).toBe(await fileSha(artifact)); + // …but prev is retained and the running process still has the OLD bytes + expect(existsSync(join(w.server, "bin", "opencode.prev"))).toBe(true); + expect(await fileSha(w.running)).not.toBe(await fileSha(artifact)); + // the post record is honest: restart pending (running ≠ frozen) + expect(bySurface(receipt.post, "server-binary").verdict).toBe("stale"); + expect(bySurface(receipt.post, "server-binary").evidence.join(" ")).toMatch(/restart pending/); + cleanup(); + }); +}); + +// ── the restore paths ──────────────────────────────────────────────────────── + +describe("upgrade server-binary — restore (verify fails → rollback to prev)", () => { + test("health fails on verify, succeeds on restore → outcome restored: prev back, sidecar REWRITTEN, running == prev, prev deleted", async () => { + const w = stageVersionStale(); + const oldFrozen = join(w.server, "bin", "opencode"); + const prevSha = await fileSha(oldFrozen); // PAST_BUILD bytes + const artifact = freshArtifact(); + const args = verbArgs(w, [ + "--root-receipts", receiptsDir(w), + "--skip-build", artifact, + "--kick-command", KICK_STUB, + "--health-command", HEALTH_FAIL_VERIFY_ONLY, + "--verify-timeout-ms", "400", + ]); + + const r = await upgradeVerb(args); + // the upgrade FAILED and rolled back — the receipt says so, exit non-zero + expect(r.code).toBe(1); + const receipt = r.json as Record; + expect(receipt.outcome).toBe("restored"); + expect(receipt.verification).toBe(false); + expect(receipt.detail.join(" ")).toMatch(/restore/); + + // prev is back as the frozen binary; the sidecar was REWRITTEN to prev's + // sha (else the surface would read integrity-failed forever) + expect(await fileSha(oldFrozen)).toBe(prevSha); + expect(readFileSync(`${oldFrozen}.sha256`, "utf8")).toContain(prevSha); + // the running process runs prev's bytes (the restore kick + verify passed) + expect(await fileSha(w.running)).toBe(prevSha); + // a VERIFIED restore deletes prev too (its verification passed — the restore's own) + expect(existsSync(join(w.server, "bin", "opencode.prev"))).toBe(false); + + // verification independence on the restored state + const independent = await surfaceInventory(ctxForWorld(w)); + expect(receipt.post).toEqual([bySurface(independent.surfaces, "server-binary")]); + // the surface is honestly stale (rolled back to the old version) — never + // integrity-failed, never current + expect(bySurface(independent.surfaces, "server-binary").verdict).toBe("stale"); + expect(lastReceipt(w).outcome).toBe("restored"); + cleanup(); + }); + + test("health fails ALWAYS → restore-failed: prev retained, verification false, exit non-zero", async () => { + const w = stageVersionStale(); + const prevSha = await fileSha(join(w.server, "bin", "opencode")); + const args = verbArgs(w, [ + "--root-receipts", receiptsDir(w), + "--skip-build", freshArtifact(), + "--kick-command", KICK_STUB, + "--health-command", HEALTH_FAIL_ALWAYS, + "--verify-timeout-ms", "300", + ]); + + const r = await upgradeVerb(args); + expect(r.code).toBe(1); + const receipt = r.json as Record; + expect(receipt.outcome).toBe("restore-failed"); + expect(receipt.verification).toBe(false); + + // the restore still ran as far as it could: frozen = prev, sidecar = prev + expect(await fileSha(join(w.server, "bin", "opencode"))).toBe(prevSha); + expect(readFileSync(join(w.server, "bin", "opencode.sha256"), "utf8")).toContain(prevSha); + // server down: prev RETAINED (the only good copy — never deleted on failure) + expect(existsSync(join(w.server, "bin", "opencode.prev"))).toBe(true); + expect(lastReceipt(w).outcome).toBe("restore-failed"); + cleanup(); + }); +}); diff --git a/packages/amico-run/test/upgrade-skills.test.ts b/packages/amico-run/test/upgrade-skills.test.ts new file mode 100644 index 00000000..b4066452 --- /dev/null +++ b/packages/amico-run/test/upgrade-skills.test.ts @@ -0,0 +1,272 @@ +// upgrade-skills.test.ts — the `amico upgrade skills` verb (#526, spec D2) plus +// the SHARED verb plumbing it exercises first: pre-flight doctor gate +// (current → no-op; unknown → aborted-unknown; stale → proceed), the +// single-operator lock (flock semantics via O_EXCL + PID liveness steal), and +// the append-only JSONL receipts. Fully hermetic — every run injects temp +// roots via the flags mirroring doctor's, the real ~/.amico is never touched. +import { describe, test, expect } from "vitest"; +import { readFileSync, writeFileSync, rmSync, mkdirSync, existsSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; +import { surfaceInventory, dirDigest, type SurfaceRecord } from "../src/surfaces.js"; +import { upgradeVerb, acquireUpgradeLock } from "../src/upgrade.js"; +import { + buildDoctorWorld, + ctxForWorld, + cleanupTracked, + trackTmp, + fakeBin, + FUTURE_BUILD, + type DoctorWorld, +} from "./helpers.js"; + +const cleanup = cleanupTracked; + +// ── harness ────────────────────────────────────────────────────────────────── + +/** All seven root flags the verbs mirror from doctor — the probe context is + * always fully injected, never the developer's real machine. */ +function verbArgs(w: DoctorWorld, surface: string, extra: string[] = []): string[] { + return [ + surface, + "--root-server", w.server, + "--root-vscext", w.vscext, + "--root-config", w.config, + "--root-repo-amicode", w.repoAmicode, + "--root-repo-fork", w.repoFork, + "--root-staging", w.staging, + ...extra, + ]; +} + +interface ReceiptLine { + receipt_version: number; + verb: string; + timestamp: string; + outcome: string; + pre: SurfaceRecord[] | null; + post: SurfaceRecord[] | null; + source_digests: Record; + verification: boolean | string | null; + detail?: string[]; +} + +function readReceipts(rootReceipts: string): ReceiptLine[] { + const p = join(rootReceipts, "upgrade-receipts.jsonl"); + return readFileSync(p, "utf8") + .split("\n") + .filter((l) => l.trim().length > 0) + .map((l) => JSON.parse(l) as ReceiptLine); +} + +const lastReceipt = (rootReceipts: string): ReceiptLine => { + const all = readReceipts(rootReceipts); + expect(all.length).toBeGreaterThan(0); + return all[all.length - 1]; +}; + +const bySurface = (records: SurfaceRecord[], name: string): SurfaceRecord => + records.find((r) => r.surface === name)!; + +function receiptsDir(w: DoctorWorld): string { + // default receipts root derives from the injected --root-server (hermetic + // by construction); tests use it explicitly for readability + return join(w.server, "upgrade-receipts"); +} + +// ── the skills verb (the simplest full verb — exercises the shared plumbing) ─ + +describe("upgrade skills — stale staged set", () => { + test("tampered staged skill → upgraded: receipt complete, JSONL appended, staged set converged", async () => { + const w = buildDoctorWorld(); + // stage stale: tamper one staged skill's bytes (digest drift) + writeFileSync(join(w.staging, "skills", "beta", "SKILL.md"), "# beta\nDRIFTED\n"); + + const r = await upgradeVerb(verbArgs(w, "skills", ["--root-receipts", receiptsDir(w)])); + expect(r.code).toBe(0); + const receipt = r.json as ReceiptLine; + expect(receipt.outcome).toBe("upgraded"); + expect(receipt.verb).toBe("skills"); + expect(receipt.verification).toBe(true); + expect(receipt.receipt_version).toBe(1); + expect(receipt.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); + + // pre/post surface records: pre stale, post current — both in the receipt + expect(receipt.pre).not.toBeNull(); + expect(bySurface(receipt.pre!, "staged-skills").verdict).toBe("stale"); + expect(receipt.post).not.toBeNull(); + expect(bySurface(receipt.post!, "staged-skills").verdict).toBe("current"); + + // source digests present (the VSIX set + staged set identities) + expect(receipt.source_digests.vsix_set).toMatch(/^sha256:/); + expect(receipt.source_digests.staged_before).toMatch(/^sha256:/); + expect(receipt.source_digests.staged_after).toMatch(/^sha256:/); + + // the JSONL store carries the same receipt, appended + expect(lastReceipt(receiptsDir(w)).outcome).toBe("upgraded"); + + // the staged set ACTUALLY converged (byte-match restored) + const post = await surfaceInventory(ctxForWorld(w)); + expect(bySurface(post.surfaces, "staged-skills").verdict).toBe("current"); + cleanup(); + }); + + test("verification independence: receipt.post equals an independent doctor re-run, field for field", async () => { + const w = buildDoctorWorld(); + writeFileSync(join(w.staging, "skills", "alpha", "SKILL.md"), "# alpha\nDRIFTED\n"); + await upgradeVerb(verbArgs(w, "skills", ["--root-receipts", receiptsDir(w)])); + const receipt = lastReceipt(receiptsDir(w)); + const independent = await surfaceInventory(ctxForWorld(w)); + expect(receipt.post).toEqual([ + bySurface(independent.surfaces, "staged-skills"), + ]); + cleanup(); + }); + + test("missing staged skill → recreated by the re-stage", async () => { + const w = buildDoctorWorld(); + rmSync(join(w.staging, "skills", "beta"), { recursive: true, force: true }); + const r = await upgradeVerb(verbArgs(w, "skills", ["--root-receipts", receiptsDir(w)])); + expect(r.code).toBe(0); + expect((r.json as ReceiptLine).outcome).toBe("upgraded"); + expect(existsSync(join(w.staging, "skills", "beta", "SKILL.md"))).toBe(true); + cleanup(); + }); +}); + +describe("upgrade skills — idempotence (the AC fixture: stale → upgraded → no-op)", () => { + test("run 2 on a converged surface: exit 0, outcome no-op, staged dir digests byte-unchanged", async () => { + const w = buildDoctorWorld(); + writeFileSync(join(w.staging, "skills", "beta", "SKILL.md"), "# beta\nDRIFTED\n"); + const args = verbArgs(w, "skills", ["--root-receipts", receiptsDir(w)]); + + const run1 = await upgradeVerb(args); + expect(run1.code).toBe(0); + expect((run1.json as ReceiptLine).outcome).toBe("upgraded"); + + // harness digest of the ENUMERATED destination set: the staged skills dir + const after1 = await dirDigest(join(w.staging, "skills")); + + const run2 = await upgradeVerb(args); + expect(run2.code).toBe(0); + const receipt2 = run2.json as ReceiptLine; + expect(receipt2.outcome).toBe("no-op"); + expect(receipt2.verification).toBe(true); + expect(receipt2.pre).not.toBeNull(); + expect(bySurface(receipt2.pre!, "staged-skills").verdict).toBe("current"); + + // receipt store excluded from destination digests by definition; the + // staged set itself is byte-unchanged + const after2 = await dirDigest(join(w.staging, "skills")); + expect(after2).toBe(after1); + expect(lastReceipt(receiptsDir(w)).outcome).toBe("no-op"); + cleanup(); + }); +}); + +describe("upgrade skills — pre-flight gates", () => { + test("already-current surface → no-op WITHOUT executing (staged dir untouched)", async () => { + const w = buildDoctorWorld(); + const before = await dirDigest(join(w.staging, "skills")); + const r = await upgradeVerb(verbArgs(w, "skills", ["--root-receipts", receiptsDir(w)])); + expect(r.code).toBe(0); + const receipt = r.json as ReceiptLine; + expect(receipt.outcome).toBe("no-op"); + expect(await dirDigest(join(w.staging, "skills"))).toBe(before); + cleanup(); + }); + + test("unknown source (no VSIX skills set) → aborted-unknown, exit 1, receipt recorded", async () => { + const w = buildDoctorWorld(); + rmSync(w.vscext, { recursive: true, force: true }); + const before = await dirDigest(join(w.staging, "skills")); + const r = await upgradeVerb(verbArgs(w, "skills", ["--root-receipts", receiptsDir(w)])); + expect(r.code).toBe(1); + const receipt = r.json as ReceiptLine; + expect(receipt.outcome).toBe("aborted-unknown"); + expect(receipt.verification).toBeNull(); + expect(receipt.post).toBeNull(); + expect(lastReceipt(receiptsDir(w)).outcome).toBe("aborted-unknown"); + expect(await dirDigest(join(w.staging, "skills"))).toBe(before); // nothing executed + cleanup(); + }); + + test("internal-only staged skills (extras) are PRESERVED: no byte change, honest no-op, post records the drift", async () => { + // the live staged set deliberately carries internal-only skills the VSIX + // never ships (stage-internal-skills.sh) — the verb re-stages the VSIX + // set without deleting them (the server script's no-delete re-stage). + // Doctor flags extras as drift; the verb converges the SHAREABLE set and + // reports the residual honestly instead of deleting fleet skills. + const w = buildDoctorWorld(); + mkdirSync(join(w.staging, "skills", "fleet"), { recursive: true }); + writeFileSync(join(w.staging, "skills", "fleet", "SKILL.md"), "# fleet\ninternal-only\n"); + const before = await dirDigest(join(w.staging, "skills")); + + const r = await upgradeVerb(verbArgs(w, "skills", ["--root-receipts", receiptsDir(w)])); + expect(r.code).toBe(0); + const receipt = r.json as ReceiptLine; + expect(receipt.outcome).toBe("no-op"); // the re-stage changed nothing: VSIX set already converged + expect(existsSync(join(w.staging, "skills", "fleet"))).toBe(true); // preserved, never deleted + expect(await dirDigest(join(w.staging, "skills"))).toBe(before); + // the post record does not lie: doctor still sees the extra as drift + expect(bySurface(receipt.post!, "staged-skills").verdict).toBe("stale"); + expect(bySurface(receipt.post!, "staged-skills").evidence.join(" ")).toMatch(/extra in staged set/); + cleanup(); + }); +}); + +// ── the shared lock (flock semantics: crash-release via PID liveness) ─────── + +describe("upgrade lock — single operator", () => { + test("a held lock → aborted-locked, exit 1, no execution", async () => { + const w = buildDoctorWorld(); + const rr = receiptsDir(w); + const lock = await acquireUpgradeLock(rr); + expect(lock.acquired).toBe(true); + try { + const r = await upgradeVerb(verbArgs(w, "skills", ["--root-receipts", rr])); + expect(r.code).toBe(1); + const receipt = r.json as ReceiptLine; + expect(receipt.outcome).toBe("aborted-locked"); + expect(receipt.pre).toBeNull(); // refused before even probing + expect(lastReceipt(rr).outcome).toBe("aborted-locked"); + } finally { + await lock.release(); + } + // after release the same verb proceeds normally + const r2 = await upgradeVerb(verbArgs(w, "skills", ["--root-receipts", rr])); + expect((r2.json as ReceiptLine).outcome).toBe("no-op"); + cleanup(); + }); + + test("a stale lock (dead holder) is stolen — crash-release is free", async () => { + const w = buildDoctorWorld(); + const rr = receiptsDir(w); + // a holder that died: spawn a process, let it exit, forge its lockfile + const dead = execFileSync("sh", ["-c", "echo $$; exit 0"], { encoding: "utf8" }).trim(); + mkdirSync(rr, { recursive: true }); + const { writeFileSync: wf } = await import("node:fs"); + wf(join(rr, ".lock"), `${dead}\n`); + const r = await upgradeVerb(verbArgs(w, "skills", ["--root-receipts", rr])); + expect(r.code).toBe(0); + expect((r.json as ReceiptLine).outcome).toBe("no-op"); // proceeded past the stolen lock + cleanup(); + }); +}); + +// ── usage surface ──────────────────────────────────────────────────────────── + +describe("upgrade verb — usage errors", () => { + test("no surface → usage error 64", async () => { + const r = await upgradeVerb([]); + expect(r.code).toBe(64); + }); + test("unknown surface → usage error 64", async () => { + const r = await upgradeVerb(["sidecar-bin"]); + expect(r.code).toBe(64); + }); + test("unknown flag → usage error 64", async () => { + const r = await upgradeVerb(["skills", "--frobnicate"]); + expect(r.code).toBe(64); + }); +});