fix(cli,daemon): rig start uses id-based route for both preview AND apply (OPR.0.3.4.1 rev1 BLOCKING)

Selection and apply were name-grounded (rigName + POST /api/up) while
preview was id-grounded (POST /api/rigs/:id/up). Same-name rigs could
be listed but not restored (ambiguous_name 409 from /api/up). Now the
entire flow is id-grounded: selectedCandidates carries the full
candidate object with rigId; apply uses POST /api/rigs/:id/up;
awaiting-decision fresh-prime retry is also id-based; idempotent
re-check matches by rig.id not name; picker values use rigId.

The Explorer route /:id/up now parses freshLogicalIds from the body
and passes it to restore() so the fresh-prime retry works through the
id-based route.

Same-name test updated: /api/up returns ambiguous_name 409 to prove
rig start never hits it; all four calls (2 plan + 2 apply) go through
the id-based route verified via _rigIdRoute in upBodies.
This commit is contained in:
mvschwarz committed 2026-06-14 18:15:49 -07:00
1 parent 7ecbe8d4e1
commit 6ba0de56d4
3 files changed
+163 -119

No files matched your search

+29 -33
View File
@@ -325,15 +325,13 @@ Examples:
return;
}
// ---- PHASE 4: selection ----
let selectedNames: string[];
if (opts.all) {
selectedNames = candidates.map((c) => c.rigName);
} else if (opts.last) {
selectedNames = candidates.map((c) => c.rigName);
// ---- PHASE 4: selection (id-grounded, rev1 BLOCKING b4c6ada4) ----
let selectedCandidates: StartCandidate[];
if (opts.all || opts.last) {
selectedCandidates = [...candidates];
} else if (opts.rigs) {
selectedNames = opts.rigs;
const missing = selectedNames.filter((n) => !candidates.some((c) => c.rigName === n));
selectedCandidates = candidates.filter((c) => opts.rigs!.includes(c.rigName));
const missing = opts.rigs.filter((n) => !candidates.some((c) => c.rigName === n));
if (missing.length > 0) {
console.error(`Rig(s) not found in candidate set: ${missing.join(", ")}`);
console.error(`Available candidates: ${candidates.map((c) => c.rigName).join(", ")}`);
@@ -364,42 +362,37 @@ Examples:
const restoreAll = await ask(`Restore all ${candidates.length} rig(s)? [Y/pick] `);
if (restoreAll) {
selectedNames = candidates.map((c) => c.rigName);
selectedCandidates = [...candidates];
} else {
const picked = await multiSelectPicker(
const pickedIds = await multiSelectPicker(
candidates.map((c) => ({
label: `${c.rigName} (${c.nodeCount} seats, ${c.lifecycleState}) ${summarizeReadiness(c)}`,
value: c.rigName,
value: c.rigId,
checked: true,
})),
);
selectedNames = picked;
selectedCandidates = candidates.filter((c) => pickedIds.includes(c.rigId));
}
}
if (selectedNames.length === 0) {
if (selectedCandidates.length === 0) {
if (!opts.json) console.log("No rigs selected for restore.");
return;
}
// ---- PHASE 5: restore each selected rig via /api/up (slice-02 path) ----
if (!opts.json) console.log(`\nRestoring ${selectedNames.length} rig(s)...\n`);
// ---- PHASE 5: restore each selected rig via id-based route (compose slice-02 path) ----
if (!opts.json) console.log(`\nRestoring ${selectedCandidates.length} rig(s)...\n`);
const results: Array<{ rigName: string; status: string; nodes: Array<{ logicalId: string; status: string; error?: string }> }> = [];
for (const rigName of selectedNames) {
const candidate = candidates.find((c) => c.rigName === rigName);
if (!candidate) {
console.error(` ${rigName}: not found in candidates (skipping)`);
results.push({ rigName, status: "skipped", nodes: [] });
continue;
}
for (const candidate of selectedCandidates) {
const rigName = candidate.rigName;
const rigId = candidate.rigId;
// Check if the rig is already running (idempotent rerun: reconcile, don't relaunch).
// Re-fetch status since we may have restored other rigs in the loop.
try {
const freshSummary = await client.get<RigSummary[]>("/api/rigs/summary");
const current = (freshSummary.data ?? []).find((r) => r.name === rigName);
const current = (freshSummary.data ?? []).find((r) => r.id === rigId);
if (current?.lifecycleState === "running") {
if (!opts.json) console.log(` ${rigName}: already running (skipping)`);
results.push({ rigName, status: "already_running", nodes: [] });
@@ -408,10 +401,13 @@ Examples:
} catch { /* proceed with restore attempt */ }
try {
const res = await client.post<Record<string, unknown>>("/api/up", {
sourceRef: rigName,
plan: false,
}, { timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS });
// Rev1 BLOCKING b4c6ada4: use id-based route for restore too (not
// name-based /api/up which 409s on same-name rigs).
const res = await client.post<Record<string, unknown>>(
`/api/rigs/${encodeURIComponent(rigId)}/up`,
{ plan: false },
{ timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS },
);
// Guard BLOCKING 25661f72: check HTTP status before treating the response
// as a success. The daemon returns non-2xx JSON payloads (rig_not_stopped,
@@ -462,11 +458,11 @@ Examples:
}
if (accepted.length > 0) {
try {
const freshRes = await client.post<Record<string, unknown>>("/api/up", {
sourceRef: rigName,
plan: false,
freshLogicalIds: accepted,
}, { timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS });
const freshRes = await client.post<Record<string, unknown>>(
`/api/rigs/${encodeURIComponent(rigId)}/up`,
{ plan: false, freshLogicalIds: accepted },
{ timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS },
);
if (freshRes.status >= 400) {
const freshError = String(freshRes.data["error"] ?? "fresh-prime failed");
console.error(` Fresh-prime for ${rigName}: ${freshError} (HTTP ${freshRes.status})`);
+128 -86
View File
@@ -144,19 +144,20 @@ describe("rig start (OPR.0.3.4.1)", () => {
{ id: "rig-1", name, nodeCount: 2, lifecycleState },
];
routes["/api/rigs/:id"] = () => ({ sessions: [{ lastSeenAt: "2026-06-14T12:00:00Z" }] });
// Plan preview via id-based route (candidate discovery uses POST /api/rigs/:id/up).
routes["/api/rigs/:id/up"] = () => ({
status: "plan", mode: "restore", rigId: "rig-1", rigName: name,
snapshot: { id: "snap-1", kind: "auto-pre-down", createdAt: "2026-06-14T11:00:00Z" },
wouldCaptureCurrentState: false,
nodes: [
{ logicalId: "dev.impl", intendedAction: hasToken ? "resume-original" : "awaiting-decision", ...(hasToken ? {} : { reason: "no token available" }) },
{ logicalId: "dev.qa", intendedAction: "resume-original" },
],
mutated: false,
});
// Apply via name-based route (restore uses POST /api/up).
routes["/api/up"] = () => {
// Both plan preview AND apply go through the id-based route.
routes["/api/rigs/:id/up"] = (_req, body) => {
if (body.plan === true) {
return {
status: "plan", mode: "restore", rigId: "rig-1", rigName: name,
snapshot: { id: "snap-1", kind: "auto-pre-down", createdAt: "2026-06-14T11:00:00Z" },
wouldCaptureCurrentState: false,
nodes: [
{ logicalId: "dev.impl", intendedAction: hasToken ? "resume-original" : "awaiting-decision", ...(hasToken ? {} : { reason: "no token available" }) },
{ logicalId: "dev.qa", intendedAction: "resume-original" },
],
mutated: false,
};
}
return {
status: "restored", rigId: "rig-1", rigName: name,
rigResult: "fully_restored",
@@ -178,9 +179,9 @@ describe("rig start (OPR.0.3.4.1)", () => {
await makeCmd(prompt).parseAsync(["node", "rig", "start", "--last"]);
});
expect(prompt).not.toHaveBeenCalled();
const applyBodies = upBodies.filter((b) => b.plan !== true);
const applyBodies = upBodies.filter((b) => b.plan !== true && b._rigIdRoute);
expect(applyBodies.length).toBeGreaterThanOrEqual(1);
expect(applyBodies[0]!.sourceRef).toBe("my-rig");
expect(applyBodies[0]!._rigIdRoute).toBe("rig-1");
const output = logs.join("\n");
expect(output).toContain("my-rig");
});
@@ -202,33 +203,37 @@ describe("rig start (OPR.0.3.4.1)", () => {
{ id: "rig-2", name: "beta", nodeCount: 1, lifecycleState: "recoverable" },
];
routes["/api/rigs/:id"] = () => ({ sessions: [] });
routes["/api/rigs/:id/up"] = () => ({
status: "plan", mode: "restore", rigId: "rig-x", rigName: "rig",
snapshot: { id: "snap-1", kind: "auto-pre-down", createdAt: "2026-06-14T00:00:00Z" },
wouldCaptureCurrentState: false,
nodes: [{ logicalId: "w", intendedAction: "resume-original" }],
mutated: false,
});
routes["/api/up"] = () => ({ status: "restored", rigResult: "fully_restored", nodes: [{ logicalId: "w", status: "resumed" }], warnings: [] });
routes["/api/rigs/:id/up"] = (_req, body) => {
if (body.plan === true) {
return {
status: "plan", mode: "restore", rigId: body._rigId ?? "rig-x", rigName: "rig",
snapshot: { id: "snap-1", kind: "auto-pre-down", createdAt: "2026-06-14T00:00:00Z" },
wouldCaptureCurrentState: false,
nodes: [{ logicalId: "w", intendedAction: "resume-original" }],
mutated: false,
};
}
return { status: "restored", rigResult: "fully_restored", nodes: [{ logicalId: "w", status: "resumed" }], warnings: [] };
};
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "start", "--rigs", "alpha"]);
});
const applyBodies = upBodies.filter((b) => b.plan !== true);
const applyBodies = upBodies.filter((b) => b.plan !== true && b._rigIdRoute);
expect(applyBodies).toHaveLength(1);
expect(applyBodies[0]!.sourceRef).toBe("alpha");
expect(applyBodies[0]!._rigIdRoute).toBe("rig-1");
});
// ---- COMPOSE, NOT REIMPLEMENT ----
it("restore goes through /api/up (slice-02 path), not a private implementation", async () => {
it("restore goes through /api/rigs/:id/up (id-based route), not a private implementation", async () => {
setupCandidateRig("my-rig");
await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "start", "--last"]);
});
const applyBodies = upBodies.filter((b) => b.plan !== true);
const applyBodies = upBodies.filter((b) => b.plan !== true && b._rigIdRoute);
expect(applyBodies.length).toBeGreaterThanOrEqual(1);
expect(applyBodies.some((b) => b.sourceRef === "my-rig")).toBe(true);
expect(applyBodies.some((b) => b._rigIdRoute === "rig-1")).toBe(true);
});
// ---- TOKENLESS INCLUSION (guard rev1 discriminator) ----
@@ -296,7 +301,8 @@ describe("rig start (OPR.0.3.4.1)", () => {
let planCallCount = 0;
throwClient.post = async (path: string, body?: unknown, opts?: unknown) => {
const b = body as Record<string, unknown> | undefined;
if (path === "/api/up" && b?.plan !== true) {
// Apply now uses the id-based route; throw on any non-plan POST to /api/rigs/:id/up.
if (path.match(/^\/api\/rigs\/[^/]+\/up$/) && b?.plan !== true) {
throw new (await import("../src/client.js")).DaemonConnectionError("request timed out");
}
return origPost(path, body, opts);
@@ -321,26 +327,30 @@ describe("rig start (OPR.0.3.4.1)", () => {
const validTerms = ["resumed", "fresh-primed", "awaiting-decision", "attention_required", "failed"];
routes["/api/rigs/summary"] = () => [{ id: "r1", name: "term-rig", nodeCount: 3, lifecycleState: "recoverable" }];
routes["/api/rigs/:id"] = () => ({ sessions: [] });
routes["/api/rigs/:id/up"] = () => ({
status: "plan", mode: "restore", rigId: "r1", rigName: "term-rig",
snapshot: { id: "s1", kind: "auto-pre-down", createdAt: "2026-06-14T00:00:00Z" },
wouldCaptureCurrentState: false,
nodes: [
{ logicalId: "a", intendedAction: "resume-original" },
{ logicalId: "b", intendedAction: "awaiting-decision", reason: "no token" },
{ logicalId: "c", intendedAction: "fresh-primed" },
],
mutated: false,
});
routes["/api/up"] = () => ({
status: "restored", rigResult: "partially_restored",
nodes: [
{ logicalId: "a", status: "resumed" },
{ logicalId: "b", status: "awaiting-decision", error: "no token" },
{ logicalId: "c", status: "fresh-primed" },
],
warnings: [],
});
routes["/api/rigs/:id/up"] = (_req, body) => {
if (body.plan === true) {
return {
status: "plan", mode: "restore", rigId: "r1", rigName: "term-rig",
snapshot: { id: "s1", kind: "auto-pre-down", createdAt: "2026-06-14T00:00:00Z" },
wouldCaptureCurrentState: false,
nodes: [
{ logicalId: "a", intendedAction: "resume-original" },
{ logicalId: "b", intendedAction: "awaiting-decision", reason: "no token" },
{ logicalId: "c", intendedAction: "fresh-primed" },
],
mutated: false,
};
}
return {
status: "restored", rigResult: "partially_restored",
nodes: [
{ logicalId: "a", status: "resumed" },
{ logicalId: "b", status: "awaiting-decision", error: "no token" },
{ logicalId: "c", status: "fresh-primed" },
],
warnings: [],
};
};
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "start", "--last"]);
@@ -360,19 +370,37 @@ describe("rig start (OPR.0.3.4.1)", () => {
{ id: "rig-2", name: "foo", nodeCount: 1, lifecycleState: "detached" },
];
routes["/api/rigs/:id"] = () => ({ sessions: [] });
routes["/api/rigs/:id/up"] = (_req, body) => ({
status: "plan", mode: "restore",
rigId: body._rigId ?? "unknown", rigName: "foo",
snapshot: { id: "s1", kind: "auto-pre-down", createdAt: "2026-06-14T00:00:00Z" },
wouldCaptureCurrentState: false,
nodes: [{ logicalId: "w", intendedAction: "resume-original" }],
mutated: false,
});
routes["/api/rigs/:id/up"] = (_req, body) => {
if (body.plan === true || body._rigId) {
if (body.plan === true) {
return {
status: "plan", mode: "restore",
rigId: body._rigId ?? "unknown", rigName: "foo",
snapshot: { id: "s1", kind: "auto-pre-down", createdAt: "2026-06-14T00:00:00Z" },
wouldCaptureCurrentState: false,
nodes: [{ logicalId: "w", intendedAction: "resume-original" }],
mutated: false,
};
}
return {
status: "restored", rigResult: "fully_restored",
nodes: [{ logicalId: "w", status: "resumed" }],
warnings: [],
};
}
return {
status: "restored", rigResult: "fully_restored",
nodes: [{ logicalId: "w", status: "resumed" }],
warnings: [],
};
};
// Name-based /api/up would return ambiguous_name 409 for same-name rigs;
// rig start should never reach this path for restore since it uses id-based route.
routes["/api/up"] = () => ({
status: "restored", rigResult: "fully_restored",
nodes: [{ logicalId: "w", status: "resumed" }],
warnings: [],
});
_httpStatus: 409,
error: 'Multiple rigs named "foo" found',
code: "ambiguous_name",
} as never);
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "start", "--all"]);
@@ -380,11 +408,15 @@ describe("rig start (OPR.0.3.4.1)", () => {
const output = logs.join("\n");
// Both should appear — never "No rigs to restore".
expect(output).not.toContain("No rigs to restore");
// The id-based preview route was used (verify via upBodies).
const planBodies = upBodies.filter((b) => b.plan === true);
expect(planBodies.length).toBe(2);
expect(planBodies.some((b) => b._rigIdRoute === "rig-1")).toBe(true);
expect(planBodies.some((b) => b._rigIdRoute === "rig-2")).toBe(true);
expect(output).not.toContain("ambiguous_name");
// The id-based route was used for both preview AND apply.
const idBodies = upBodies.filter((b) => b._rigIdRoute);
expect(idBodies.length).toBeGreaterThanOrEqual(4); // 2 plan + 2 apply
expect(idBodies.some((b) => b._rigIdRoute === "rig-1")).toBe(true);
expect(idBodies.some((b) => b._rigIdRoute === "rig-2")).toBe(true);
// Name-based /api/up was NOT called for restore.
const nameBodies = upBodies.filter((b) => !b._rigIdRoute);
expect(nameBodies).toHaveLength(0);
});
// ---- PREVIEW ERROR STATE (guard BLOCKING bfed4ce3) ----
@@ -489,20 +521,19 @@ describe("rig start (OPR.0.3.4.1)", () => {
const rigUpMatch2 = method === "POST" && url.match(/^\/api\/rigs\/([^/]+)\/up$/);
if (rigUpMatch2) {
upBodies.push({ ...body, _rigIdRoute: rigUpMatch2[1] });
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
status: "plan", mode: "restore", rigId: rigUpMatch2[1], rigName: "live-rig",
snapshot: { id: "s1", kind: "auto-pre-down", createdAt: "2026-06-14T00:00:00Z" },
wouldCaptureCurrentState: false,
nodes: [{ logicalId: "w", intendedAction: "resume-original" }],
mutated: false,
}));
return;
}
if (method === "POST" && url === "/api/up") {
upBodies.push(body);
res.writeHead(409, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Rig r1 has live sessions", code: "rig_not_stopped" }));
if (body.plan === true) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
status: "plan", mode: "restore", rigId: rigUpMatch2[1], rigName: "live-rig",
snapshot: { id: "s1", kind: "auto-pre-down", createdAt: "2026-06-14T00:00:00Z" },
wouldCaptureCurrentState: false,
nodes: [{ logicalId: "w", intendedAction: "resume-original" }],
mutated: false,
}));
} else {
res.writeHead(409, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Rig r1 has live sessions", code: "rig_not_stopped" }));
}
return;
}
res.writeHead(404).end();
@@ -541,12 +572,23 @@ describe("rig start (OPR.0.3.4.1)", () => {
it("partially_restored rigResult -> nonzero exit (not treated as clean)", async () => {
setupCandidateRig("partial-rig");
// Override apply response only; the id-based plan preview from setupCandidateRig is fine.
routes["/api/up"] = () => ({
status: "restored", rigResult: "partially_restored",
nodes: [{ logicalId: "dev.impl", status: "fresh-primed" }],
warnings: [],
});
// Override both plan and apply to return partially_restored on apply.
routes["/api/rigs/:id/up"] = (_req, body) => {
if (body.plan === true) {
return {
status: "plan", mode: "restore", rigId: "rig-1", rigName: "partial-rig",
snapshot: { id: "s1", kind: "auto-pre-down", createdAt: "2026-06-14T00:00:00Z" },
wouldCaptureCurrentState: false,
nodes: [{ logicalId: "dev.impl", intendedAction: "resume-original" }],
mutated: false,
};
}
return {
status: "restored", rigResult: "partially_restored",
nodes: [{ logicalId: "dev.impl", status: "fresh-primed" }],
warnings: [],
};
};
const { exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "start", "--last"]);
+6
View File
@@ -481,6 +481,11 @@ rigsRoutes.post("/:id/up", async (c) => {
// 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;
// OPR.0.3.4.1 — thread freshLogicalIds so the id-based route supports
// the awaiting-decision fresh-prime retry (operation B).
const freshLogicalIds = Array.isArray(body["freshLogicalIds"])
? (body["freshLogicalIds"] as unknown[]).filter((v): v is string => typeof v === "string")
: undefined;
const snapshotRepo = c.get("snapshotRepo" as never) as SnapshotRepository;
const snapshotCapture = c.get("snapshotCapture" as never) as SnapshotCapture;
@@ -518,6 +523,7 @@ rigsRoutes.post("/:id/up", async (c) => {
const result = await restoreOrch.restore(snapshot.id, {
adapters: adapters ?? {},
fsOps: { exists: (p: string) => fs.existsSync(p) },
freshLogicalIds,
});
if (!result.ok) {
if (result.code === "pre_restore_validation_failed") {