mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 22:58:42 -10:00
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:
1 parent
c491827bd7
commit
8bb2d272df
3 files changed
+249
-8
No files matched your search
@@ -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`
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user