mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 11:51:37 -10:00
Merge OPR.0.3.4.4 restore plan read-only honest async
This commit is contained in:
commit
2a1ea97946
7 files changed
+580
-23
No files matched your search
@@ -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}`);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user