mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
fix(cli): let rig add take the rig name, not only its id (#1027)
rig add accepts a rig name as well as its id, resolved like rig down: an exact id first across all rigs including archived, then a single active rig with that name. A name shared by two active rigs is refused before anything is added, with the matching ids. An unreadable summary falls back to the id-only path, and with a remote host selected names aren't resolved locally.
This commit is contained in:
1 parent
f9c1e899cc
commit
f7158c9a8a
9 files changed
+352
-89
No files matched your search
@@ -749,7 +749,7 @@ Root: `rig`; declared option: `-V, --version`.
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig add <rig-id> <pod-namespace> <member-fragment-path>` | — | `--json`<br>`--rig-root <path>` |
|
||||
| `rig add <rig> <pod-namespace> <member-fragment-path>` | — | `--json`<br>`--rig-root <path>` |
|
||||
|
||||
### create
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Command } from "commander";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { shellQuote } from "../cross-host-executor.js";
|
||||
import { readSelectedHost } from "../host-selection.js";
|
||||
import { resolveRigHandle } from "../rig-handle.js";
|
||||
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
@@ -20,17 +23,29 @@ interface AddMemberResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Quote a shell argument only when it needs it, so plain suggestions stay readable. */
|
||||
function shellArg(s: string): string {
|
||||
return /^[\w@%+=:,./-]+$/.test(s) ? s : shellQuote(s);
|
||||
}
|
||||
|
||||
export function addMemberCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("add").description("Add a member to an existing pod in a running rig");
|
||||
const cmd = new Command("add").description("Add a member to an existing pod in a running rig")
|
||||
.addHelpText("after", `
|
||||
The rig can be named the way rig whoami --json reports it, or by id. A name that
|
||||
matches more than one rig is refused; use one of the listed ids instead.
|
||||
|
||||
Examples:
|
||||
rig add my-rig dev ./reviewer.member.yaml
|
||||
rig add 01KXYZ... dev ./reviewer.member.yaml --json`);
|
||||
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
cmd
|
||||
.argument("<rig-id>", "ID of the target rig")
|
||||
.argument("<rig>", "Rig name or ID of the target rig")
|
||||
.argument("<pod-namespace>", "Namespace of the existing pod to add the member to")
|
||||
.argument("<member-fragment-path>", "Path to YAML/JSON member fragment file (spec snake_case fields)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.option("--rig-root <path>", "Root directory for agent resolution")
|
||||
.action(async (rigId: string, podNamespace: string, fragmentPath: string, opts: { json?: boolean; rigRoot?: string }) => {
|
||||
.action(async (rigHandle: string, podNamespace: string, fragmentPath: string, opts: { json?: boolean; rigRoot?: string }) => {
|
||||
const deps = getDeps();
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (!daemonStatusGuard(status)) return;
|
||||
@@ -80,6 +95,43 @@ export function addMemberCommand(depsOverride?: StatusDeps): Command {
|
||||
if (opts.rigRoot) body["rigRoot"] = opts.rigRoot;
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
|
||||
const refuse = (error: Record<string, unknown> & { message: string }) => {
|
||||
if (opts.json) console.log(JSON.stringify({ ok: false, ...error }, null, 2));
|
||||
else console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
};
|
||||
|
||||
// Resolve before the POST: an ambiguous name must stop here, before any
|
||||
// mutation, rather than add the member to whichever rig matched first.
|
||||
const resolution = await resolveRigHandle(client, rigHandle);
|
||||
|
||||
// rig add has no --host and always targets the local daemon, while a
|
||||
// selected remote host makes whoami/ps report THAT host's rigs. A name
|
||||
// copied from them must not land on a same-named local rig (every
|
||||
// install has a `kernel`). Exact local ids keep working as before.
|
||||
const selectedHost = readSelectedHost();
|
||||
if (selectedHost !== "local" && (resolution.kind === "ambiguous" || (resolution.kind === "resolved" && resolution.byName))) {
|
||||
refuse({
|
||||
code: "remote_host_selected",
|
||||
message: `Host '${selectedHost}' is selected, but rig add only targets the local daemon, so it does not resolve the rig name '${rigHandle}'. Nothing was added. `
|
||||
+ "Run rig add on that host, or pass the local rig id (find it with rig ps --json after rig host select local).",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolution.kind === "ambiguous") {
|
||||
const rigRoot = opts.rigRoot ? ` --rig-root ${shellArg(opts.rigRoot)}` : "";
|
||||
const suggestions = resolution.ids.map((id) => `rig add ${id} ${shellArg(podNamespace)} ${shellArg(fragmentPath)}${rigRoot}`);
|
||||
refuse({
|
||||
code: "rig_ambiguous",
|
||||
message: `'${rigHandle}' matches ${resolution.ids.length} rigs. Nothing was added. Re-run with the specific id, e.g. ${suggestions.join(" | ")}`,
|
||||
candidates: resolution.ids,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const rigId = resolution.kind === "resolved" ? resolution.id : rigHandle;
|
||||
|
||||
const res = await client.post<AddMemberResponse>(
|
||||
`/api/rigs/${encodeURIComponent(rigId)}/pods/${encodeURIComponent(podNamespace)}/members`,
|
||||
body,
|
||||
@@ -103,6 +155,9 @@ export function addMemberCommand(depsOverride?: StatusDeps): Command {
|
||||
?? (data.errors && data.errors.length > 0 ? data.errors.join("; ") : data.error)
|
||||
?? `Add member failed (HTTP ${res.status})`;
|
||||
console.error(msg);
|
||||
if (resolution.kind === "passthrough" && data.code === "rig_not_found") {
|
||||
console.error("Could not list rigs to resolve a name. If this is a rig name, pass the rig id instead (rig ps --json).");
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -111,7 +166,7 @@ export function addMemberCommand(depsOverride?: StatusDeps): Command {
|
||||
const icon = node.status === "launched" ? "OK" : "FAIL";
|
||||
const session = node.sessionName ? ` (${node.sessionName})` : "";
|
||||
const error = node.error ? ` - ${node.error}` : "";
|
||||
console.log(`Added member to rig ${rigId}`);
|
||||
console.log(`Added member to rig ${rigId === rigHandle ? rigId : `${rigHandle} (${rigId})`}`);
|
||||
console.log(` Pod: ${data.result!.podNamespace}`);
|
||||
console.log(` Member: [${icon}] ${node.logicalId}${session}${error}`);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps , daemonStatusGuard} from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
import { resolveRigHandle } from "../rig-handle.js";
|
||||
|
||||
interface TeardownResult {
|
||||
rigId: string;
|
||||
@@ -51,80 +52,6 @@ async function showAgentsBeforeDown(rigId: string, readInventory: () => Promise<
|
||||
|
||||
const LONG_RUNNING_TIMEOUT_MS = 45_000;
|
||||
|
||||
interface RigSummaryEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
archivedAt?: string | null;
|
||||
lifecycleState?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, across ALL rigs incl. archived - an id is unique, so
|
||||
* it is never ambiguous, and an archived rig's id must still reach the
|
||||
* canonical teardown id path (AC-2 unchanged).
|
||||
* 2. else name-filter over ACTIVE (non-archived) rigs only:
|
||||
* - exactly 1 active match -> resolve to that id;
|
||||
* - >1 active matches -> AMBIGUOUS: halt, never guess (load-bearing AC-3);
|
||||
* - 0 active matches -> NOT_FOUND: halt, honest error (AC-4).
|
||||
*
|
||||
* `/api/rigs/summary` defaults to ACTIVE-only and exposes `archivedAt`; we fetch
|
||||
* with `includeArchived=true` so an archived id still id-matches, then filter
|
||||
* names to active. So an active+archived same-name pair is NOT ambiguous (only
|
||||
* the active candidate counts), and an archived-only name does not resolve by
|
||||
* name (use the id, or the archive path).
|
||||
*/
|
||||
async function resolveRigHandle(client: DaemonClient, handle: string): Promise<HandleResolution> {
|
||||
let summaries: RigSummaryEntry[];
|
||||
try {
|
||||
// includeArchived=true so an archived rig's id still id-matches below
|
||||
// (preserving today's `rig down <id>` path); names are filtered to active.
|
||||
const res = await client.get<RigSummaryEntry[]>("/api/rigs/summary?includeArchived=true");
|
||||
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, across ALL rigs incl. archived (AC-2: down by id,
|
||||
// unchanged; ids are never ambiguous; archived ids still reach teardown).
|
||||
if (summaries.some((r) => r.id === handle)) {
|
||||
return { kind: "resolved", id: handle };
|
||||
}
|
||||
|
||||
// 2. name-filter over ACTIVE (non-archived) rigs only, symmetric with `up`.
|
||||
const activeNameMatches = summaries.filter((r) => r.name === handle && r.archivedAt == null);
|
||||
if (activeNameMatches.length === 1) {
|
||||
return { kind: "resolved", id: activeNameMatches[0]!.id };
|
||||
}
|
||||
if (activeNameMatches.length > 1) {
|
||||
return { kind: "ambiguous", name: handle, ids: activeNameMatches.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
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { DaemonClient } from "./client.js";
|
||||
|
||||
interface RigSummaryEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
archivedAt?: string | null;
|
||||
lifecycleState?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of resolving a rig handle (name OR id) to a concrete id, shared by
|
||||
* `rig down` and `rig add`. A mutation only ever runs on a `resolved` id or a
|
||||
* `passthrough` handle; callers halt on `ambiguous` (and `rig down` also on
|
||||
* `not_found`) BEFORE any POST.
|
||||
*/
|
||||
export type HandleResolution =
|
||||
// `byName` is true when the handle matched an active rig's name, not an id.
|
||||
| { kind: "resolved"; id: string; byName: boolean }
|
||||
| { 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 reach the wrong rig.
|
||||
| { kind: "passthrough"; handle: string };
|
||||
|
||||
/**
|
||||
* Resolve a rig handle (rig name OR id) to a concrete rig id, mirroring the
|
||||
* `/api/rigs/summary` path `rig up` uses. Resolution is a PRE-STEP: each
|
||||
* command's existing request + guards downstream are unchanged; this only maps
|
||||
* the handle to an id.
|
||||
*
|
||||
* Safety order (mutating ops):
|
||||
* 1. id-exact-match FIRST, across ALL rigs incl. archived - an id is unique, so
|
||||
* it is never ambiguous, and an archived rig's id must still reach the
|
||||
* canonical id path (also when an active rig is NAMED like that id).
|
||||
* 2. else name-filter over ACTIVE (non-archived) rigs only:
|
||||
* - exactly 1 active match -> resolve to that id;
|
||||
* - >1 active matches -> AMBIGUOUS: halt, never guess;
|
||||
* - 0 active matches -> NOT_FOUND.
|
||||
*
|
||||
* `/api/rigs/summary` defaults to ACTIVE-only and exposes `archivedAt`; we fetch
|
||||
* with `includeArchived=true` so an archived id still id-matches, then filter
|
||||
* names to active. So an active+archived same-name pair is NOT ambiguous (only
|
||||
* the active candidate counts), and an archived-only name does not resolve by
|
||||
* name (use the id, or the archive path).
|
||||
*/
|
||||
export async function resolveRigHandle(client: DaemonClient, handle: string): Promise<HandleResolution> {
|
||||
let summaries: RigSummaryEntry[];
|
||||
try {
|
||||
// includeArchived=true so an archived rig's id still id-matches below;
|
||||
// names are filtered to active.
|
||||
const res = await client.get<RigSummaryEntry[]>("/api/rigs/summary?includeArchived=true");
|
||||
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, across ALL rigs incl. archived (ids are never
|
||||
// ambiguous; archived ids still reach the id path).
|
||||
if (summaries.some((r) => r.id === handle)) {
|
||||
return { kind: "resolved", id: handle, byName: false };
|
||||
}
|
||||
|
||||
// 2. name-filter over ACTIVE (non-archived) rigs only, symmetric with `up`.
|
||||
const activeNameMatches = summaries.filter((r) => r.name === handle && r.archivedAt == null);
|
||||
if (activeNameMatches.length === 1) {
|
||||
return { kind: "resolved", id: activeNameMatches[0]!.id, byName: true };
|
||||
}
|
||||
if (activeNameMatches.length > 1) {
|
||||
return { kind: "ambiguous", name: handle, ids: activeNameMatches.map((r) => r.id) };
|
||||
}
|
||||
return { kind: "not_found", handle };
|
||||
}
|
||||
@@ -66,6 +66,26 @@ const NOT_FOUND_RESPONSE = {
|
||||
message: 'Pod "nope" not found in rig "test". Existing pods: infra. Check the namespace, or add a new pod with `rig expand`.',
|
||||
};
|
||||
|
||||
const RIG_NOT_FOUND_RESPONSE = {
|
||||
ok: false,
|
||||
code: "rig_not_found",
|
||||
message: 'Rig "ghost" not found.',
|
||||
};
|
||||
|
||||
const ALPHA_ID = "01KALPHA0000000000000000AA";
|
||||
const DUPE_ID_1 = "01KDUPE10000000000000000AA";
|
||||
const DUPE_ID_2 = "01KDUPE20000000000000000AA";
|
||||
const ARCHIVED_ID = "01KARCH00000000000000000AA";
|
||||
const COLLIDE_ID = "01KCOLLIDE00000000000000AA";
|
||||
const RIG_SUMMARIES = [
|
||||
{ id: ALPHA_ID, name: "alpha", nodeCount: 2, archivedAt: null },
|
||||
{ id: DUPE_ID_1, name: "dupe", nodeCount: 1, archivedAt: null },
|
||||
{ id: DUPE_ID_2, name: "dupe", nodeCount: 1, archivedAt: null },
|
||||
// An archived rig, plus an ACTIVE rig whose name is that archived rig's id.
|
||||
{ id: ARCHIVED_ID, name: "old-team", nodeCount: 1, archivedAt: "2026-06-01T00:00:00Z" },
|
||||
{ id: COLLIDE_ID, name: ARCHIVED_ID, nodeCount: 1, archivedAt: null },
|
||||
];
|
||||
|
||||
const FAILED_LAUNCH_RESPONSE = {
|
||||
ok: true,
|
||||
result: {
|
||||
@@ -82,17 +102,29 @@ describe("rig add", () => {
|
||||
let tmpDir: string;
|
||||
let fragmentPath: string;
|
||||
let capturedBody: Record<string, unknown> | null = null;
|
||||
let capturedUrl: string | null = null;
|
||||
let summaryStatus = 200;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
if (req.method === "POST" && req.url?.includes("/members")) {
|
||||
if (req.method === "GET" && req.url?.startsWith("/api/rigs/summary")) {
|
||||
res.writeHead(summaryStatus, { "Content-Type": "application/json" });
|
||||
// Mirrors the route: archived rigs only with ?includeArchived=true.
|
||||
const includeArchived = new URL(req.url, "http://x").searchParams.get("includeArchived") === "true";
|
||||
const rigs = includeArchived ? RIG_SUMMARIES : RIG_SUMMARIES.filter((r) => r.archivedAt == null);
|
||||
res.end(JSON.stringify(summaryStatus === 200 ? rigs : { error: "unavailable" }));
|
||||
} else if (req.method === "POST" && req.url?.includes("/members")) {
|
||||
capturedUrl = req.url;
|
||||
let body = "";
|
||||
req.on("data", (chunk) => { body += chunk; });
|
||||
req.on("end", () => {
|
||||
const parsed = JSON.parse(body);
|
||||
capturedBody = parsed;
|
||||
const memberId = parsed.member?.id;
|
||||
if (req.url?.includes("/nope/")) {
|
||||
if (req.url?.startsWith("/api/rigs/ghost/")) {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(RIG_NOT_FOUND_RESPONSE));
|
||||
} else if (req.url?.includes("/nope/")) {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(NOT_FOUND_RESPONSE));
|
||||
} else if (memberId === "server") {
|
||||
@@ -117,7 +149,15 @@ describe("rig add", () => {
|
||||
|
||||
afterAll(() => { server.close(); });
|
||||
|
||||
let savedHostSelected: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedBody = null;
|
||||
capturedUrl = null;
|
||||
summaryStatus = 200;
|
||||
// Hermetic host selection: never read the real ~/.openrig/config.json.
|
||||
savedHostSelected = process.env["OPENRIG_HOST_SELECTED"];
|
||||
process.env["OPENRIG_HOST_SELECTED"] = "local";
|
||||
tmpDir = join(tmpdir(), `add-test-${Date.now()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
fragmentPath = join(tmpDir, "member.yaml");
|
||||
@@ -125,6 +165,8 @@ describe("rig add", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (savedHostSelected === undefined) delete process.env["OPENRIG_HOST_SELECTED"];
|
||||
else process.env["OPENRIG_HOST_SELECTED"] = savedHostSelected;
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -263,4 +305,160 @@ describe("rig add", () => {
|
||||
// Rejected before the POST - never silently omitted and sent.
|
||||
expect(capturedBody).toBeNull();
|
||||
});
|
||||
|
||||
// Identity ergonomics: `rig whoami --json` names the rig, so `rig add` must
|
||||
// accept that exact unique name as well as the id.
|
||||
describe("rig name or id", () => {
|
||||
it("an exact unique name resolves to that rig's id", async () => {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "add", "alpha", "infra", fragmentPath]);
|
||||
});
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(capturedUrl).toBe(`/api/rigs/${ALPHA_ID}/pods/infra/members`);
|
||||
expect(logs.join("\n")).toContain("[OK] infra.server2");
|
||||
});
|
||||
|
||||
it("an id is posted unchanged", async () => {
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "add", DUPE_ID_2, "infra", fragmentPath]);
|
||||
});
|
||||
expect(capturedUrl).toBe(`/api/rigs/${DUPE_ID_2}/pods/infra/members`);
|
||||
});
|
||||
|
||||
it("an archived rig's exact id wins over an active rig with that name", async () => {
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "add", ARCHIVED_ID, "infra", fragmentPath]);
|
||||
});
|
||||
expect(capturedUrl).toBe(`/api/rigs/${ARCHIVED_ID}/pods/infra/members`);
|
||||
});
|
||||
|
||||
it("an archived rig's name does not resolve (posted as typed)", async () => {
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "add", "old-team", "infra", fragmentPath]);
|
||||
});
|
||||
expect(capturedUrl).toBe("/api/rigs/old-team/pods/infra/members");
|
||||
});
|
||||
|
||||
it("a missing name keeps the daemon's rig_not_found (exit 1)", async () => {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "add", "ghost", "infra", fragmentPath]);
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
expect(capturedUrl).toBe("/api/rigs/ghost/pods/infra/members");
|
||||
expect(logs.join("\n")).toContain('Rig "ghost" not found.');
|
||||
});
|
||||
|
||||
it("an ambiguous name refuses without posting and lists the matching ids", async () => {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "add", "dupe", "infra", fragmentPath]);
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
expect(capturedUrl).toBeNull();
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("'dupe' matches 2 rigs");
|
||||
expect(output).toContain(`rig add ${DUPE_ID_1} infra ${fragmentPath}`);
|
||||
expect(output).toContain(`rig add ${DUPE_ID_2} infra ${fragmentPath}`);
|
||||
});
|
||||
|
||||
it("suggested re-run commands quote a fragment path with spaces and keep --rig-root", async () => {
|
||||
const spaced = join(tmpDir, "my member.yaml");
|
||||
writeFileSync(spaced, `id: server2\nruntime: terminal\nagent_ref: "builtin:terminal"\nprofile: none\ncwd: /tmp\n`);
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "add", "dupe", "infra", spaced, "--rig-root", "/tmp/my root"]);
|
||||
});
|
||||
expect(capturedUrl).toBeNull();
|
||||
expect(logs.join("\n")).toContain(`rig add ${DUPE_ID_1} infra '${spaced}' --rig-root '/tmp/my root'`);
|
||||
});
|
||||
|
||||
it("--json: an ambiguous name returns rig_ambiguous with the candidate ids", async () => {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "add", "dupe", "infra", fragmentPath, "--json"]);
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
expect(capturedUrl).toBeNull();
|
||||
const parsed = JSON.parse(logs.join("\n"));
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.code).toBe("rig_ambiguous");
|
||||
expect(parsed.candidates).toEqual([DUPE_ID_1, DUPE_ID_2]);
|
||||
});
|
||||
|
||||
it("summary unavailable -> the raw handle is posted as before", async () => {
|
||||
summaryStatus = 500;
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "add", "alpha", "infra", fragmentPath]);
|
||||
});
|
||||
expect(capturedUrl).toBe("/api/rigs/alpha/pods/infra/members");
|
||||
});
|
||||
|
||||
it("summary unavailable + rig_not_found -> hints that a name could not be resolved", async () => {
|
||||
summaryStatus = 500;
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "add", "ghost", "infra", fragmentPath]);
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain('Rig "ghost" not found.');
|
||||
expect(output).toContain("Could not list rigs to resolve a name");
|
||||
});
|
||||
|
||||
// A selected remote host makes whoami/ps report THAT host's rigs, but rig
|
||||
// add only talks to the local daemon. A copied name must not land on a
|
||||
// same-named local rig.
|
||||
describe("with a remote host selected", () => {
|
||||
beforeEach(() => { process.env["OPENRIG_HOST_SELECTED"] = "host-b"; });
|
||||
|
||||
it("refuses a name that matches a local rig, without posting", async () => {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "add", "alpha", "infra", fragmentPath]);
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
expect(capturedUrl).toBeNull();
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("host-b");
|
||||
expect(output).toContain("local daemon");
|
||||
expect(output).toContain("Nothing was added");
|
||||
});
|
||||
|
||||
it("--json: refuses with remote_host_selected", async () => {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "add", "dupe", "infra", fragmentPath, "--json"]);
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
expect(capturedUrl).toBeNull();
|
||||
const parsed = JSON.parse(logs.join("\n"));
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.code).toBe("remote_host_selected");
|
||||
});
|
||||
|
||||
it("an exact local id is posted unchanged, as before", async () => {
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "add", ALPHA_ID, "infra", fragmentPath]);
|
||||
});
|
||||
expect(capturedUrl).toBe(`/api/rigs/${ALPHA_ID}/pods/infra/members`);
|
||||
});
|
||||
|
||||
it("an archived rig's exact id is posted unchanged, not refused as a name", async () => {
|
||||
const { exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "add", ARCHIVED_ID, "infra", fragmentPath]);
|
||||
});
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(capturedUrl).toBe(`/api/rigs/${ARCHIVED_ID}/pods/infra/members`);
|
||||
});
|
||||
|
||||
it("an unknown handle keeps the daemon's rig_not_found", async () => {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "add", "ghost", "infra", fragmentPath]);
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
expect(capturedUrl).toBe("/api/rigs/ghost/pods/infra/members");
|
||||
expect(logs.join("\n")).toContain('Rig "ghost" not found.');
|
||||
});
|
||||
});
|
||||
|
||||
it("help names the accepted identity form", () => {
|
||||
const help = addMemberCommand(runningDeps()).helpInformation();
|
||||
expect(help).toContain("<rig>");
|
||||
expect(help).toContain("Rig name or ID");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1302,7 +1302,7 @@ Node-granular managed partial restore (v0.3.4+):
|
||||
### Add a member to an existing pod — v0.3.3+
|
||||
|
||||
```bash
|
||||
rig add <rig-id> <pod-namespace> <member-fragment-path> [--json]
|
||||
rig add <rig> <pod-namespace> <member-fragment-path> [--json]
|
||||
```
|
||||
|
||||
`rig add` is the top-level verb for the `add_member` converge op. It adds a
|
||||
@@ -1310,6 +1310,12 @@ single member to an existing pod from a YAML/JSON member fragment file. The
|
||||
daemon resolves the named pod, validates the member, runs preflight, and
|
||||
launches the member in place.
|
||||
|
||||
`<rig>` is the rig id or its exact name (the `rigName` that `rig whoami --json`
|
||||
reports). A name shared by more than one rig is refused before anything is
|
||||
added, with the matching ids listed (`rig_ambiguous` under `--json`). `rig add`
|
||||
always targets the local daemon: while a remote host is selected it refuses to
|
||||
resolve a name (`remote_host_selected`); an exact local id still works.
|
||||
|
||||
HTTP outcomes:
|
||||
- `201` — member added; per-node launch state included in the response.
|
||||
- `400` — `validation_failed` or `preflight_failed` (the fragment or its launch posture is rejected before any state change).
|
||||
|
||||
+2
-2
@@ -67,8 +67,8 @@ Write a fragment instead when a seat needs something `rig grow` does not set: an
|
||||
explicit model, a permission policy, a different agent spec or role profile, a
|
||||
per-seat runtime or working directory, or startup files. Use
|
||||
`rig expand <rig-id> <pod-fragment-path>` to add a pod, or
|
||||
`rig add <rig-id> <pod-namespace> <member-fragment-path>` to add one member to an
|
||||
existing pod.
|
||||
`rig add <rig> <pod-namespace> <member-fragment-path>` to add one member to an
|
||||
existing pod (`<rig>` is the rig id or its exact unique name).
|
||||
|
||||
## Don't use this when
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"core/rig-lifecycle/SKILL.md": "8346e01d532795b24c3dba63413ca662c7b44cc5eed39ee2e85568580f40adca",
|
||||
"core/session-source-fork/SKILL.md": "c5381d130e0a58c2dd6669770a391314b0db0797df07641cf2d3f9804fac21a2",
|
||||
"core/specification-system/SKILL.md": "5226d6e8b1362df89ba3d5afd82e4b4add355870132ca4b140d7f9f4324ffdc1",
|
||||
"core/topology-mutation-and-seat-management/SKILL.md": "38a7a6a4dc9f7b8c01d57ff74e72c5a07ac85a455ea580d617894ccb4364d15c",
|
||||
"core/topology-mutation-and-seat-management/SKILL.md": "77db0e4cbaf09a78c86f59624cc10975ccd00231814cc1d9001fcfbd5a750f9f",
|
||||
"core/watchdog/SKILL.md": "24b2e756e3e9d067fa93a4b5fa5f48a5cce511e8b1a5b360b1c39bc09bc95d82",
|
||||
"pm/backlog-capture/SKILL.md": "345c2b47fc85e48c15c0d42bbb48fb27dd3b0ceec36bf59dc6ed20daaea47a84",
|
||||
"pm/context-builder/SKILL.md": "0134c9b0a9f196565109f7f51b6ec78d5c3e4b5982a0d7105a93b29952625b3c",
|
||||
@@ -82,7 +82,7 @@
|
||||
"openrig-operating-model/templates/SOP.md": "1eaf0daf4f9bbf0715e6a5aaf0f29f030af5fe8b8a10232488ee97c507afa6dc",
|
||||
"openrig-operating-model/templates/SPEC.md": "f126536daa59cea3f1cfb0898d83c7cdf99f6392f6f155ea64fe55187dd0f5f1",
|
||||
"openrig-skills/SKILL.md": "c3de569b72ff422ff8178c36ce9d1256f9603ccbc86e47e512582eb5eab1469a",
|
||||
"openrig-user/SKILL.md": "1c7c0c3ecf3ea7e5dff1c63d95fee3c410f264d740fe4d64f9f50b18f30f3b63",
|
||||
"openrig-user/SKILL.md": "5f6ac4f04470ce33b0ab588dac1b118be4dc0e74a644632ff6f522348484b340",
|
||||
"orienting-to-an-inherited-seat/CHANGELOG.md": "4d0970e75e5c15b00a48d6a7150376d646257777264fd1b9a191165a8d5a6e84",
|
||||
"orienting-to-an-inherited-seat/SKILL.md": "65300ce0e299cce0148a8bacd51b0475ac1b59cd77a51a9bb2db9d43bda6e208",
|
||||
"queue-handoff/SKILL.md": "d4d7e3e8772886b3771daffd60523b5f79040309b892c8be1afd47d1f7287f7b",
|
||||
@@ -119,7 +119,7 @@
|
||||
"core/rig-lifecycle/SKILL.md": "8346e01d532795b24c3dba63413ca662c7b44cc5eed39ee2e85568580f40adca",
|
||||
"core/session-source-fork/SKILL.md": "c5381d130e0a58c2dd6669770a391314b0db0797df07641cf2d3f9804fac21a2",
|
||||
"core/specification-system/SKILL.md": "5226d6e8b1362df89ba3d5afd82e4b4add355870132ca4b140d7f9f4324ffdc1",
|
||||
"core/topology-mutation-and-seat-management/SKILL.md": "38a7a6a4dc9f7b8c01d57ff74e72c5a07ac85a455ea580d617894ccb4364d15c",
|
||||
"core/topology-mutation-and-seat-management/SKILL.md": "77db0e4cbaf09a78c86f59624cc10975ccd00231814cc1d9001fcfbd5a750f9f",
|
||||
"core/watchdog/SKILL.md": "24b2e756e3e9d067fa93a4b5fa5f48a5cce511e8b1a5b360b1c39bc09bc95d82",
|
||||
"pm/backlog-capture/SKILL.md": "345c2b47fc85e48c15c0d42bbb48fb27dd3b0ceec36bf59dc6ed20daaea47a84",
|
||||
"pm/context-builder/SKILL.md": "0134c9b0a9f196565109f7f51b6ec78d5c3e4b5982a0d7105a93b29952625b3c",
|
||||
|
||||
@@ -67,8 +67,8 @@ Write a fragment instead when a seat needs something `rig grow` does not set: an
|
||||
explicit model, a permission policy, a different agent spec or role profile, a
|
||||
per-seat runtime or working directory, or startup files. Use
|
||||
`rig expand <rig-id> <pod-fragment-path>` to add a pod, or
|
||||
`rig add <rig-id> <pod-namespace> <member-fragment-path>` to add one member to an
|
||||
existing pod.
|
||||
`rig add <rig> <pod-namespace> <member-fragment-path>` to add one member to an
|
||||
existing pod (`<rig>` is the rig id or its exact unique name).
|
||||
|
||||
## Don't use this when
|
||||
|
||||
|
||||
Reference in new issue
Block a user