Merge OPR.0.3.4.4 restore plan read-only honest async

This commit is contained in:
mvschwarz committed 2026-06-14 16:42:30 -07:00
commit 2a1ea97946
7 files changed
+580 -23

No files matched your search

+69 -18
View File
@@ -1,6 +1,6 @@
import nodePath from "node:path";
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { DaemonClient, DaemonConnectionError } from "../client.js";
import { getDaemonStatus, getDaemonUrl, startDaemon, type LifecycleDeps } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
@@ -302,15 +302,38 @@ Examples:
}
}
const res = await client.post<Record<string, unknown>>("/api/up", {
sourceRef,
plan: opts.plan ?? false,
autoApprove: opts.yes ?? false,
cwdOverride: opts.cwd ? nodePath.resolve(opts.cwd) : defaultLibraryCwdOverride,
targetRoot,
// OPR.0.3.4.2 — operation B opt-in seats (deliberate fresh-prime).
freshLogicalIds: opts.fresh,
}, opts.plan ? undefined : { timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS });
// OPR.0.3.4.4 — honest async: a client timeout / connection loss in
// APPLY mode does NOT mean the operation failed; the daemon may still
// be processing. Report in-progress/unknown with a verify command,
// never a bare connection failure (the false-failure that caused the
// outage's wrong next move). No operation id is surfaced client-side
// on timeout today, so the honest message is the MVP floor.
const printHonestTimeout = (err: DaemonConnectionError): void => {
console.error(`The CLI timed out waiting for the daemon, but the operation may STILL BE IN PROGRESS.`);
console.error(`This does not mean the operation failed; the daemon may still be processing it.`);
console.error(`Verify the actual state with: rig ps`);
console.error(`(underlying: ${err.message})`);
process.exitCode = 1;
};
let res: { status: number; data: Record<string, unknown> };
try {
res = await client.post<Record<string, unknown>>("/api/up", {
sourceRef,
plan: opts.plan ?? false,
autoApprove: opts.yes ?? false,
cwdOverride: opts.cwd ? nodePath.resolve(opts.cwd) : defaultLibraryCwdOverride,
targetRoot,
// OPR.0.3.4.2 — operation B opt-in seats (deliberate fresh-prime).
freshLogicalIds: opts.fresh,
}, opts.plan ? undefined : { timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS });
} catch (err) {
if (err instanceof DaemonConnectionError && !opts.plan) {
printHonestTimeout(err);
return;
}
throw err;
}
if (opts.json) {
console.log(JSON.stringify(res.data));
@@ -351,6 +374,25 @@ Examples:
// Success output
const resStatus = res.data["status"] as string;
// OPR.0.3.4.4 — read-only restore plan preview (`--plan` on the
// existing-rig path). Renders intended per-seat actions; nothing ran.
if (resStatus === "plan" && res.data["mode"] === "restore") {
const planRigName = res.data["rigName"] as string | undefined;
const planSnapshot = res.data["snapshot"] as { id: string; kind: string; createdAt: string } | null;
console.log(`Plan: restore rig "${planRigName ?? source}" (read-only preview)`);
if (planSnapshot) {
console.log(`Snapshot: ${planSnapshot.id} (kind=${planSnapshot.kind}, captured ${planSnapshot.createdAt})`);
} else if (res.data["wouldCaptureCurrentState"] === true) {
console.log(`Snapshot: none usable — apply would first capture current DB state as an auto-rehydrate snapshot.`);
}
const planNodes = (res.data["nodes"] as Array<{ logicalId: string; intendedAction: string; reason?: string }>) ?? [];
for (const n of planNodes) {
console.log(` ${n.logicalId}: ${n.intendedAction}${n.reason ? ` — ${n.reason}` : ""}`);
}
console.log("No changes made.");
return;
}
if (resStatus === "restored") {
// Existing-rig power-on handoff
const rigId = res.data["rigId"] as string;
@@ -403,14 +445,23 @@ Examples:
if (yes) accepted.push(n.logicalId);
}
if (accepted.length > 0) {
const freshRes = await client.post<Record<string, unknown>>("/api/up", {
sourceRef,
plan: false,
autoApprove: opts.yes ?? false,
cwdOverride: opts.cwd ? nodePath.resolve(opts.cwd) : defaultLibraryCwdOverride,
targetRoot,
freshLogicalIds: accepted,
}, { timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS });
let freshRes: { status: number; data: Record<string, unknown> };
try {
freshRes = await client.post<Record<string, unknown>>("/api/up", {
sourceRef,
plan: false,
autoApprove: opts.yes ?? false,
cwdOverride: opts.cwd ? nodePath.resolve(opts.cwd) : defaultLibraryCwdOverride,
targetRoot,
freshLogicalIds: accepted,
}, { timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS });
} catch (err) {
if (err instanceof DaemonConnectionError) {
printHonestTimeout(err);
return;
}
throw err;
}
const freshNodes = (freshRes.data["nodes"] as Array<{ logicalId: string; status: string }>) ?? [];
for (const n of freshNodes.filter((fn) => accepted.includes(fn.logicalId))) {
console.log(` ${n.logicalId}: ${n.status}`);
+81 -1
View File
@@ -7,7 +7,7 @@ import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vites
import http from "node:http";
import { Command } from "commander";
import { upCommand } from "../src/commands/up.js";
import { DaemonClient } from "../src/client.js";
import { DaemonClient, DaemonConnectionError } from "../src/client.js";
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
function mockLifecycleDeps(): LifecycleDeps {
@@ -166,6 +166,86 @@ describe("rig up --existing — resume-original default (OPR.0.3.4.2)", () => {
expect(exitCode).toBe(1);
});
// OPR.0.3.4.4 — plan-restore preview render + honest-async timeout.
it("--plan restore preview renders per-seat intended actions and 'No changes made.'", async () => {
respond = () => ({
status: "plan",
mode: "restore",
rigId: "rig-1",
rigName: "myrig",
snapshot: { id: "snap-9", kind: "auto-pre-down", createdAt: "2026-06-11T00:00:00Z" },
wouldCaptureCurrentState: false,
nodes: [
{ logicalId: "dev.impl", intendedAction: "resume-original" },
{ logicalId: "dev.qa", intendedAction: "awaiting-decision", reason: "resume source 'claude_name' recorded but no token available — apply would stop and ask (zero session)" },
],
mutated: false,
});
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "myrig", "--existing", "--plan"]);
});
const output = logs.join("\n");
expect(output).toContain('Plan: restore rig "myrig"');
expect(output).toContain("snap-9");
expect(output).toContain("dev.impl: resume-original");
expect(output).toContain("dev.qa: awaiting-decision");
expect(output).toContain("no token available");
expect(output).toContain("No changes made.");
expect(exitCode).toBeUndefined();
// The plan flag reached the daemon body.
expect(upBodies).toHaveLength(1);
expect(upBodies[0]!.plan).toBe(true);
});
it("--plan --fresh posts BOTH plan:true and freshLogicalIds, and renders the fresh-primed preview", async () => {
respond = (body) => ({
status: "plan",
mode: "restore",
rigId: "rig-1",
rigName: "myrig",
snapshot: { id: "snap-9", kind: "auto-pre-down", createdAt: "2026-06-11T00:00:00Z" },
wouldCaptureCurrentState: false,
nodes: Array.isArray(body.freshLogicalIds) && (body.freshLogicalIds as string[]).includes("dev.impl")
? [{ logicalId: "dev.impl", intendedAction: "fresh-primed", reason: "listed in --fresh — apply would deliberately skip the resume (operation B)" }]
: [{ logicalId: "dev.impl", intendedAction: "resume-original" }],
mutated: false,
});
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "myrig", "--existing", "--plan", "--fresh", "dev.impl"]);
});
expect(upBodies).toHaveLength(1);
expect(upBodies[0]!.plan).toBe(true);
expect(upBodies[0]!.freshLogicalIds).toEqual(["dev.impl"]);
const output = logs.join("\n");
expect(output).toContain("dev.impl: fresh-primed");
expect(output).toContain("No changes made.");
});
it("HONEST TIMEOUT (apply mode): DaemonConnectionError renders in-progress/unknown + verify command, never a bare failure", async () => {
const throwingClient = {
get: async () => { throw new DaemonConnectionError("request timed out after 120000ms"); },
post: async () => { throw new DaemonConnectionError("request timed out after 120000ms"); },
} as unknown as DaemonClient;
const customDeps = { ...deps(), clientFactory: () => throwingClient };
const prog = new Command();
prog.exitOverride();
prog.addCommand(upCommand(customDeps));
const { logs, exitCode } = await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "up", "myrig", "--existing"]);
});
const output = logs.join("\n");
// Honest in-progress/unknown framing, not a false "failed".
expect(output).toContain("may still be processing");
expect(output).toContain("does not mean the operation failed");
expect(output).toContain("rig ps");
expect(output).not.toContain("Up failed");
expect(exitCode).toBe(1);
});
it("distinct naming: all five vocabulary terms render distinctly per seat", async () => {
respond = () => ({
status: "restored", rigId: "rig-1", rigName: "myrig", rigResult: "partially_restored",
@@ -0,0 +1,133 @@
// OPR.0.3.4.4 — read-only restore plan preview.
//
// `rig up --existing <rig> --plan` advertised "preview without executing" but
// the rig_name path bypassed the plan gate and MUTATED (recreated sessions,
// flipped detached->running, replaced manually-resumed panes). This module is
// the shared read-only preview both restore routes (`/api/up` rig_name and the
// Explorer `/api/rigs/:id/up`) return when plan=true: it computes the INTENDED
// per-seat restore action from snapshot/session data and touches NOTHING — no
// restoreOrchestrator.restore(), no session create/kill/replace/resume, no
// snapshot capture (the auto-rehydrate capture is itself a mutation and is
// only reported as would-happen), no projection writes.
import type Database from "better-sqlite3";
import type { RigWithRelations, Snapshot } from "./types.js";
export interface RestorePlanPreviewNode {
logicalId: string;
/** The slice-02 vocabulary, as a forecast: what apply mode WOULD do. */
intendedAction: "resume-original" | "fresh-primed" | "awaiting-decision";
reason?: string;
}
export interface RestorePlanPreview {
status: "plan";
mode: "restore";
rigId: string;
rigName: string;
/** The snapshot apply mode would restore from (null when it would capture
* a current-state auto-rehydrate snapshot instead). */
snapshot: { id: string; kind: string; createdAt: string } | null;
/** True when apply mode would first CAPTURE an auto-rehydrate snapshot —
* reported, not performed (plan mode performs zero writes). */
wouldCaptureCurrentState: boolean;
nodes: RestorePlanPreviewNode[];
/** Always false — the contract this preview exists to keep. */
mutated: false;
}
export interface PreviewSessionRow {
nodeId: string;
restorePolicy: string | null;
resumeType: string | null;
resumeToken: string | null;
/** ULID-ordered id so the newest session per node wins (matches the
* orchestrator's latest-session selection). */
id: string;
}
/** Forecast one seat's restore action — mirrors the orchestrator's pre-launch
* classification (OPR.0.3.4.2) without touching anything. A fresh-listed
* seat (operation B, `--fresh <seat>`) forecasts `fresh-primed` BEFORE any
* resume-token logic, exactly as apply mode deliberately skips the resume. */
function intendedActionFor(rows: PreviewSessionRow[], freshRequested: boolean): { intendedAction: RestorePlanPreviewNode["intendedAction"]; reason?: string } {
if (freshRequested) {
return {
intendedAction: "fresh-primed",
reason: "listed in --fresh — apply would deliberately skip the resume (operation B)",
};
}
const latest = rows.length > 0 ? rows.reduce((a, b) => (b.id > a.id ? b : a)) : null;
const policy = latest?.restorePolicy ?? "resume_if_possible";
const sourceRecorded = !!latest?.resumeType && latest.resumeType !== "none";
if (policy === "resume_if_possible" && sourceRecorded && latest?.resumeToken) {
return { intendedAction: "resume-original" };
}
if (policy === "resume_if_possible" && sourceRecorded && !latest?.resumeToken) {
return {
intendedAction: "awaiting-decision",
reason: `resume source '${latest?.resumeType}' recorded but no token available — apply would stop and ask (zero session)`,
};
}
return { intendedAction: "fresh-primed" };
}
/** Gather the session rows the preview forecasts from — the snapshot's
* captured sessions when one exists, otherwise the live rows an
* auto-rehydrate capture WOULD snapshot (read-only SELECT; the capture
* itself is a mutation and is never performed here). */
export function collectPreviewSessionRows(
db: Database.Database,
rig: RigWithRelations,
snapshot: Snapshot | null,
): PreviewSessionRow[] {
if (snapshot) {
return (snapshot.data.sessions ?? []).map((s) => ({
nodeId: s.nodeId,
restorePolicy: s.restorePolicy ?? null,
resumeType: s.resumeType ?? null,
resumeToken: s.resumeToken ?? null,
id: s.id,
}));
}
const nodeIds = rig.nodes.map((n) => n.id);
if (nodeIds.length === 0) return [];
const placeholders = nodeIds.map(() => "?").join(",");
const rows = db.prepare(
`SELECT id, node_id, restore_policy, resume_type, resume_token FROM sessions WHERE node_id IN (${placeholders})`
).all(...nodeIds) as Array<{ id: string; node_id: string; restore_policy: string | null; resume_type: string | null; resume_token: string | null }>;
return rows.map((r) => ({
nodeId: r.node_id,
restorePolicy: r.restore_policy,
resumeType: r.resume_type,
resumeToken: r.resume_token,
id: r.id,
}));
}
export function buildRestorePlanPreview(
rig: RigWithRelations,
snapshot: Snapshot | null,
sessionRows: PreviewSessionRow[],
freshLogicalIds?: string[],
): RestorePlanPreview {
const byNode = new Map<string, PreviewSessionRow[]>();
for (const row of sessionRows) {
(byNode.get(row.nodeId) ?? byNode.set(row.nodeId, []).get(row.nodeId)!).push(row);
}
const nodes: RestorePlanPreviewNode[] = rig.nodes.map((node) => {
const freshRequested = freshLogicalIds?.includes(node.logicalId) ?? false;
const { intendedAction, reason } = intendedActionFor(byNode.get(node.id) ?? [], freshRequested);
return { logicalId: node.logicalId, intendedAction, ...(reason ? { reason } : {}) };
});
return {
status: "plan",
mode: "restore",
rigId: rig.rig.id,
rigName: rig.rig.name,
snapshot: snapshot ? { id: snapshot.id, kind: snapshot.kind, createdAt: snapshot.createdAt } : null,
wouldCaptureCurrentState: snapshot === null,
nodes,
mutated: false,
};
}
+15
View File
@@ -18,6 +18,7 @@ import type { AgentActivityStore } from "../domain/agent-activity-store.js";
import type { SeatActivityService } from "../domain/seat-activity-service.js";
import { deriveRigLifecycleState } from "../domain/ps-projection.js";
import { assessCurrentStateRehydrateEligibility } from "../domain/rehydrate-eligibility.js";
import { buildRestorePlanPreview, collectPreviewSessionRows } from "../domain/restore-plan-preview.js";
import type { ContextUsageStore } from "../domain/context-usage-store.js";
import type { Pod, ExpansionPodFragment } from "../domain/types.js";
import type { RigExpansionService } from "../domain/rig-expansion-service.js";
@@ -476,6 +477,11 @@ rigsRoutes.post("/:id/up", async (c) => {
const rig = repo.getRig(rigId);
if (!rig) return c.json({ error: `Rig "${rigId}" not found. List rigs with: rig ps` }, 404);
// OPR.0.3.4.4 — this independent Explorer restore route previously parsed
// NO body, so plan:true was silently ignored and the route always mutated.
const body: Record<string, unknown> = await c.req.json().catch(() => ({}));
const plan = body["plan"] === true;
const snapshotRepo = c.get("snapshotRepo" as never) as SnapshotRepository;
const snapshotCapture = c.get("snapshotCapture" as never) as SnapshotCapture;
let snapshot = snapshotRepo.findLatestRestoreUsable(rigId);
@@ -489,6 +495,15 @@ rigsRoutes.post("/:id/up", async (c) => {
blockers: eligibility.blockers,
}, 404);
}
}
// OPR.0.3.4.4 — read-only plan gate BEFORE the auto-rehydrate capture
// (the capture is itself a mutation) and before restoreOrch.restore().
if (plan) {
return c.json(buildRestorePlanPreview(rig, snapshot ?? null, collectPreviewSessionRows(repo.db, rig, snapshot ?? null)), 200);
}
if (!snapshot) {
snapshot = snapshotCapture.captureSnapshot(rigId, "auto-rehydrate");
capturedCurrentState = true;
}
+16 -2
View File
@@ -9,6 +9,7 @@ import type { SnapshotRepository } from "../domain/snapshot-repository.js";
import type { SnapshotCapture } from "../domain/snapshot-capture.js";
import type { RestoreOrchestrator } from "../domain/restore-orchestrator.js";
import { assessCurrentStateRehydrateEligibility } from "../domain/rehydrate-eligibility.js";
import { buildRestorePlanPreview, collectPreviewSessionRows } from "../domain/restore-plan-preview.js";
export const upRoutes = new Hono();
@@ -87,7 +88,7 @@ function getDeps(c: { get: (key: string) => unknown }) {
*
* Shared helper used by both /api/up (rig_name) and /api/rigs/:rigId/up (Explorer).
*/
async function restoreByRigId(rigId: string, rigName: string | null, deps: ReturnType<typeof getDeps>, c: { json: (data: unknown, status?: number) => Response }, freshLogicalIds?: string[]) {
async function restoreByRigId(rigId: string, rigName: string | null, deps: ReturnType<typeof getDeps>, c: { json: (data: unknown, status?: number) => Response }, freshLogicalIds?: string[], plan?: boolean) {
const { snapshotRepo, restoreOrchestrator } = deps;
const rig = deps.rigRepo.getRig(rigId);
@@ -106,6 +107,19 @@ async function restoreByRigId(rigId: string, rigName: string | null, deps: Retur
blockers: eligibility.blockers,
}, 404);
}
}
// OPR.0.3.4.4 — read-only plan gate, BEFORE any restore mutation. The
// rig_name path previously early-returned past the bootstrap plan gate, so
// `rig up --existing <rig> --plan` mutated (the outage-01 bypass). Plan
// mode never reaches restoreOrchestrator.restore(), and the auto-rehydrate
// snapshot capture (itself a mutation) is reported as would-happen, never
// performed.
if (plan) {
return c.json(buildRestorePlanPreview(rig, snapshot ?? null, collectPreviewSessionRows(snapshotRepo.db, rig, snapshot ?? null), freshLogicalIds), 200);
}
if (!snapshot) {
snapshot = deps.snapshotCapture.captureSnapshot(rigId, "auto-rehydrate");
capturedCurrentState = true;
}
@@ -193,7 +207,7 @@ upRoutes.post("/", async (c) => {
const freshLogicalIds = Array.isArray(body["freshLogicalIds"])
? (body["freshLogicalIds"] as unknown[]).filter((v): v is string => typeof v === "string")
: undefined;
return restoreByRigId(rigs[0]!.id, sourceRef, getDeps(c), c, freshLogicalIds) as any;
return restoreByRigId(rigs[0]!.id, sourceRef, getDeps(c), c, freshLogicalIds, plan) as any;
}
// File-based: resolve path now
+82 -1
View File
@@ -1,10 +1,11 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import type { Hono } from "hono";
import type Database from "better-sqlite3";
import type { RigRepository } from "../src/domain/rig-repository.js";
import type { SessionRegistry } from "../src/domain/session-registry.js";
import type { SnapshotRepository } from "../src/domain/snapshot-repository.js";
import type { SnapshotCapture } from "../src/domain/snapshot-capture.js";
import type { RestoreOrchestrator } from "../src/domain/restore-orchestrator.js";
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
function insertStartupContextRow(db: Database.Database, nodeId: string) {
@@ -20,6 +21,7 @@ describe("Rig CRUD routes", () => {
let sessionRegistry: SessionRegistry;
let snapshotRepo: SnapshotRepository;
let snapshotCapture: SnapshotCapture;
let restoreOrchestrator: RestoreOrchestrator;
beforeEach(() => {
db = createFullTestDb();
@@ -29,6 +31,7 @@ describe("Rig CRUD routes", () => {
sessionRegistry = setup.sessionRegistry;
snapshotRepo = setup.snapshotRepo;
snapshotCapture = setup.snapshotCapture;
restoreOrchestrator = setup.restoreOrchestrator;
});
afterEach(() => {
@@ -628,4 +631,82 @@ describe("Rig CRUD routes", () => {
expect(body.error).toContain("restore-usable");
expect(body.error).not.toContain("auto-pre-down snapshot");
});
// OPR.0.3.4.4 — the Explorer restore route is INDEPENDENT of /api/up (it
// parsed no body at all), so plan:true was silently ignored and the route
// always mutated. These tests pin the daemon-level read-only gate here too.
describe("OPR.0.3.4.4 --plan read-only restore gate (Explorer /:id/up)", () => {
it("plan:true returns a read-only preview: restore() NOT called, zero session/snapshot mutation", async () => {
const rig = repo.createRig("explorer-plan-rig");
const node = repo.addNode(rig.id, "worker", { role: "worker", runtime: "claude-code" });
const sess = sessionRegistry.registerSession(node.id, "worker@explorer-plan-rig");
db.prepare("UPDATE sessions SET resume_type = ?, resume_token = ?, status = ? WHERE id = ?")
.run("claude_name", "tok-1", "detached", sess.id);
snapshotCapture.captureSnapshot(rig.id, "auto-pre-down");
const restoreSpy = vi.spyOn(restoreOrchestrator, "restore");
const sessionsBefore = db.prepare("SELECT * FROM sessions ORDER BY id").all();
const snapshotsBefore = db.prepare("SELECT * FROM snapshots ORDER BY id").all();
const bindingsBefore = db.prepare("SELECT * FROM bindings ORDER BY id").all();
const res = await app.request(`/api/rigs/${rig.id}/up`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ plan: true }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe("plan");
expect(body.mode).toBe("restore");
expect(body.mutated).toBe(false);
expect(body.nodes).toHaveLength(1);
expect(body.nodes[0].intendedAction).toBe("resume-original");
// THE gate: no restore mutation of any kind.
expect(restoreSpy).not.toHaveBeenCalled();
expect(db.prepare("SELECT * FROM sessions ORDER BY id").all()).toEqual(sessionsBefore);
expect(db.prepare("SELECT * FROM snapshots ORDER BY id").all()).toEqual(snapshotsBefore);
expect(db.prepare("SELECT * FROM bindings ORDER BY id").all()).toEqual(bindingsBefore);
});
it("plan:true with no usable snapshot reports wouldCaptureCurrentState WITHOUT capturing", async () => {
const rig = repo.createRig("explorer-plan-rehydrate");
const node = repo.addNode(rig.id, "dev.impl", { runtime: "claude-code" });
const session = sessionRegistry.registerSession(node.id, "dev-impl@explorer-plan-rehydrate");
sessionRegistry.updateStatus(session.id, "stopped");
sessionRegistry.updateStartupStatus(session.id, "failed");
insertStartupContextRow(db, node.id);
const restoreSpy = vi.spyOn(restoreOrchestrator, "restore");
const res = await app.request(`/api/rigs/${rig.id}/up`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ plan: true }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.snapshot).toBeNull();
expect(body.wouldCaptureCurrentState).toBe(true);
const rows = db.prepare("SELECT COUNT(*) as c FROM snapshots WHERE rig_id = ?").get(rig.id) as { c: number };
expect(rows.c).toBe(0);
expect(restoreSpy).not.toHaveBeenCalled();
});
it("APPLY regression: body without plan still restores; bodyless POST unchanged", async () => {
const rig = repo.createRig("explorer-apply-rig");
repo.addNode(rig.id, "worker", { role: "worker" });
snapshotCapture.captureSnapshot(rig.id, "auto-pre-down");
const restoreSpy = vi.spyOn(restoreOrchestrator, "restore");
const res = await app.request(`/api/rigs/${rig.id}/up`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ plan: false }),
});
expect(res.status).toBe(200);
expect((await res.json()).status).toBe("restored");
expect(restoreSpy).toHaveBeenCalledTimes(1);
});
});
});
+184 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
@@ -6,6 +6,7 @@ import type Database from "better-sqlite3";
import type { RigRepository } from "../src/domain/rig-repository.js";
import type { SessionRegistry } from "../src/domain/session-registry.js";
import type { SnapshotCapture } from "../src/domain/snapshot-capture.js";
import type { RestoreOrchestrator } from "../src/domain/restore-orchestrator.js";
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
import { buildAttentionResponse } from "../src/routes/up.js";
@@ -32,6 +33,7 @@ describe("Up API route", () => {
let rigRepo: RigRepository;
let sessionRegistry: SessionRegistry;
let snapshotCapture: SnapshotCapture;
let restoreOrchestrator: RestoreOrchestrator;
beforeEach(() => {
db = createFullTestDb();
@@ -42,6 +44,7 @@ describe("Up API route", () => {
rigRepo = setup.rigRepo;
sessionRegistry = setup.sessionRegistry;
snapshotCapture = setup.snapshotCapture;
restoreOrchestrator = setup.restoreOrchestrator;
});
afterEach(() => {
@@ -220,6 +223,186 @@ describe("Up API route", () => {
});
});
// OPR.0.3.4.4 — `--plan` must be READ-ONLY on the rig_name restore path.
// The rig_name branch early-returned BEFORE the bootstrap plan gate, so
// `rig up --existing <rig> --plan` mutated (recreated sessions, flipped
// detached->running). These tests pin the daemon-level gate.
describe("OPR.0.3.4.4 --plan read-only restore gate (rig_name path)", () => {
function snapshotOf(db_: Database.Database, table: string): unknown[] {
return db_.prepare(`SELECT * FROM ${table} ORDER BY id`).all();
}
it("plan:true returns a read-only preview: restore() NOT called, zero session/snapshot mutation", async () => {
const rig = rigRepo.createRig("plan-ro-rig");
const node = rigRepo.addNode(rig.id, "worker", { role: "worker", runtime: "claude-code" });
const sess = sessionRegistry.registerSession(node.id, "worker@plan-ro-rig");
db.prepare("UPDATE sessions SET resume_type = ?, resume_token = ?, status = ? WHERE id = ?")
.run("claude_name", "tok-1", "detached", sess.id);
snapshotCapture.captureSnapshot(rig.id, "auto-pre-down");
const restoreSpy = vi.spyOn(restoreOrchestrator, "restore");
const sessionsBefore = snapshotOf(db, "sessions");
const snapshotsBefore = snapshotOf(db, "snapshots");
const bindingsBefore = snapshotOf(db, "bindings");
const res = await app.request("/api/up", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceRef: "plan-ro-rig", plan: true }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe("plan");
expect(body.mode).toBe("restore");
expect(body.mutated).toBe(false);
// Useful preview: per-seat intended action, derived from resume state.
expect(body.nodes).toHaveLength(1);
expect(body.nodes[0].logicalId).toBe("worker");
expect(body.nodes[0].intendedAction).toBe("resume-original");
expect(body.snapshot).not.toBeNull();
expect(body.wouldCaptureCurrentState).toBe(false);
// THE gate: no restore mutation of any kind.
expect(restoreSpy).not.toHaveBeenCalled();
expect(snapshotOf(db, "sessions")).toEqual(sessionsBefore);
expect(snapshotOf(db, "snapshots")).toEqual(snapshotsBefore);
expect(snapshotOf(db, "bindings")).toEqual(bindingsBefore);
});
it("plan:true previews awaiting-decision for a recorded-source/no-token seat (forecasts the slice-02 stop)", async () => {
const rig = rigRepo.createRig("plan-ad-rig");
const node = rigRepo.addNode(rig.id, "worker", { role: "worker", runtime: "claude-code" });
const sess = sessionRegistry.registerSession(node.id, "worker@plan-ad-rig");
db.prepare("UPDATE sessions SET resume_type = ?, resume_token = NULL, status = ? WHERE id = ?")
.run("claude_name", "detached", sess.id);
snapshotCapture.captureSnapshot(rig.id, "auto-pre-down");
const res = await app.request("/api/up", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceRef: "plan-ad-rig", plan: true }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.nodes[0].intendedAction).toBe("awaiting-decision");
expect(body.nodes[0].reason).toContain("no token");
});
it("plan:true + freshLogicalIds previews fresh-primed for the listed RESUMABLE seat (operation-B honesty), zero mutation", async () => {
// Guard BLOCKING a23009d8: a token-resumable seat listed in --fresh
// must preview what apply would DO (deliberate fresh-prime), not what
// the token alone suggests (resume-original).
const rig = rigRepo.createRig("plan-fresh-rig");
const node = rigRepo.addNode(rig.id, "worker", { role: "worker", runtime: "claude-code" });
const sess = sessionRegistry.registerSession(node.id, "worker@plan-fresh-rig");
db.prepare("UPDATE sessions SET resume_type = ?, resume_token = ?, status = ? WHERE id = ?")
.run("claude_name", "tok-1", "detached", sess.id);
snapshotCapture.captureSnapshot(rig.id, "auto-pre-down");
const restoreSpy = vi.spyOn(restoreOrchestrator, "restore");
const sessionsBefore = snapshotOf(db, "sessions");
const snapshotsBefore = snapshotOf(db, "snapshots");
const bindingsBefore = snapshotOf(db, "bindings");
const res = await app.request("/api/up", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceRef: "plan-fresh-rig", plan: true, freshLogicalIds: ["worker"] }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe("plan");
expect(body.nodes[0].logicalId).toBe("worker");
expect(body.nodes[0].intendedAction).toBe("fresh-primed");
expect(body.nodes[0].reason).toContain("--fresh");
expect(restoreSpy).not.toHaveBeenCalled();
expect(snapshotOf(db, "sessions")).toEqual(sessionsBefore);
expect(snapshotOf(db, "snapshots")).toEqual(snapshotsBefore);
expect(snapshotOf(db, "bindings")).toEqual(bindingsBefore);
});
it("plan:true + freshLogicalIds leaves UNLISTED seats forecast from their resume state", async () => {
const rig = rigRepo.createRig("plan-fresh-mixed");
const nodeA = rigRepo.addNode(rig.id, "worker-a", { role: "worker", runtime: "claude-code" });
const nodeB = rigRepo.addNode(rig.id, "worker-b", { role: "worker", runtime: "claude-code" });
for (const [node, name] of [[nodeA, "worker-a"], [nodeB, "worker-b"]] as const) {
const sess = sessionRegistry.registerSession(node.id, `${name}@plan-fresh-mixed`);
db.prepare("UPDATE sessions SET resume_type = ?, resume_token = ?, status = ? WHERE id = ?")
.run("claude_name", "tok-1", "detached", sess.id);
}
snapshotCapture.captureSnapshot(rig.id, "auto-pre-down");
const res = await app.request("/api/up", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceRef: "plan-fresh-mixed", plan: true, freshLogicalIds: ["worker-b"] }),
});
expect(res.status).toBe(200);
const body = await res.json();
const byId = Object.fromEntries((body.nodes as Array<{ logicalId: string; intendedAction: string }>).map((n) => [n.logicalId, n.intendedAction]));
expect(byId["worker-a"]).toBe("resume-original");
expect(byId["worker-b"]).toBe("fresh-primed");
});
it("plan:true with no usable snapshot reports wouldCaptureCurrentState WITHOUT capturing", async () => {
const rig = rigRepo.createRig("plan-rehydrate-rig");
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: "claude-code" });
const session = sessionRegistry.registerSession(node.id, "dev-impl@plan-rehydrate-rig");
sessionRegistry.updateStatus(session.id, "stopped");
sessionRegistry.updateStartupStatus(session.id, "failed");
insertStartupContextRow(db, node.id);
const restoreSpy = vi.spyOn(restoreOrchestrator, "restore");
const res = await app.request("/api/up", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceRef: "plan-rehydrate-rig", plan: true }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe("plan");
expect(body.snapshot).toBeNull();
expect(body.wouldCaptureCurrentState).toBe(true);
// The auto-rehydrate capture is a mutation; plan must NOT perform it.
const rows = db.prepare("SELECT COUNT(*) as c FROM snapshots WHERE rig_id = ?").get(rig.id) as { c: number };
expect(rows.c).toBe(0);
expect(restoreSpy).not.toHaveBeenCalled();
});
it("plan:true with no usable snapshot and ineligible state still 404s (same as apply)", async () => {
rigRepo.createRig("plan-no-snap");
const res = await app.request("/api/up", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceRef: "plan-no-snap", plan: true }),
});
expect(res.status).toBe(404);
expect((await res.json()).code).toBe("no_snapshot");
});
it("APPLY regression: plan absent still restores (restore() called once)", async () => {
const rig = rigRepo.createRig("plan-apply-rig");
rigRepo.addNode(rig.id, "worker", { role: "worker" });
snapshotCapture.captureSnapshot(rig.id, "auto-pre-down");
const restoreSpy = vi.spyOn(restoreOrchestrator, "restore");
const res = await app.request("/api/up", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceRef: "plan-apply-rig" }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe("restored");
expect(restoreSpy).toHaveBeenCalledTimes(1);
});
});
// Agent Starter v1 vertical M2 R2 — POST /api/up end-to-end proof for starter_ref.
//
// These tests exercise the full apply path through the route. The positive