mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
feat(cli): rig launch takes a rig name as well as its ID (#1312)
`rig launch` used to need a rig's ID; a name was passed through unresolved and the daemon answered not-found. Now it resolves the handle first, locally and with `--host` or a selected host, on the single, subset, plan and first-start retry routes: an exact ID wins (archived IDs and ID-looking names included), then a unique active name; an ambiguous name stops before anything is sent and lists the candidate IDs; a missing handle, or a lookup that fails or returns an unusable list, sends the raw handle exactly as before. Each launch makes that one lookup first. `rig down --host` uses the same remote lookup, so its results change only for host lists that hold malformed rows: an exact ID or unique active name no longer crashes on an unrelated null row and posts the down for that rig; a missing or archived-only name, or an ambiguous one, with a null row gives the existing error (read from source, not measured); a response that isn't a list gives a readable error and sends nothing; a name carried only by a row without a rig ID is not found and sends nothing; and a row without a rig ID that shares a name still counts toward that name's ambiguity, as before. Local `rig down` and `rig add` are unchanged. The PR also updates the CLI reference, the openrig-user and topology skills and the capability page, and regenerates the generated skill-edge digest.
This commit is contained in:
1 parent
63f7b4caab
commit
8daf6a43df
11 files changed
+403
-35
No files matched your search
@@ -801,7 +801,7 @@ the sections that were not read are `null`.
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig launch <rigId> [nodeRef]` | — | `--seats <ids>`<br>`--hold-reason <reason>`<br>`--snapshot-id <id>`<br>`--retry-startup-from <member-file>`<br>`--rig-root <path>`<br>`--plan`<br>`--json`<br>`--host <id>` |
|
||||
| `rig launch <rig> [nodeRef]` | — | `--seats <ids>`<br>`--hold-reason <reason>`<br>`--snapshot-id <id>`<br>`--retry-startup-from <member-file>`<br>`--rig-root <path>`<br>`--plan`<br>`--json`<br>`--host <id>` |
|
||||
|
||||
### remove
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-life
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
import { answerShowsAction, isPlanAnswer, notAPlanMessage, planSupportRefusal } from "../launch-plan-guard.js";
|
||||
import { resolveRigHandle } from "../rig-handle.js";
|
||||
|
||||
type LaunchResponse = {
|
||||
ok: boolean;
|
||||
@@ -48,6 +49,21 @@ function printSnapshotSelection(selection: LaunchResponse["snapshotSelection"]):
|
||||
if (newer?.snapshotId) console.log(`Newer usable alternative: ${newer.snapshotId}${newer.kind ? ` (${newer.kind})` : ""}`);
|
||||
}
|
||||
|
||||
/** A rig name that matches more than one active rig stops before any request: it would fail as "not found" anyway,
|
||||
* and the candidates let the person re-run with the right id. */
|
||||
function refuseAmbiguousRig(handle: string, ids: string[], json: boolean | undefined, host?: string, count = ids.length): void {
|
||||
const fact = `'${handle}' matches ${count} rigs${host ? ` on host ${host}` : ""}.`;
|
||||
const consequence = "Nothing was launched.";
|
||||
const action = `Re-run with the specific rig id: ${ids.join(" | ")}`;
|
||||
if (json) console.log(JSON.stringify({ ok: false, code: "rig_ambiguous", error: { fact, consequence, action, candidates: ids } }));
|
||||
else {
|
||||
console.error(`Error: ${fact}`);
|
||||
console.error(` ${consequence}`);
|
||||
console.error(` ${action}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
/** Build the launch command, allowing local launches that start seats a longer wait than previews. */
|
||||
export function launchCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("launch").description("Launch or relaunch a node in a running rig");
|
||||
@@ -60,7 +76,7 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
|
||||
}
|
||||
|
||||
cmd
|
||||
.argument("<rigId>", "Target rig ID")
|
||||
.argument("<rig>", "Rig name or ID")
|
||||
.argument("[nodeRef]", "Node logical ID or node ID (single target)")
|
||||
.option("--seats <ids>", "Comma-separated logical IDs for subset launch")
|
||||
.option("--hold-reason <reason>", "Reason for holding non-target seats")
|
||||
@@ -70,7 +86,7 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
|
||||
.option("--plan", "Show subset selection and non-target effects without mutation")
|
||||
.option("--json", "JSON output")
|
||||
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml")
|
||||
.action(async (rigId: string, nodeRef: string | undefined, opts: { json?: boolean; holdReason?: string; seats?: string; host?: string; snapshotId?: string; plan?: boolean; retryStartupFrom?: string; rigRoot?: string }) => {
|
||||
.action(async (rigHandle: string, nodeRef: string | undefined, opts: { json?: boolean; holdReason?: string; seats?: string; host?: string; snapshotId?: string; plan?: boolean; retryStartupFrom?: string; rigRoot?: string }) => {
|
||||
// OPR.0.4.6.MH1 FR-2: selected-host routing — explicit --host wins;
|
||||
// else the persisted selection feeds the SHIPPED --host path; no
|
||||
// selection = today exactly.
|
||||
@@ -118,15 +134,21 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
|
||||
return;
|
||||
}
|
||||
|
||||
// The rig is named by its id or by its name; resolution runs once, after the input checks, right before the
|
||||
// request. An exact id wins (archived rigs included), then one active rig of that name. A missing handle or a
|
||||
// lookup that can't be read keeps today's path: the handle goes to the daemon as an id.
|
||||
let rigId = rigHandle;
|
||||
let rigLabel = rigHandle;
|
||||
|
||||
if (opts.host) {
|
||||
const { runRemoteHttpOp } = await import("../remote-host-ops.js");
|
||||
const { runRemoteHttpOp, lookupRemoteRigId } = await import("../remote-host-ops.js");
|
||||
const seatList = opts.seats
|
||||
? opts.seats.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: (isSingleNodePlan && trimmedNodeRef ? [trimmedNodeRef] : []);
|
||||
let apiPath: string;
|
||||
let body: unknown;
|
||||
let route: "subset" | "single";
|
||||
if (seatList.length > 0) {
|
||||
apiPath = `/api/rigs/${encodeURIComponent(rigId)}/nodes/launch-subset`;
|
||||
route = "subset";
|
||||
body = {
|
||||
seats: seatList,
|
||||
...(opts.holdReason ? { holdReason: opts.holdReason } : {}),
|
||||
@@ -145,7 +167,7 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
apiPath = `/api/rigs/${encodeURIComponent(rigId)}/nodes/${encodeURIComponent(nodeRef)}/launch`;
|
||||
route = "single";
|
||||
body = retryBody ?? (opts.snapshotId ? { snapshotId: opts.snapshotId } : {});
|
||||
} else {
|
||||
console.error("Either a node reference or --seats is required for launch --host");
|
||||
@@ -166,6 +188,16 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// After the plan-support check, so a plan the daemon can't honour costs no lookup.
|
||||
const lookup = await lookupRemoteRigId(opts.host, rigHandle, deps);
|
||||
if (lookup.kind === "ambiguous") {
|
||||
refuseAmbiguousRig(rigHandle, lookup.ids, opts.json, opts.host, lookup.count);
|
||||
return;
|
||||
}
|
||||
if (lookup.kind === "resolved") rigId = lookup.rigId;
|
||||
const apiPath = route === "subset"
|
||||
? `/api/rigs/${encodeURIComponent(rigId)}/nodes/launch-subset`
|
||||
: `/api/rigs/${encodeURIComponent(rigId)}/nodes/${encodeURIComponent(nodeRef!)}/launch`;
|
||||
const result = await runRemoteHttpOp(opts.host, "POST", apiPath, body, deps, opts);
|
||||
const notAPlan = Boolean(opts.plan && !isPlanAnswer(result.data) && answerShowsAction(result.ok, result.data));
|
||||
if (notAPlan) {
|
||||
@@ -208,6 +240,20 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
|
||||
? opts.seats.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: (isSingleNodePlan && trimmedNodeRef ? [trimmedNodeRef] : []);
|
||||
|
||||
const resolveLocalRig = async (): Promise<boolean> => {
|
||||
// A rig list that can't be matched (it throws on a malformed entry) keeps the raw handle, like a failed read.
|
||||
const resolution = await resolveRigHandle(client, rigHandle).catch(() => ({ kind: "passthrough" as const, handle: rigHandle }));
|
||||
if (resolution.kind === "ambiguous") {
|
||||
refuseAmbiguousRig(rigHandle, resolution.ids, opts.json);
|
||||
return false;
|
||||
}
|
||||
if (resolution.kind === "resolved") {
|
||||
rigId = resolution.id;
|
||||
if (resolution.byName) rigLabel = `${rigHandle} (${resolution.id})`;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
if (seatList.length > 0) {
|
||||
const body: { seats: string[]; holdReason?: string; snapshotId?: string; plan?: boolean; nonTargetMode?: "unchanged" | "detach_and_hold" } = { seats: seatList };
|
||||
if (opts.holdReason) body.holdReason = opts.holdReason;
|
||||
@@ -232,6 +278,7 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
|
||||
}
|
||||
body.plan = true;
|
||||
}
|
||||
if (!(await resolveLocalRig())) return;
|
||||
const res = await client.post<LaunchResponse>(`/api/rigs/${encodeURIComponent(rigId)}/nodes/launch-subset`, body,
|
||||
opts.plan ? undefined : { timeoutMs: 120_000 });
|
||||
const notAPlan = Boolean(opts.plan && !isPlanAnswer(res.data) && answerShowsAction(res.status >= 200 && res.status < 300, res.data));
|
||||
@@ -321,6 +368,7 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await resolveLocalRig())) return;
|
||||
const body: Record<string, unknown> = retryBody ?? {};
|
||||
if (opts.snapshotId) body.snapshotId = opts.snapshotId;
|
||||
|
||||
@@ -345,12 +393,12 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
|
||||
for (const w of res.data.warnings ?? []) console.warn(`Warning: ${w}`);
|
||||
};
|
||||
if (res.data.code === "already_running" || (res.data.alreadyRunning && res.data.alreadyRunning.length > 0)) {
|
||||
console.log(`Node ${logicalId} is already running in rig ${rigId} (not relaunched)`);
|
||||
console.log(`Node ${logicalId} is already running in rig ${rigLabel} (not relaunched)`);
|
||||
printLaunchWarnings();
|
||||
return;
|
||||
}
|
||||
const sessionSuffix = res.data.sessionName ? ` (${res.data.sessionName})` : "";
|
||||
console.log(`Launched node ${logicalId} in rig ${rigId}${sessionSuffix}`);
|
||||
console.log(`Launched node ${logicalId} in rig ${rigLabel}${sessionSuffix}`);
|
||||
printLaunchWarnings();
|
||||
});
|
||||
|
||||
|
||||
@@ -100,23 +100,47 @@ export async function runRemoteHttpOp(
|
||||
}
|
||||
}
|
||||
|
||||
/** How a rig handle matched a host's rig list: an exact id first (archived rigs included), else one active rig of
|
||||
* that name. Non-object rows (null, primitives) take no part, so they can't hide or block a match. A row without a
|
||||
* string `rigId` can't be a target, but an active one still counts toward a name's ambiguity, and a name only it
|
||||
* carries is not found. `unavailable` means the list itself couldn't be read or wasn't a list, so nothing is known. */
|
||||
export type RemoteRigLookup =
|
||||
| { kind: "resolved"; rigId: string; byName: boolean }
|
||||
/** `count` is every active row with that name; `ids` the ones that can be targeted. */
|
||||
| { kind: "ambiguous"; count: number; ids: string[] }
|
||||
| { kind: "not_found" }
|
||||
| { kind: "unavailable"; error: string | undefined };
|
||||
|
||||
export async function lookupRemoteRigId(hostId: string, handle: string, deps: RemoteHostDeps): Promise<RemoteRigLookup> {
|
||||
const psResult = await runRemoteHttpOp(hostId, "GET", "/api/ps?includeArchived=true", undefined, deps, {});
|
||||
if (!psResult.ok) return { kind: "unavailable", error: psResult.error };
|
||||
if (!Array.isArray(psResult.data)) return { kind: "unavailable", error: "the host's rig list was not readable" };
|
||||
const rows = (psResult.data as unknown[]).filter(
|
||||
(r): r is { rigId?: unknown; name?: unknown; archivedAt?: string | null } => r !== null && typeof r === "object",
|
||||
);
|
||||
const targetable = (r: { rigId?: unknown }): r is { rigId: string } => typeof r.rigId === "string";
|
||||
|
||||
const exactId = rows.find((r) => targetable(r) && r.rigId === handle);
|
||||
if (exactId && targetable(exactId)) return { kind: "resolved", rigId: exactId.rigId, byName: false };
|
||||
|
||||
const byName = rows.filter((r) => r.name === handle && !r.archivedAt);
|
||||
if (byName.length > 1) return { kind: "ambiguous", count: byName.length, ids: byName.filter(targetable).map((r) => r.rigId) };
|
||||
const only = byName[0];
|
||||
if (only && targetable(only)) return { kind: "resolved", rigId: only.rigId, byName: true };
|
||||
return { kind: "not_found" };
|
||||
}
|
||||
|
||||
export async function resolveRemoteRigId(
|
||||
hostId: string,
|
||||
handle: string,
|
||||
deps: RemoteHostDeps,
|
||||
): Promise<{ ok: true; rigId: string } | { ok: false; error: string }> {
|
||||
const psResult = await runRemoteHttpOp(hostId, "GET", "/api/ps?includeArchived=true", undefined, deps, {});
|
||||
if (!psResult.ok) return { ok: false, error: `cannot resolve rig on host ${hostId}: ${psResult.error}` };
|
||||
|
||||
const rigs = psResult.data as Array<{ rigId: string; name: string; archivedAt?: string | null }>;
|
||||
|
||||
const exactId = rigs.find((r) => r.rigId === handle);
|
||||
if (exactId) return { ok: true, rigId: exactId.rigId };
|
||||
|
||||
const byName = rigs.filter((r) => r.name === handle && !r.archivedAt);
|
||||
if (byName.length === 1) return { ok: true, rigId: byName[0]!.rigId };
|
||||
if (byName.length > 1) {
|
||||
return { ok: false, error: `ambiguous rig name "${handle}" on host ${hostId}: ${byName.length} active rigs share that name. Use the rig id instead.` };
|
||||
const lookup = await lookupRemoteRigId(hostId, handle, deps);
|
||||
switch (lookup.kind) {
|
||||
case "resolved": return { ok: true, rigId: lookup.rigId };
|
||||
case "unavailable": return { ok: false, error: `cannot resolve rig on host ${hostId}: ${lookup.error}` };
|
||||
case "ambiguous":
|
||||
return { ok: false, error: `ambiguous rig name "${handle}" on host ${hostId}: ${lookup.count} active rigs share that name. Use the rig id instead.` };
|
||||
case "not_found": return { ok: false, error: `rig "${handle}" not found on host ${hostId}` };
|
||||
}
|
||||
return { ok: false, error: `rig "${handle}" not found on host ${hostId}` };
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// `rig launch` addresses a rig by its id or by its name, on every route, locally and on a selected host.
|
||||
// An exact id wins (archived rigs included, and over another rig *named* like that id); then one active rig of that
|
||||
// name. A name matching several active rigs stops before any request. A missing handle, or a rig lookup that fails
|
||||
// or can't be read, keeps the earlier behaviour: the handle goes to the daemon as an id.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { launchCommand } from "../src/commands/launch.js";
|
||||
import { lookupRemoteRigId, resolveRemoteRigId } from "../src/remote-host-ops.js";
|
||||
import { downCommand } from "../src/commands/down.js";
|
||||
|
||||
const ALPHA = "01ALPHA000000000000000000A";
|
||||
const ARCHIVED = "01ARCHIVED0000000000000000";
|
||||
const DUP1 = "01DUP10000000000000000000A";
|
||||
const DUP2 = "01DUP20000000000000000000B";
|
||||
const TARGET = "01TARGET000000000000000000";
|
||||
const NAMED_LIKE_TARGET = "01NAMEDLIKETARGET000000000";
|
||||
/** The same synthetic rigs, in the local summary's shape and the remote ps shape. */
|
||||
const RIGS = [
|
||||
{ id: ALPHA, name: "alpha", archivedAt: null },
|
||||
{ id: ARCHIVED, name: "old", archivedAt: "2026-10-01T00:00:00Z" },
|
||||
{ id: DUP1, name: "dup", archivedAt: null },
|
||||
{ id: DUP2, name: "dup", archivedAt: null },
|
||||
{ id: TARGET, name: "target", archivedAt: null },
|
||||
{ id: NAMED_LIKE_TARGET, name: TARGET, archivedAt: null },
|
||||
];
|
||||
|
||||
type Answer = { status: number; data: unknown };
|
||||
type Lookup = "rigs" | "fail" | "malformed" | "throws";
|
||||
|
||||
/** A daemon stub: answers the rig lookup per `lookup`, records every launch POST, and acknowledges it. */
|
||||
function daemon(lookup: Lookup, shape: "summary" | "ps") {
|
||||
const posts: Array<{ path: string; body: unknown }> = [];
|
||||
const gets: string[] = [];
|
||||
const client = {
|
||||
get: vi.fn(async (path: string): Promise<Answer> => {
|
||||
gets.push(path);
|
||||
if (path.startsWith("/api/rigs/summary") || path.startsWith("/api/ps")) {
|
||||
if (lookup === "throws") throw new Error("connect ECONNREFUSED");
|
||||
if (lookup === "fail") return { status: 503, data: { error: "busy" } };
|
||||
if (lookup === "malformed") return { status: 200, data: { rigs: "not a list" } };
|
||||
return { status: 200, data: shape === "summary" ? RIGS : RIGS.map(({ id, ...r }) => ({ rigId: id, ...r })) };
|
||||
}
|
||||
// Plan-support reads (launch-plan-guard): a current daemon.
|
||||
if (path === "/healthz") return { status: 200, data: { status: "ok", semver: "0.7.0" } };
|
||||
return { status: 200, data: { version: "0.7.0" } };
|
||||
}),
|
||||
post: vi.fn(async (path: string, body: unknown): Promise<Answer> => {
|
||||
posts.push({ path, body });
|
||||
if (path.endsWith("/launch-subset")) {
|
||||
const plan = (body as { plan?: boolean }).plan;
|
||||
return { status: 201, data: plan
|
||||
? { ok: true, planOnly: true, nonTargetEffects: { mode: "unchanged", affected: [] } }
|
||||
: { ok: true, launched: [{ nodeId: "n1", logicalId: "worker", status: "fresh" }], held: [], alreadyRunning: [] } };
|
||||
}
|
||||
return { status: 201, data: { ok: true, nodeId: "n1", logicalId: "worker", sessionName: "worker@rig" } };
|
||||
}),
|
||||
};
|
||||
return { client, posts, gets };
|
||||
}
|
||||
|
||||
function deps(client: unknown) {
|
||||
return {
|
||||
lifecycleDeps: {
|
||||
spawn: vi.fn(), fetch: vi.fn(async () => ({ ok: true })), kill: vi.fn(() => true),
|
||||
readFile: vi.fn(() => JSON.stringify({ pid: 1, port: 3000, db: "t.sqlite", startedAt: new Date().toISOString() })),
|
||||
writeFile: vi.fn(), removeFile: vi.fn(), exists: vi.fn(() => true), mkdirp: vi.fn(), openForAppend: vi.fn(() => 1), isProcessAlive: vi.fn(() => true),
|
||||
},
|
||||
clientFactory: () => client,
|
||||
hostRegistryLoader: () => ({ ok: true, registry: { hosts: [{ id: "h1", transport: "http", url: "http://h1.invalid:7433" }] } }),
|
||||
} as never;
|
||||
}
|
||||
|
||||
let out: string[];
|
||||
let err: string[];
|
||||
beforeEach(() => {
|
||||
out = []; err = [];
|
||||
vi.stubEnv("OPENRIG_HOST_SELECTED", "local");
|
||||
vi.spyOn(console, "log").mockImplementation((...a) => { out.push(a.join(" ")); });
|
||||
vi.spyOn(console, "error").mockImplementation((...a) => { err.push(a.join(" ")); });
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); process.exitCode = undefined; });
|
||||
|
||||
const retryFile = (() => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "launch-by-name-"));
|
||||
const file = join(dir, "member.yaml");
|
||||
writeFileSync(file, "member:\n id: worker\n runtime: claude-code\n");
|
||||
return { file, root: dir };
|
||||
})();
|
||||
|
||||
/** Every launch route, as argv after the rig handle, with the launch path it must POST to. */
|
||||
const ROUTES = [
|
||||
{ route: "single", args: ["worker"], path: (id: string) => `/api/rigs/${id}/nodes/worker/launch` },
|
||||
{ route: "subset", args: ["--seats", "worker"], path: (id: string) => `/api/rigs/${id}/nodes/launch-subset` },
|
||||
{ route: "subset plan", args: ["--plan", "--seats", "worker"], path: (id: string) => `/api/rigs/${id}/nodes/launch-subset` },
|
||||
{ route: "single-seat plan", args: ["worker", "--plan"], path: (id: string) => `/api/rigs/${id}/nodes/launch-subset` },
|
||||
{ route: "first-start retry", args: ["worker", "--retry-startup-from", retryFile.file, "--rig-root", retryFile.root], path: (id: string) => `/api/rigs/${id}/nodes/worker/launch` },
|
||||
] as const;
|
||||
|
||||
/** Handles and the rig each one must reach (`null`: the raw handle goes to the daemon, as before). */
|
||||
const HANDLES: Array<{ name: string; handle: string; reaches: string | null }> = [
|
||||
{ name: "exact id", handle: ALPHA, reaches: ALPHA },
|
||||
{ name: "unique active name", handle: "alpha", reaches: ALPHA },
|
||||
{ name: "archived rig's id", handle: ARCHIVED, reaches: ARCHIVED },
|
||||
{ name: "archived rig's name", handle: "old", reaches: null },
|
||||
{ name: "an id that is also another rig's name", handle: TARGET, reaches: TARGET },
|
||||
{ name: "missing", handle: "nope", reaches: null },
|
||||
];
|
||||
|
||||
async function launch(d: ReturnType<typeof daemon>, argv: string[]) {
|
||||
await launchCommand(deps(d.client)).parseAsync(["node", "rig", ...argv]);
|
||||
}
|
||||
|
||||
for (const where of ["local", "explicit --host", "selected host"] as const) {
|
||||
describe(`rig launch by rig name or id (${where})`, () => {
|
||||
const shape = where === "local" ? "summary" : "ps";
|
||||
const hostArgs = where === "explicit --host" ? ["--host", "h1"] : [];
|
||||
beforeEach(() => { if (where === "selected host") vi.stubEnv("OPENRIG_HOST_SELECTED", "h1"); });
|
||||
|
||||
for (const r of ROUTES) {
|
||||
for (const h of HANDLES) {
|
||||
it(`${r.route}: ${h.name}`, async () => {
|
||||
const d = daemon("rigs", shape);
|
||||
await launch(d, [h.handle, ...r.args, ...hostArgs]);
|
||||
expect(d.posts.map((p) => p.path)).toEqual([r.path(encodeURIComponent(h.reaches ?? h.handle))]);
|
||||
expect(d.gets.filter((g) => g.startsWith(shape === "summary" ? "/api/rigs/summary" : "/api/ps"))).toHaveLength(1);
|
||||
expect(process.exitCode ?? 0).toBe(0);
|
||||
});
|
||||
}
|
||||
|
||||
it(`${r.route}: an ambiguous name stops before any request and lists the candidates`, async () => {
|
||||
const d = daemon("rigs", shape);
|
||||
await launch(d, ["dup", ...r.args, ...hostArgs]);
|
||||
expect(d.posts).toEqual([]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(err.join("\n")).toContain("'dup' matches 2 rigs");
|
||||
expect(err.join("\n")).toContain(`${DUP1} | ${DUP2}`);
|
||||
expect(err.join("\n")).toContain("Nothing was launched.");
|
||||
});
|
||||
|
||||
for (const lookup of ["fail", "malformed", "throws"] as const) {
|
||||
it(`${r.route}: a lookup that ${lookup === "fail" ? "fails" : lookup === "malformed" ? "can't be read" : "can't connect"} keeps the raw handle`, async () => {
|
||||
const d = daemon(lookup, shape);
|
||||
await launch(d, ["alpha", ...r.args, ...hostArgs]);
|
||||
expect(d.posts.map((p) => p.path)).toEqual([r.path("alpha")]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it("--json reports an ambiguous name as data, with the candidates", async () => {
|
||||
const d = daemon("rigs", shape);
|
||||
await launch(d, ["dup", "worker", "--json", ...hostArgs]);
|
||||
expect(JSON.parse(out.join("\n"))).toMatchObject({ ok: false, code: "rig_ambiguous", error: { candidates: [DUP1, DUP2] } });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("rig launch by name: what a person sees locally", () => {
|
||||
it("names the rig and its id after a launch by name, and the id alone after a launch by id", async () => {
|
||||
await launch(daemon("rigs", "summary"), ["alpha", "worker"]);
|
||||
expect(out).toContain(`Launched node worker in rig alpha (${ALPHA}) (worker@rig)`);
|
||||
out.length = 0;
|
||||
await launch(daemon("rigs", "summary"), [ALPHA, "worker"]);
|
||||
expect(out).toContain(`Launched node worker in rig ${ALPHA} (worker@rig)`);
|
||||
});
|
||||
|
||||
it("does not look the rig up for a request it refuses on its own input", async () => {
|
||||
const d = daemon("rigs", "summary");
|
||||
await launch(d, ["alpha"]);
|
||||
expect(d.gets).toEqual([]);
|
||||
expect(d.posts).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/** A rig list with one malformed row (null, a primitive, a row without an id), before or after the real rows. */
|
||||
function listWithBadRow(bad: unknown, where: "before" | "after", shape: "summary" | "ps"): unknown[] {
|
||||
const rows = shape === "summary" ? RIGS : RIGS.map(({ id, ...r }) => ({ rigId: id, ...r }));
|
||||
return where === "before" ? [bad, ...rows] : [...rows, bad];
|
||||
}
|
||||
function daemonWithRows(rows: unknown[]) {
|
||||
const d = daemon("rigs", "summary");
|
||||
const get = d.client.get;
|
||||
d.client.get = vi.fn(async (path: string) =>
|
||||
path.startsWith("/api/rigs/summary") || path.startsWith("/api/ps") ? (d.gets.push(path), { status: 200, data: rows }) : get(path));
|
||||
return d;
|
||||
}
|
||||
const BAD_ROWS: Array<[string, unknown]> = [["null", null], ["a primitive", 7], ["a row without an id", { name: "zzz" }]];
|
||||
|
||||
describe("a malformed row in the rig list never refuses a valid id, and doesn't hide a match", () => {
|
||||
for (const [badName, bad] of BAD_ROWS) {
|
||||
for (const where of ["before", "after"] as const) {
|
||||
for (const r of ROUTES) {
|
||||
it(`local ${r.route}, ${badName} ${where}: by id launches that id`, async () => {
|
||||
const d = daemonWithRows(listWithBadRow(bad, where, "summary"));
|
||||
await launch(d, [ALPHA, ...r.args]);
|
||||
expect(d.posts.map((p) => p.path)).toEqual([r.path(ALPHA)]);
|
||||
});
|
||||
it(`--host ${r.route}, ${badName} ${where}: by id and by name reach the rig`, async () => {
|
||||
for (const handle of [ALPHA, "alpha"]) {
|
||||
const d = daemonWithRows(listWithBadRow(bad, where, "ps"));
|
||||
await launch(d, [handle, ...r.args, "--host", "h1"]);
|
||||
expect(d.posts.map((p) => p.path)).toEqual([r.path(ALPHA)]);
|
||||
}
|
||||
});
|
||||
}
|
||||
it(`local, ${badName} ${where}: by name resolves over the readable rows, or keeps the raw name`, async () => {
|
||||
const d = daemonWithRows(listWithBadRow(bad, where, "summary"));
|
||||
await launch(d, ["alpha", "worker"]);
|
||||
// The shared local resolver throws on a null row; the launch then keeps the raw handle, as before this change.
|
||||
expect(d.posts.map((p) => p.path)).toEqual([`/api/rigs/${bad === null ? "alpha" : ALPHA}/nodes/worker/launch`]);
|
||||
});
|
||||
it(`rig down --host, ${badName} ${where}: by id and by name tear down that rig`, async () => {
|
||||
for (const handle of [ALPHA, "alpha"]) {
|
||||
const d = daemonWithRows(listWithBadRow(bad, where, "ps"));
|
||||
await downCommand(deps(d.client)).parseAsync(["node", "rig", handle, "--host", "h1", "--json"]);
|
||||
expect(d.posts).toEqual([{ path: "/api/down", body: expect.objectContaining({ rigId: ALPHA }) }]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("a host's row without an id still counts toward a name, but is never a target", () => {
|
||||
const ghost = { name: "alpha", archivedAt: null }; // an active row named like alpha, with no rigId
|
||||
for (const host of ["explicit", "selected"] as const) {
|
||||
const hostArgs = host === "explicit" ? ["--host", "h1"] : [];
|
||||
beforeEach(() => { if (host === "selected") vi.stubEnv("OPENRIG_HOST_SELECTED", "h1"); });
|
||||
for (const where of ["before", "after"] as const) {
|
||||
it(`rig down --host (${host}), ${where}: the name stays ambiguous and nothing is sent`, async () => {
|
||||
const d = daemonWithRows(listWithBadRow(ghost, where, "ps"));
|
||||
await downCommand(deps(d.client)).parseAsync(["node", "rig", "alpha", ...hostArgs, "--json"]);
|
||||
expect(d.posts).toEqual([]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(JSON.parse(out.join("\n"))).toEqual({ ok: false,
|
||||
error: 'ambiguous rig name "alpha" on host h1: 2 active rigs share that name. Use the rig id instead.' });
|
||||
});
|
||||
it(`rig launch (${host}), ${where}: the name stays ambiguous and nothing is sent`, async () => {
|
||||
const d = daemonWithRows(listWithBadRow(ghost, where, "ps"));
|
||||
await launch(d, ["alpha", "worker", ...hostArgs]);
|
||||
expect(d.posts).toEqual([]);
|
||||
expect(err.join("\n")).toContain("'alpha' matches 2 rigs on host h1.");
|
||||
});
|
||||
it(`(${host}), ${where}: the exact id still selects the valid rig`, async () => {
|
||||
const down = daemonWithRows(listWithBadRow(ghost, where, "ps"));
|
||||
await downCommand(deps(down.client)).parseAsync(["node", "rig", ALPHA, ...hostArgs, "--json"]);
|
||||
expect(down.posts).toEqual([{ path: "/api/down", body: expect.objectContaining({ rigId: ALPHA }) }]);
|
||||
const up = daemonWithRows(listWithBadRow(ghost, where, "ps"));
|
||||
await launch(up, [ALPHA, "worker", ...hostArgs]);
|
||||
expect(up.posts.map((p) => p.path)).toEqual([`/api/rigs/${ALPHA}/nodes/worker/launch`]);
|
||||
});
|
||||
}
|
||||
it(`rig down --host (${host}): a name only an id-less row carries is not found, and nothing is sent`, async () => {
|
||||
const d = daemonWithRows(listWithBadRow({ name: "ghost", archivedAt: null }, "after", "ps"));
|
||||
await downCommand(deps(d.client)).parseAsync(["node", "rig", "ghost", ...hostArgs, "--json"]);
|
||||
expect(d.posts).toEqual([]);
|
||||
expect(JSON.parse(out.join("\n"))).toEqual({ ok: false, error: 'rig "ghost" not found on host h1' });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("remote rig lookup keeps rig down --host's results", () => {
|
||||
const remote = (lookup: Lookup) => deps(daemon(lookup, "ps").client) as never;
|
||||
it("distinguishes each outcome", async () => {
|
||||
expect(await lookupRemoteRigId("h1", "alpha", remote("rigs"))).toEqual({ kind: "resolved", rigId: ALPHA, byName: true });
|
||||
expect(await lookupRemoteRigId("h1", ALPHA, remote("rigs"))).toEqual({ kind: "resolved", rigId: ALPHA, byName: false });
|
||||
expect(await lookupRemoteRigId("h1", "dup", remote("rigs"))).toEqual({ kind: "ambiguous", count: 2, ids: [DUP1, DUP2] });
|
||||
expect(await lookupRemoteRigId("h1", "nope", remote("rigs"))).toEqual({ kind: "not_found" });
|
||||
expect(await lookupRemoteRigId("h1", "alpha", remote("fail"))).toMatchObject({ kind: "unavailable" });
|
||||
expect(await lookupRemoteRigId("h1", "alpha", remote("malformed"))).toMatchObject({ kind: "unavailable" });
|
||||
});
|
||||
|
||||
it("resolveRemoteRigId returns the same messages as before", async () => {
|
||||
expect(await resolveRemoteRigId("h1", "alpha", remote("rigs"))).toEqual({ ok: true, rigId: ALPHA });
|
||||
expect(await resolveRemoteRigId("h1", "dup", remote("rigs"))).toEqual({ ok: false,
|
||||
error: 'ambiguous rig name "dup" on host h1: 2 active rigs share that name. Use the rig id instead.' });
|
||||
expect(await resolveRemoteRigId("h1", "nope", remote("rigs"))).toEqual({ ok: false, error: 'rig "nope" not found on host h1' });
|
||||
expect(await resolveRemoteRigId("h1", "alpha", remote("fail"))).toEqual({ ok: false, error: "cannot resolve rig on host h1: HTTP 503" });
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,15 @@ function deps(client: DaemonClient): StatusDeps {
|
||||
};
|
||||
}
|
||||
|
||||
/** The launch resolves the rig handle with one summary read first; answer it at once with no rigs, so the handle
|
||||
* goes on as an id and only the launch request itself is slow. */
|
||||
function answerRigLookup(req: http.IncomingMessage, res: http.ServerResponse): boolean {
|
||||
if (req.method !== "GET" || !req.url?.startsWith("/api/rigs/summary")) return false;
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end("[]");
|
||||
return true;
|
||||
}
|
||||
|
||||
let server: http.Server | undefined;
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("OPENRIG_HOST_SELECTED", "local");
|
||||
@@ -42,6 +51,7 @@ describe("local launch request deadlines", () => {
|
||||
let requests = 0;
|
||||
let requestBody = "";
|
||||
server = http.createServer(async (req, res) => {
|
||||
if (answerRigLookup(req, res)) return;
|
||||
for await (const chunk of req) requestBody += chunk.toString();
|
||||
requests++;
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
@@ -61,6 +71,7 @@ describe("local launch request deadlines", () => {
|
||||
it("waits for a single-node launch response beyond the default five seconds without retrying", async () => {
|
||||
let requests = 0;
|
||||
server = http.createServer(async (req, res) => {
|
||||
if (answerRigLookup(req, res)) return;
|
||||
for await (const _chunk of req) { /* drain */ }
|
||||
requests++;
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
|
||||
@@ -489,8 +489,9 @@ describe("rig launch --host HTTP", () => {
|
||||
const { stdout, exitCode } = await captureLogs(async () => {
|
||||
await prog.parseAsync(["node", "rig", "launch", "rig-1", "--seats", "dev.impl", "--plan", "--host", "host-b", "--json"]);
|
||||
});
|
||||
expect(client._calls.map((c) => `${c.method} ${c.path}`)).toEqual([`GET ${HEALTH}`, `POST ${SUBSET}`]);
|
||||
expect(client._calls[1]!.body).toMatchObject({ seats: ["dev.impl"], plan: true });
|
||||
// The rig lookup runs after the plan-support check; this host has no rig list, so the handle goes on as an id.
|
||||
expect(client._calls.map((c) => `${c.method} ${c.path}`)).toEqual([`GET ${HEALTH}`, "GET /api/ps?includeArchived=true", `POST ${SUBSET}`]);
|
||||
expect(client._calls[2]!.body).toMatchObject({ seats: ["dev.impl"], plan: true });
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(JSON.parse(stdout.join(""))).toMatchObject({ ok: true, data: { planOnly: true } });
|
||||
});
|
||||
|
||||
@@ -1281,8 +1281,8 @@ This lets ordinary agents ask the manager for OpenRig help instead of every agen
|
||||
```bash
|
||||
rig grow <rig-id> <member...> [--pod <pod> | --new-pod <pod>] [--runtime <runtime>] [--cwd <path>] [--json]
|
||||
rig expand <rig-id> <pod-fragment-path> [--rig-root <path>] [--json]
|
||||
rig launch <rigId> <nodeRef> [--json]
|
||||
rig launch <rigId> --seats <a,b,c> [--hold-reason <text>] [--json]
|
||||
rig launch <rig> <nodeRef> [--json]
|
||||
rig launch <rig> --seats <a,b,c> [--hold-reason <text>] [--json]
|
||||
rig remove <rigId> <nodeRef> [--json]
|
||||
rig shrink <rigId> <podRef> [--json]
|
||||
rig unclaim <sessionRef> [--json]
|
||||
@@ -1295,8 +1295,9 @@ or `rig add` (one member) when a seat needs an explicit model, a permission poli
|
||||
different agent spec or role profile, per-seat runtime or cwd, or startup files.
|
||||
|
||||
Node-granular managed partial restore (v0.3.4+):
|
||||
- `rig launch <rigId> <nodeRef>` relaunches a single seat by logical id or node id through orchestration.
|
||||
- `rig launch <rigId> --seats <a,b,c>` relaunches a comma-separated subset of seats.
|
||||
- `rig launch <rig> <nodeRef>` relaunches a single seat by logical id or node id through orchestration. `<rig>` is the
|
||||
rig's name or ID; a name shared by more than one active rig stops with the candidate IDs.
|
||||
- `rig launch <rig> --seats <a,b,c>` relaunches a comma-separated subset of seats.
|
||||
- `--hold-reason <text>` records a reason for holding non-target seats during the partial launch.
|
||||
- This is a SUPPORTED managed path. The prior `pod_aware_launch_unsupported` dead-end is retired; pod-aware narrow launch now goes through this surface rather than ad-hoc rebuilds.
|
||||
|
||||
|
||||
@@ -367,8 +367,8 @@ not have to tear one down to change it, and you rarely have to start from nothin
|
||||
human mode it first prints one line with each agent's last known activity, or “unknown”: a
|
||||
best-effort snapshot, not a record of what was interrupted. With `--host`, a remote teardown
|
||||
that reports errors exits 2.
|
||||
- **`rig launch <rigId> [seat]`** — one seat is down; start just that one, without disturbing the
|
||||
rest. It takes the rig ID from `rig ps --json`; a rig name gives `rig_not_found`. Add `--plan` to preview it first, locally or with `--host`; a plan is never sent to a
|
||||
- **`rig launch <rig> [seat]`** — one seat is down; start just that one, without disturbing the
|
||||
rest. It takes the rig's name or ID; a name shared by more than one active rig stops and lists their IDs. Add `--plan` to preview it first, locally or with `--host`; a plan is never sent to a
|
||||
daemon older than 0.5.9, which would ignore it and launch. A seat whose agent exited to a shell in
|
||||
its kept pane reads `attention_required` (exit 1), not already running: resume it, or
|
||||
`rig seat launch <session> --fresh --stop`.
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ Per `cli-reference.md` v0.2.0:
|
||||
- `rig grow <rig-id> <member...> [--pod <pod> | --new-pod <pod>] [--runtime <runtime>] [--cwd <path>]` (added after v0.2.0; check `rig grow --help`)
|
||||
- `rig expand <rig-id> <pod-fragment-path>` (with optional `session_source`)
|
||||
- `rig shrink <rigId> <podRef>`
|
||||
- `rig launch <rigId> <nodeRef>`
|
||||
- `rig launch <rig> <nodeRef>` (the rig's name or ID)
|
||||
- `rig remove <rigId> <nodeRef>`
|
||||
- `rig discover [--draft]`
|
||||
- `rig bind <discoveredId> --rig <rigId> (--node <id> | --pod <ns> --member <name>)`
|
||||
|
||||
@@ -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": "77db0e4cbaf09a78c86f59624cc10975ccd00231814cc1d9001fcfbd5a750f9f",
|
||||
"core/topology-mutation-and-seat-management/SKILL.md": "527ca074432ce6513fb25cd27e8c709dabe0c91361b8999e09b272f3813abe62",
|
||||
"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": "ed2de7fe238dcfeda0e108e3cd8c447a6c9f2d16869bbdbac404e0a1621cd6f6",
|
||||
"openrig-user/SKILL.md": "f5acb06f98c225c6be1212572ea266af9a73a949f86249d684a3767d41edc0be",
|
||||
"openrig-user/SKILL.md": "f24074e15219fc51162805dbc065baa594f5c36a35259d7ab1203d097a9bdb92",
|
||||
"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": "77db0e4cbaf09a78c86f59624cc10975ccd00231814cc1d9001fcfbd5a750f9f",
|
||||
"core/topology-mutation-and-seat-management/SKILL.md": "527ca074432ce6513fb25cd27e8c709dabe0c91361b8999e09b272f3813abe62",
|
||||
"core/watchdog/SKILL.md": "24b2e756e3e9d067fa93a4b5fa5f48a5cce511e8b1a5b360b1c39bc09bc95d82",
|
||||
"pm/backlog-capture/SKILL.md": "345c2b47fc85e48c15c0d42bbb48fb27dd3b0ceec36bf59dc6ed20daaea47a84",
|
||||
"pm/context-builder/SKILL.md": "0134c9b0a9f196565109f7f51b6ec78d5c3e4b5982a0d7105a93b29952625b3c",
|
||||
|
||||
@@ -112,7 +112,7 @@ Per `cli-reference.md` v0.2.0:
|
||||
- `rig grow <rig-id> <member...> [--pod <pod> | --new-pod <pod>] [--runtime <runtime>] [--cwd <path>]` (added after v0.2.0; check `rig grow --help`)
|
||||
- `rig expand <rig-id> <pod-fragment-path>` (with optional `session_source`)
|
||||
- `rig shrink <rigId> <podRef>`
|
||||
- `rig launch <rigId> <nodeRef>`
|
||||
- `rig launch <rig> <nodeRef>` (the rig's name or ID)
|
||||
- `rig remove <rigId> <nodeRef>`
|
||||
- `rig discover [--draft]`
|
||||
- `rig bind <discoveredId> --rig <rigId> (--node <id> | --pod <ns> --member <name>)`
|
||||
|
||||
Reference in new issue
Block a user