Compare commits

...
3 changed files with 88 additions and 3 deletions

No files matched your search

+12 -3
View File
@@ -3,6 +3,7 @@ import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { resolveRigHandleInSummaries } from "../rig-handle.js";
const LONG_RUNNING_TIMEOUT_MS = 45_000;
@@ -30,8 +31,12 @@ Examples:
throw new Error(error ?? `Failed to list rigs (HTTP ${summaries.status})`);
}
if (!Array.isArray(summaries.data)) throw new Error("The daemon returned an invalid rig summary.");
const match = summaries.data.find((r) => r.name === rigRef || r.id === rigRef);
if (!match) throw new Error(`Rig '${rigRef}' not found`);
// Keep this command's existing HTTP/invalid-summary diagnostics and its one summary read.
const match = resolveRigHandleInSummaries(summaries.data, rigRef);
if (match.kind === "ambiguous") throw Object.assign(new Error(
`rig_ambiguous: '${rigRef}' matches ${match.ids.length} rigs (${match.ids.join(", ")}). Nothing was done; pass one of these ids instead.`,
), { code: "rig_ambiguous", candidates: match.ids });
if (match.kind === "not_found") throw new Error(`Rig '${rigRef}' not found`);
return match.id;
}
@@ -48,7 +53,11 @@ Examples:
let rigId: string;
try { rigId = await resolveRigId(client, rig); } catch (err) {
console.error((err as Error).message); process.exitCode = 1; return;
const error = err as Error & { code?: string; candidates?: string[] };
if (opts.json && error.code === "rig_ambiguous") {
console.log(JSON.stringify({ ok: false, code: error.code, message: error.message, candidates: error.candidates }, null, 2));
} else console.error(error.message);
process.exitCode = 1; return;
}
const res = await client.get<Record<string, unknown>>(`/api/rigs/${encodeURIComponent(rigId)}/env`);
+9
View File
@@ -73,6 +73,15 @@ export async function resolveRigHandle(
return { kind: "passthrough", handle };
}
return resolveRigHandleInSummaries(summaries, handle, opts);
}
/** The shared id-first/name-ambiguity rule for callers that already fetched and checked a summary. */
export function resolveRigHandleInSummaries(
summaries: RigSummaryEntry[],
handle: string,
opts: { archived?: boolean } = {},
): Exclude<HandleResolution, { kind: "passthrough" }> {
// 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)) {
@@ -0,0 +1,67 @@
// Real command parsing and HTTP client, a task-owned simulated daemon, no service action.
import { createServer } from "node:http";
import { Command } from "commander";
import { expect, it, vi } from "vitest";
import { DaemonClient } from "../src/client.js";
import { envCommand } from "../src/commands/env.js";
import { STATE_FILE } from "../src/daemon-lifecycle.js";
import type { StatusDeps } from "../src/commands/status.js";
const cases = [
{ name: "ambiguous", ref: "twin", rigs: [{ id: "rig-a", name: "twin" }, { id: "rig-b", name: "twin" }], expected: null },
{ name: "exact-id", ref: "chosen", rigs: [{ id: "rig-a", name: "chosen" }, { id: "chosen", name: "other" }], expected: "chosen" },
{ name: "unique-name", ref: "solo", rigs: [{ id: "rig-a", name: "solo" }], expected: "rig-a" },
];
it.each(cases.flatMap(c => ["status", "logs", "down"].map(operation => ({ ...c, operation }))))(
"env $operation resolves $name before a service request", async ({ operation, ref, rigs, expected }) => {
const requests: string[] = [];
const payloads: unknown[] = [];
const server = createServer((req, res) => {
requests.push(`${req.method} ${req.url}`);
let body = ""; req.on("data", chunk => { body += chunk; });
req.on("end", () => {
if (body) payloads.push(JSON.parse(body));
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(req.url === "/api/rigs/summary" ? rigs.map(r => ({ ...r, archivedAt: null }))
: { ok: true, hasServices: false, output: "fixture logs" }));
});
});
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as { port: number }).port;
const deps: StatusDeps = {
lifecycleDeps: {
readFile: (p: string) => p === STATE_FILE ? JSON.stringify({ pid: 123, port, db: "fixture" }) : null,
exists: (p: string) => p === STATE_FILE, isProcessAlive: () => true, fetch: async () => ({ ok: true }),
} as StatusDeps["lifecycleDeps"],
clientFactory: () => new DaemonClient(`http://127.0.0.1:${port}`),
};
const output: string[] = [];
const log = vi.spyOn(console, "log").mockImplementation((...a) => { output.push(a.join(" ")); });
const error = vi.spyOn(console, "error").mockImplementation((...a) => { output.push(a.join(" ")); });
const priorExitCode = process.exitCode; process.exitCode = undefined;
try {
const program = new Command(); program.addCommand(envCommand(deps));
await program.parseAsync(["node", "rig", "env", operation, ref,
...(operation === "status" ? ["--json"] : operation === "down" ? ["--volumes"] : [])]);
expect(["darwin", "linux"]).toContain(process.platform);
expect(requests[0]).toBe("GET /api/rigs/summary");
if (expected === null) {
expect(requests).toEqual(["GET /api/rigs/summary"]);
expect(payloads).toEqual([]);
expect(process.exitCode).toBe(1);
expect(output.join("\n")).toContain("rig_ambiguous");
expect(output.join("\n")).toContain("rig-a"); expect(output.join("\n")).toContain("rig-b");
if (operation === "status") expect(JSON.parse(output[0]!)).toMatchObject({ code: "rig_ambiguous", candidates: ["rig-a", "rig-b"] });
} else {
const route = `/api/rigs/${expected}/env` + (operation === "down" ? "/down" : operation === "logs" ? "/logs?tail=100" : "");
expect(requests).toEqual(["GET /api/rigs/summary", `${operation === "down" ? "POST" : "GET"} ${route}`]);
if (operation === "down") expect(payloads).toEqual([{ volumes: true }]);
expect(process.exitCode).toBeUndefined();
}
} finally {
log.mockRestore(); error.mockRestore(); process.exitCode = priorExitCode;
server.closeAllConnections();
await new Promise<void>(resolve => server.close(() => resolve()));
expect(server.listening).toBe(false);
}
});