NS-T06: auto-snapshot on down + existing-rig power-on

Implements the core product loop: down (auto-snapshot) → up <name>
(auto-restore).

Key changes:
- RigTeardownOrchestrator: auto-snapshot (kind: auto-pre-down) before
  every teardown, best-effort
- UpCommandRouter: rig_name detection (no '/' and no file extension)
- RigRepository.findRigsByName: returns all matching rigs for ambiguity
- SnapshotRepository.findLatestAutoPreDown: finds latest power-on snap
- /api/up: rig_name branch classifies raw sourceRef before path
  resolution, finds rig → snapshot → restore via shared helper
- POST /api/rigs/:id/up: Explorer-facing power-on route
- Down response includes rigName + isUniqueName for conditional handoff
- CLI up: detects name vs path, prints restore handoff with node outcomes
- CLI down: prints 'To restore: rigged up {name}' (unique) or explicit
  restore command (ambiguous)

Both power-on routes compute attachCommand identically from node
inventory (first running session, fallback to any session).

7 new tests. Daemon: 92/92, 1224/1224. CLI: 176/176.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
Mike SchwarzandClaude Opus 4.6 committed 2026-03-31 05:43:01 -07:00
1 parent 8c918f3cc2
commit e015ce9215
12 files changed
+276 -27

No files matched your search

+12 -2
View File
@@ -92,9 +92,19 @@ export function downCommand(depsOverride?: StatusDeps): Command {
return;
}
// Clean stop
// Clean stop with post-command handoff
console.log(`Rig ${rigId} stopped. ${result.sessionsKilled} session(s) killed.`);
if (result.snapshotId) console.log(`Snapshot: ${result.snapshotId}`);
if (result.snapshotId) {
console.log(`Snapshot: ${result.snapshotId}`);
// Post-command handoff: how to restore (check for duplicate names)
const rigName = (res.data as Record<string, unknown>)["rigName"] as string | undefined;
const isUniqueName = (res.data as Record<string, unknown>)["isUniqueName"] as boolean | undefined;
if (rigName && isUniqueName !== false) {
console.log(`To restore: rigged up ${rigName}`);
} else {
console.log(`To restore: rigged restore ${result.snapshotId} --rig ${rigId}`);
}
}
});
return cmd;
+39 -10
View File
@@ -39,8 +39,12 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
const client = deps.clientFactory(`http://127.0.0.1:${status.port}`);
// Detect rig name vs file path: names don't contain / and don't end in .yaml/.yml/.rigbundle
const isRigName = !source.includes("/") && !source.match(/\.(ya?ml|rigbundle)$/i);
const sourceRef = isRigName ? source : nodePath.resolve(source);
const res = await client.post<Record<string, unknown>>("/api/up", {
sourceRef: nodePath.resolve(source),
sourceRef,
plan: opts.plan ?? false,
autoApprove: opts.yes ?? false,
targetRoot: opts.target,
@@ -75,16 +79,41 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
// Success output
const resStatus = res.data["status"] as string;
const stages = (res.data["stages"] as Array<{ stage: string; status: string }>) ?? [];
for (const s of stages) {
console.log(` ${s.stage}: ${s.status}`);
if (resStatus === "restored") {
// Existing-rig power-on handoff
const rigId = res.data["rigId"] as string;
const rigName = res.data["rigName"] as string | undefined;
console.log(`Rig "${rigName ?? rigId}" restored (ID: ${rigId})`);
const nodes = (res.data["nodes"] as Array<{ logicalId: string; status: string }>) ?? [];
for (const n of nodes) {
console.log(` ${n.logicalId}: ${n.status}`);
}
const warnings = (res.data["warnings"] as string[]) ?? [];
for (const w of warnings) {
console.error(` warning: ${w}`);
}
// Attach command from server response (uses real canonical session name)
const attachCommand = res.data["attachCommand"] as string | undefined;
if (attachCommand) {
console.log(`Attach: ${attachCommand}`);
}
if (nodes.some((n) => n.status === "failed")) {
process.exitCode = 1;
}
} else {
// Fresh boot output
const stages = (res.data["stages"] as Array<{ stage: string; status: string }>) ?? [];
for (const s of stages) {
console.log(` ${s.stage}: ${s.status}`);
}
const rigId = res.data["rigId"] as string | undefined;
if (rigId) console.log(`\nRig: ${rigId}`);
console.log(`Status: ${resStatus}`);
if (resStatus === "partial") process.exitCode = 1;
}
const rigId = res.data["rigId"] as string | undefined;
if (rigId) console.log(`\nRig: ${rigId}`);
console.log(`Status: ${resStatus}`);
if (resStatus === "partial") process.exitCode = 1;
});
return cmd;
@@ -138,6 +138,13 @@ export class RigRepository {
return rows.map((r) => this.rowToRig(r));
}
findRigsByName(name: string): Rig[] {
const rows = this.db
.prepare("SELECT * FROM rigs WHERE name = ? ORDER BY created_at")
.all(name) as RigRow[];
return rows.map((r) => this.rowToRig(r));
}
getRigSummaries(): Array<{ id: string; name: string; nodeCount: number; latestSnapshotAt: string | null; latestSnapshotId: string | null }> {
const rows = this.db.prepare(`
SELECT
+7 -8
View File
@@ -84,14 +84,13 @@ export class RigTeardownOrchestrator {
return result;
}
// 4. Snapshot before teardown
if (opts?.snapshot) {
try {
const snap = this.deps.snapshotCapture.captureSnapshot(rigId, "manual");
result.snapshotId = snap.id;
} catch (err) {
result.errors.push(`Snapshot failed: ${(err as Error).message}`);
}
// 4. Auto-snapshot before teardown (always, best-effort)
try {
const snap = this.deps.snapshotCapture.captureSnapshot(rigId, "auto-pre-down");
result.snapshotId = snap.id;
} catch (err) {
result.errors.push(`Snapshot failed: ${(err as Error).message}`);
// Best-effort — teardown proceeds even if snapshot fails
}
// 5. Kill each live session
@@ -34,6 +34,15 @@ export class SnapshotRepository {
return row ? this.rowToSnapshot(row) : null;
}
findLatestAutoPreDown(rigId: string): Snapshot | null {
const row = this.db
.prepare(
"SELECT * FROM snapshots WHERE rig_id = ? AND kind = 'auto-pre-down' ORDER BY created_at DESC LIMIT 1"
)
.get(rigId) as SnapshotRow | undefined;
return row ? this.rowToSnapshot(row) : null;
}
getLatestSnapshot(rigId: string): Snapshot | null {
const row = this.db
.prepare(
@@ -3,7 +3,7 @@ import { LegacyRigSpecSchema as LegacySchema } from "./rigspec-schema.js";
import { RigSpecCodec as PodCodec } from "./rigspec-codec.js";
import { RigSpecSchema as PodSchema } from "./rigspec-schema.js";
export type SourceKind = "rig_spec" | "rig_bundle";
export type SourceKind = "rig_spec" | "rig_bundle" | "rig_name";
export interface RouteResult {
sourceKind: SourceKind;
@@ -31,8 +31,13 @@ export class UpCommandRouter {
}
route(sourceRef: string): RouteResult {
// Rig name detection: no '/' and no file extension → treat as existing rig name
if (!sourceRef.includes("/") && !sourceRef.match(/\.(ya?ml|rigbundle)$/i)) {
return { sourceKind: "rig_name", sourceRef };
}
if (!this.fs.exists(sourceRef)) {
throw new Error(`Source not found: ${sourceRef}`);
throw new Error(`Source not found: ${sourceRef}. Provide a .yaml rig spec path or a rig name to restore.`);
}
// Extension-based routing
+8 -1
View File
@@ -1,5 +1,6 @@
import { Hono } from "hono";
import type { RigTeardownOrchestrator } from "../domain/rig-teardown.js";
import type { RigRepository } from "../domain/rig-repository.js";
import { RigNotFoundError } from "../domain/errors.js";
export const downRoutes = new Hono();
@@ -45,7 +46,13 @@ downRoutes.post("/", async (c) => {
return c.json(result, 500);
}
return c.json(result, 200);
// Include rig name + uniqueness in response for post-command handoff
const rigRepo = c.get("rigRepo" as never) as RigRepository;
const rig = rigRepo.getRig(rigId);
const rigName = rig?.rig.name ?? null;
const isUniqueName = rigName ? rigRepo.findRigsByName(rigName).length === 1 : false;
const enriched = { ...result, rigName, isUniqueName };
return c.json(enriched, 200);
} catch (err) {
if (err instanceof RigNotFoundError) {
return c.json({ error: err.message }, 404);
+42
View File
@@ -2,6 +2,8 @@ import { Hono } from "hono";
import type { RigRepository } from "../domain/rig-repository.js";
import type { SessionRegistry } from "../domain/session-registry.js";
import type { EventBus } from "../domain/event-bus.js";
import type { SnapshotRepository } from "../domain/snapshot-repository.js";
import type { RestoreOrchestrator } from "../domain/restore-orchestrator.js";
import { projectRigToGraph } from "../domain/graph-projection.js";
export const rigsRoutes = new Hono();
@@ -83,3 +85,43 @@ rigsRoutes.delete("/:id", (c) => {
return c.json({ error: "delete failed" }, 500);
}
});
// POST /api/rigs/:id/up — power-on an existing rig from auto-pre-down snapshot
rigsRoutes.post("/:id/up", async (c) => {
const rigId = c.req.param("id")!;
const repo = getRepo(c);
const rig = repo.getRig(rigId);
if (!rig) return c.json({ error: `Rig "${rigId}" not found. List rigs with: rigged ps` }, 404);
const snapshotRepo = c.get("snapshotRepo" as never) as SnapshotRepository;
const snapshot = snapshotRepo.findLatestAutoPreDown(rigId);
if (!snapshot) {
return c.json({ error: `Rig "${rig.rig.name}" exists but has no auto-pre-down snapshot. Start fresh with: rigged up <spec-path>`, code: "no_snapshot" }, 404);
}
const restoreOrch = c.get("restoreOrchestrator" as never) as RestoreOrchestrator | undefined;
if (!restoreOrch) {
return c.json({ error: "Restore orchestrator not available" }, 500);
}
const result = await restoreOrch.restore(snapshot.id);
if (!result.ok) {
return c.json({ error: result.message, code: result.code }, result.code === "rig_not_stopped" ? 409 : 400);
}
// Compute attach command from first running/resumed node (same logic as /api/up)
const { getNodeInventory } = await import("../domain/node-inventory.js");
const inventory = getNodeInventory(repo.db, rigId);
const firstRunning = inventory.find((n) => n.canonicalSessionName && n.sessionStatus === "running");
const attachCommand = firstRunning?.tmuxAttachCommand ?? inventory.find((n) => n.canonicalSessionName)?.tmuxAttachCommand ?? null;
return c.json({
status: "restored",
rigId,
rigName: rig.rig.name,
snapshotId: snapshot.id,
nodes: result.result.nodes,
warnings: result.result.warnings,
attachCommand,
}, 200);
});
+66 -4
View File
@@ -4,6 +4,9 @@ import type { BootstrapOrchestrator } from "../domain/bootstrap-orchestrator.js"
import type { BootstrapRepository } from "../domain/bootstrap-repository.js";
import type { EventBus } from "../domain/event-bus.js";
import type { UpCommandRouter } from "../domain/up-command-router.js";
import type { RigRepository } from "../domain/rig-repository.js";
import type { SnapshotRepository } from "../domain/snapshot-repository.js";
import type { RestoreOrchestrator } from "../domain/restore-orchestrator.js";
export const upRoutes = new Hono();
@@ -13,9 +16,50 @@ function getDeps(c: { get: (key: string) => unknown }) {
bootstrapRepo: c.get("bootstrapRepo" as never) as BootstrapRepository,
eventBus: c.get("eventBus" as never) as EventBus,
upRouter: c.get("upRouter" as never) as UpCommandRouter,
rigRepo: c.get("rigRepo" as never) as RigRepository,
snapshotRepo: c.get("snapshotRepo" as never) as SnapshotRepository,
restoreOrchestrator: c.get("restoreOrchestrator" as never) as RestoreOrchestrator | undefined,
};
}
/**
* Restore a rig by ID from its latest auto-pre-down snapshot.
* 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 }) {
const { snapshotRepo, restoreOrchestrator } = deps;
const snapshot = snapshotRepo.findLatestAutoPreDown(rigId);
if (!snapshot) {
return c.json({ error: `Rig exists but has no auto-pre-down snapshot. Start fresh with: rigged up <spec-path>`, code: "no_snapshot" }, 404);
}
if (!restoreOrchestrator) {
return c.json({ error: "Restore orchestrator not available" }, 500);
}
const result = await restoreOrchestrator.restore(snapshot.id);
if (!result.ok) {
return c.json({ error: result.message, code: result.code }, result.code === "rig_not_stopped" ? 409 : 400);
}
// Compute attach command from first running node (same logic as /api/rigs/:id/up)
const { getNodeInventory } = await import("../domain/node-inventory.js");
const inventory = getNodeInventory(deps.snapshotRepo.db, rigId);
const firstRunning = inventory.find((n) => n.canonicalSessionName && n.sessionStatus === "running");
const attachCommand = firstRunning?.tmuxAttachCommand ?? inventory.find((n) => n.canonicalSessionName)?.tmuxAttachCommand ?? null;
return c.json({
status: "restored",
rigId,
rigName,
snapshotId: snapshot.id,
nodes: result.result.nodes,
warnings: result.result.warnings,
attachCommand,
}, 200);
}
// POST /api/up — the hero route
upRoutes.post("/", async (c) => {
const { bootstrapOrchestrator, bootstrapRepo, eventBus, upRouter } = getDeps(c);
@@ -29,11 +73,29 @@ upRoutes.post("/", async (c) => {
return c.json({ error: "sourceRef is required" }, 400);
}
// Route source
// Route source — classify raw sourceRef first, only resolve path for file-based kinds
let sourceKind: string;
let resolvedSourceRef = sourceRef;
try {
const route = upRouter.route(nodePath.resolve(sourceRef));
const route = upRouter.route(sourceRef);
sourceKind = route.sourceKind;
// Rig name: restore from latest auto-pre-down snapshot
if (sourceKind === "rig_name") {
const { rigRepo } = getDeps(c);
const rigs = rigRepo.findRigsByName(sourceRef);
if (rigs.length === 0) {
return c.json({ error: `No rig found named "${sourceRef}". Provide a .yaml spec path to create a new rig.`, code: "rig_not_found" }, 404);
}
if (rigs.length > 1) {
const ids = rigs.map((r) => r.id).join(", ");
return c.json({ error: `Multiple rigs named "${sourceRef}" found (IDs: ${ids}). Use rigged restore --rig <rigId> with a specific rig ID.`, code: "ambiguous_name" }, 409);
}
return restoreByRigId(rigs[0]!.id, sourceRef, getDeps(c), c) as any;
}
// File-based: resolve path now
resolvedSourceRef = nodePath.resolve(sourceRef);
} catch (err) {
return c.json({ error: (err as Error).message }, 400);
}
@@ -53,7 +115,7 @@ upRoutes.post("/", async (c) => {
// Plan mode — no run lifecycle
const result = await bootstrapOrchestrator.bootstrap({
mode: "plan",
sourceRef: nodePath.resolve(sourceRef),
sourceRef: resolvedSourceRef,
sourceKind,
targetRoot,
});
@@ -82,7 +144,7 @@ upRoutes.post("/", async (c) => {
try {
const result = await bootstrapOrchestrator.bootstrap({
mode: "apply",
sourceRef: nodePath.resolve(sourceRef),
sourceRef: resolvedSourceRef,
sourceKind,
autoApprove,
targetRoot,
+43
View File
@@ -128,4 +128,47 @@ describe("POST /api/down route", () => {
snapshotCapture.captureSnapshot = origCapture;
});
// NS-T06: auto-snapshot on down
it("auto-snapshots before teardown (auto-pre-down)", async () => {
const db = createFullTestDb();
const { app, rigRepo, sessionRegistry } = createTestApp(db);
const rig = rigRepo.createRig("auto-snap-rig");
const node = rigRepo.addNode(rig.id, "impl", { runtime: "claude-code" });
const session = sessionRegistry.registerSession(node.id, "r01-impl");
sessionRegistry.updateStatus(session.id, "running");
const res = await app.request("/api/down", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rigId: rig.id }),
});
expect(res.status).toBe(200);
const body = await res.json();
// Auto-snapshot should have been created
expect(body.snapshotId).toBeTruthy();
// Verify snapshot kind
const snap = db.prepare("SELECT kind FROM snapshots WHERE id = ?").get(body.snapshotId) as { kind: string } | undefined;
expect(snap?.kind).toBe("auto-pre-down");
db.close();
});
// NS-T06: down response includes rig name for handoff
it("response includes rigName for post-command handoff", async () => {
const db = createFullTestDb();
const { app, rigRepo, sessionRegistry } = createTestApp(db);
const rig = rigRepo.createRig("handoff-test");
const node = rigRepo.addNode(rig.id, "impl");
const session = sessionRegistry.registerSession(node.id, "r01-impl");
sessionRegistry.updateStatus(session.id, "running");
const res = await app.request("/api/down", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rigId: rig.id }),
});
const body = await res.json();
expect(body.rigName).toBe("handoff-test");
db.close();
});
});
+15
View File
@@ -326,4 +326,19 @@ describe("Rig CRUD routes", () => {
// ZZZZ sorts after AAAA, so ZZZZ_snap should win the tiebreak
expect(epsilons[0].latestSnapshotId).toBe("ZZZZ_snap");
});
// NS-T06: POST /api/rigs/:id/up — power-on from auto-pre-down snapshot
it("POST /api/rigs/:id/up returns 404 when no auto-pre-down snapshot exists", async () => {
const rig = repo.createRig("no-snap-rig");
const res = await app.request(`/api/rigs/${rig.id}/up`, { method: "POST" });
expect(res.status).toBe(404);
const body = await res.json();
expect(body.code).toBe("no_snapshot");
});
it("POST /api/rigs/:id/up returns 404 for nonexistent rig", async () => {
const res = await app.request("/api/rigs/nonexistent/up", { method: "POST" });
expect(res.status).toBe(404);
expect((await res.json()).error).toContain("not found");
});
});
@@ -196,4 +196,25 @@ edges: []
const result = router.route(specPath);
expect(result.sourceKind).toBe("rig_spec");
});
// NS-T06: rig name detection
it("bare name without / or extension → rig_name", () => {
const router = new UpCommandRouter({ fsOps: realFsOps() });
const result = router.route("auth-feats");
expect(result.sourceKind).toBe("rig_name");
expect(result.sourceRef).toBe("auth-feats");
});
it("name with .yaml extension → NOT rig_name (file path)", () => {
const specPath = path.join(tmpDir, "auth.yaml");
fs.writeFileSync(specPath, VALID_SPEC);
const router = new UpCommandRouter({ fsOps: realFsOps() });
const result = router.route(specPath);
expect(result.sourceKind).toBe("rig_spec");
});
it("path with / → NOT rig_name", () => {
const router = new UpCommandRouter({ fsOps: realFsOps() });
expect(() => router.route("/tmp/nonexistent")).toThrow(/Source not found/);
});
});