feat(cli): rig down accepts name-or-id with fail-safe ambiguity guard (OPR.0.3.3.22)

rig up product-team works by name but rig down product-team did not -
down only accepted the generated id, forcing rig ps -> copy id -> down.
Make rig down accept a rig name OR id, mirroring up's /api/rigs/summary
resolution path, as a PRE-STEP before the existing /api/down teardown.

Destructive-op safety (the load-bearing care-point):
- id-exact-match FIRST (an id is unique, never ambiguous) -> AC-2 unchanged.
- name-filter the summary: 1 match resolves; >1 matches HALT with a 3-part
  honest error listing the ids (AC-3, fail-safe); 0 matches HALT not-found
  (AC-4). Ambiguous/not-found return BEFORE the POST, so an ambiguous name
  can never reach teardown.
- The resolved id funnels into the SAME existing down(id) path + guards;
  no forked teardown. Flags (--delete/--force/--snapshot/--json) flow through.
- Summary unavailable (non-200/error) falls back to today's id-only POST;
  safe because the daemon resolves by exact id only.

Tests: AC-1 by-name, AC-2 id-exact-match-wins, AC-3 discriminator (asserts
zero /api/down POSTs on an ambiguous name; fails a naive first-match impl),
AC-3 --json candidates, AC-4 not-found, AC-5 flags-flow-through, and the
summary-unavailable passthrough. All pre-existing id-based down tests pass
unchanged (no summary mock -> 404 -> passthrough). cli-reference updated;
the now-fixed v0.2.0 down-by-name 404 note replaced.

Note: /api/rigs/summary includes archived rigs and exposes no archived flag
(getRigSummaries has no archived_at filter), so the >1-match halt is strictly
fail-safe and also halts a rare active+archived same-name pair (forcing id
use) rather than guess - flagged for review.
This commit is contained in:
mvschwarz committed 2026-06-09 01:03:03 -07:00
1 parent c491827bd7
commit 8bb2d272df
3 files changed
+249 -8

No files matched your search

+7 -3
View File
@@ -271,7 +271,10 @@ Success modes:
### `rig down`
Usage: `rig down <rigId> [--delete] [--force] [--snapshot] [--json]`
Usage: `rig down <rig> [--delete] [--force] [--snapshot] [--json]`
`<rig>` accepts a rig **name or id**, symmetric with `rig up`. A name is
resolved to its id via the active (non-archived) rig summary before teardown.
Flags:
- `--delete`: delete the rig record after teardown
@@ -281,8 +284,9 @@ Flags:
Notes:
- When `--snapshot` succeeds, human output includes the restore command.
- If the rig name is uniquely reusable, the handoff prefers `rig up <rigName>`.
- v0.2.0 known issue: `rig down <name> --delete` can return HTTP 404; use
`rig ps` to find the rig ID and run `rig down <rigId> --delete`.
- Destructive-op safety: if a name matches more than one rig, `rig down`
refuses to tear down any of them and lists the matching ids - re-run with
`rig down <id>`. An id always resolves directly (ids are never ambiguous).
### `rig env`
+110 -3
View File
@@ -16,8 +16,75 @@ interface TeardownResult {
const LONG_RUNNING_TIMEOUT_MS = 45_000;
interface RigSummaryEntry {
id: string;
name: string;
lifecycleState?: string;
}
/**
* `rig down <rigId>` — tear down a rig.
* Outcome of resolving a `rig down <rig>` handle (name OR id) to a concrete id.
* The destructive teardown only ever runs on a `resolved`/`passthrough` id;
* `ambiguous`/`not_found` halt BEFORE any `/api/down` POST.
*/
type HandleResolution =
| { kind: "resolved"; id: string }
| { kind: "ambiguous"; name: string; ids: string[] }
| { kind: "not_found"; handle: string }
// Summary unavailable (non-200 / fetch error): fall back to today's id-only
// behavior - POST the raw handle as the id and let the daemon resolve it by
// exact id (404 if absent). Safe: the daemon matches a single exact id, so a
// name posted this way cannot tear down the wrong rig.
| { kind: "passthrough"; handle: string };
/**
* Resolve a `rig down` handle (rig name OR id) to a concrete rig id, mirroring
* the `/api/rigs/summary` path `rig up` uses. Resolution is a PRE-STEP: the
* existing teardown + guards downstream are unchanged; this only maps the
* handle to an id.
*
* Safety order (destructive-op):
* 1. id-exact-match FIRST - an id is unique, so it is never ambiguous.
* 2. else name-filter over the summary candidate set:
* - exactly 1 match -> resolve to that id;
* - >1 matches -> AMBIGUOUS: halt, never guess (load-bearing AC-3);
* - 0 matches -> NOT_FOUND: halt, honest error (AC-4).
*
* Note: `/api/rigs/summary` includes archived rigs and exposes no archived flag
* (rig-repository.ts getRigSummaries has no archived_at filter), so the >1-match
* halt is strictly fail-safe and also halts a rare active+archived same-name
* pair (forcing id use) rather than risk guessing.
*/
async function resolveRigHandle(client: DaemonClient, handle: string): Promise<HandleResolution> {
let summaries: RigSummaryEntry[];
try {
const res = await client.get<RigSummaryEntry[]>("/api/rigs/summary");
if (res.status !== 200 || !Array.isArray(res.data)) {
return { kind: "passthrough", handle };
}
summaries = res.data;
} catch {
return { kind: "passthrough", handle };
}
// 1. id-exact-match first (AC-2: down by id, unchanged; ids are never ambiguous).
if (summaries.some((r) => r.id === handle)) {
return { kind: "resolved", id: handle };
}
// 2. name-filter (AC-1 / AC-3 / AC-4).
const nameMatches = summaries.filter((r) => r.name === handle);
if (nameMatches.length === 1) {
return { kind: "resolved", id: nameMatches[0]!.id };
}
if (nameMatches.length > 1) {
return { kind: "ambiguous", name: handle, ids: nameMatches.map((r) => r.id) };
}
return { kind: "not_found", handle };
}
/**
* `rig down <rig>` - tear down a rig by name or id.
* @param depsOverride - injectable deps for testing
* @returns Commander command
*/
@@ -26,12 +93,12 @@ export function downCommand(depsOverride?: StatusDeps): Command {
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
cmd
.argument("<rigId>", "Rig identifier to tear down")
.argument("<rig>", "Rig name or id to tear down")
.option("--delete", "Delete rig record after stopping")
.option("--force", "Kill sessions immediately")
.option("--snapshot", "Take snapshot before teardown")
.option("--json", "JSON output for agents")
.action(async (rigId: string, opts: { delete?: boolean; force?: boolean; snapshot?: boolean; json?: boolean }) => {
.action(async (rigHandle: string, opts: { delete?: boolean; force?: boolean; snapshot?: boolean; json?: boolean }) => {
const deps = getDepsF();
const status = await getDaemonStatus(deps.lifecycleDeps);
@@ -43,6 +110,46 @@ export function downCommand(depsOverride?: StatusDeps): Command {
const client = deps.clientFactory(getDaemonUrl(status));
// Resolve the handle (name OR id) to a concrete id BEFORE the teardown POST.
// Ambiguous/not-found halt here and never reach `/api/down` - for a
// destructive op, ambiguity must stop, never guess (AC-3 load-bearing).
const resolution = await resolveRigHandle(client, rigHandle);
if (resolution.kind === "ambiguous") {
const fact = `'${resolution.name}' matches ${resolution.ids.length} rigs.`;
const consequence = "Refusing to tear down: an ambiguous name could destroy the wrong rig.";
const action = `Re-run with the specific id, e.g. ${resolution.ids.map((id) => `rig down ${id}`).join(" | ")}`;
if (opts.json) {
console.log(JSON.stringify({ error: { fact, consequence, action, candidates: resolution.ids } }));
} else {
console.error(`Error: ${fact}`);
console.error(` ${consequence}`);
console.error(` ${action}`);
}
process.exitCode = 2;
return;
}
if (resolution.kind === "not_found") {
const fact = `No rig found matching '${resolution.handle}'.`;
const consequence = "Nothing was torn down.";
const action = "List rigs with: rig ps";
if (opts.json) {
console.log(JSON.stringify({ error: { fact, consequence, action } }));
} else {
console.error(`Error: ${fact}`);
console.error(` ${consequence}`);
console.error(` ${action}`);
}
process.exitCode = 2;
return;
}
// resolved -> the looked-up id; passthrough -> the raw handle (summary
// unavailable; daemon resolves by exact id, 404s if absent). Either way
// the SAME existing teardown path + guards run below - no forked path.
const rigId = resolution.kind === "resolved" ? resolution.id : resolution.handle;
const res = await client.post<TeardownResult | { error: string }>("/api/down", {
rigId,
delete: opts.delete ?? false,
+132 -2
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from "vitest";
import http from "node:http";
import { Command } from "commander";
import { downCommand } from "../src/commands/down.js";
@@ -55,16 +55,37 @@ function runningDeps(port: number): StatusDeps {
describe("Down CLI", () => {
let server: http.Server;
let port: number;
let lastBody: Record<string, unknown>;
let lastBody: Record<string, unknown> | undefined;
let responseOverride: { status: number; body: Record<string, unknown> } | null;
// When null, GET /api/rigs/summary returns 404 -> down falls back to today's
// id-only behavior (so every pre-existing id-based test passes unchanged).
// When set, the summary is served at 200 to exercise name resolution.
let summaryOverride: Array<{ id: string; name: string; lifecycleState?: string }> | null;
// Count POST /api/down calls so the AC-3 discriminator can assert that an
// ambiguous name reaches teardown ZERO times (fail-safe).
let downCallCount: number;
beforeAll(async () => {
responseOverride = null;
summaryOverride = null;
downCallCount = 0;
server = http.createServer(async (req, res) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/rigs/summary" && req.method === "GET") {
if (summaryOverride === null) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(summaryOverride));
return;
}
if (req.url === "/api/down" && req.method === "POST") {
downCallCount += 1;
lastBody = JSON.parse(body);
if (responseOverride) {
@@ -90,6 +111,13 @@ describe("Down CLI", () => {
afterAll(() => { server.close(); });
beforeEach(() => {
responseOverride = null;
summaryOverride = null;
downCallCount = 0;
lastBody = undefined;
});
function makeCmd(): Command {
const prog = new Command();
prog.exitOverride();
@@ -232,4 +260,106 @@ describe("Down CLI", () => {
});
expect(exitCode).toBe(2);
});
// ---- OPR.0.3.3.22: down accepts name-or-id ----
// AC-1: down by name resolves the name to its id and tears down.
it("AC-1: down by name resolves the name to its id and tears down", async () => {
summaryOverride = [{ id: "rig-abc", name: "product-team" }];
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "down", "product-team"]);
});
expect(lastBody["rigId"]).toBe("rig-abc"); // resolved id, not the name
expect(downCallCount).toBe(1);
expect(logs.some((l) => l.includes("rig-abc") && l.includes("stopped"))).toBe(true);
expect(exitCode).toBeUndefined(); // 0
});
// AC-2: down by id still works when the id is present in the summary (no regression).
it("AC-2: down by exact id resolves directly (id-match wins, even if a name also matches)", async () => {
// A rig whose NAME equals another rig's id-shaped handle would be a trap;
// assert the exact-id match is taken first and posts that id.
summaryOverride = [
{ id: "rig-xyz", name: "alpha" },
{ id: "rig-collide", name: "rig-xyz" }, // name collides with the first rig's id
];
const { exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "down", "rig-xyz"]);
});
expect(lastBody["rigId"]).toBe("rig-xyz"); // id-exact-match wins, not the name-collision rig
expect(downCallCount).toBe(1);
expect(exitCode).toBeUndefined();
});
// AC-3 (LOAD-BEARING DISCRIMINATOR): ambiguous active name tears down NOTHING.
it("AC-3: ambiguous name is refused and NEITHER rig is torn down (no /api/down POST)", async () => {
summaryOverride = [
{ id: "rig-1a", name: "product-team" },
{ id: "rig-2b", name: "product-team" },
];
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "down", "product-team"]);
});
// The discriminator: teardown was never attempted.
expect(downCallCount).toBe(0);
expect(lastBody).toBeUndefined(); // no POST body ever parsed
// Honest 3-part error listing both ids + the remediation.
const out = logs.join("\n");
expect(out).toContain("rig-1a");
expect(out).toContain("rig-2b");
expect(out).toMatch(/rig down rig-1a|rig down rig-2b/);
expect(out.toLowerCase()).toContain("ambiguous");
expect(exitCode).toBe(2);
});
// AC-3 --json: same fail-safe, structured error with candidate ids, no POST.
it("AC-3 --json: ambiguous name emits structured error with candidates and does not POST", async () => {
summaryOverride = [
{ id: "rig-1a", name: "product-team" },
{ id: "rig-2b", name: "product-team" },
];
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "down", "product-team", "--json"]);
});
expect(downCallCount).toBe(0);
const parsed = JSON.parse(logs.join(""));
expect(parsed.error.candidates).toEqual(["rig-1a", "rig-2b"]);
expect(exitCode).toBe(2);
});
// AC-4: honest not-found when a good summary contains no id/name match. No POST.
it("AC-4: not-found name halts with an honest error and no teardown", async () => {
summaryOverride = [{ id: "rig-abc", name: "product-team" }];
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "down", "no-such-rig"]);
});
expect(downCallCount).toBe(0);
const out = logs.join("\n");
expect(out).toContain("no-such-rig");
expect(out).toContain("rig ps");
expect(exitCode).toBe(2);
});
// AC-5 regression: flags flow through unchanged when a name resolves to an id.
it("AC-5: --delete/--force/--snapshot flow through when a name resolves", async () => {
summaryOverride = [{ id: "rig-abc", name: "product-team" }];
await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "down", "product-team", "--delete", "--force", "--snapshot"]);
});
expect(lastBody["rigId"]).toBe("rig-abc");
expect(lastBody["delete"]).toBe(true);
expect(lastBody["force"]).toBe(true);
expect(lastBody["snapshot"]).toBe(true);
});
// Passthrough: summary unavailable (non-200) -> today's id-only behavior preserved.
it("falls back to id-only POST when the rig summary is unavailable", async () => {
summaryOverride = null; // GET /api/rigs/summary -> 404
const { exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "down", "rig-1"]);
});
expect(lastBody["rigId"]).toBe("rig-1"); // raw handle posted, as today
expect(downCallCount).toBe(1);
expect(exitCode).toBeUndefined();
});
});