Files
openrig/packages/cli/test/bundle-behaviour-command.test.ts
Mike Schwarz 7d49696ade fix: resolve only a profile's named skills; rig up --plan waits like an apply (#1658)
Resolving a member used to read and parse every SKILL.md in every skill folder for its runtime, once per member, synchronously on the daemon's event loop. With tens of thousands of skills in a user folder, `rig up <spec> --plan` stopped `/healthz` and every other request for every seat on the machine for 10 to 20 seconds per plan, and launch and restore paid the same cost. Now a member's resolution looks up only the skills its profile names, at `<root>/<name>/SKILL.md`, in the same root order, and falls back to the full scan only when a named skill isn't found that way, so folders named differently from their skill still resolve and a broken named skill gets the same rejection message. One change in precedence: a folder whose name doesn't match its skill in an earlier root no longer outranks a correctly named folder in a later root.

`rig up --plan` now waits 120 seconds, the same as an apply, locally and with `--host`; it used the client's 5-second default, so a plan with slow profile probes reported a timeout with an unknown outcome while the daemon finished it. If the daemon still doesn't answer a plan, the CLI says the plan created or changed no rig or seat and is safe to run again, and exits 1; with `--json` it prints `ok: false`, `plan: true`, `changed: false` and a message. Locally that covers a timeout; with `--host` it also covers a request that may have arrived without an answer or an unreadable reply. An apply that times out keeps its existing guidance and, with `--host`, `outcomeUnknown: true`.

Other work can still block the daemon during a plan: the runtime profile probes (`execSync` inside an async wrapper, up to 10 seconds each), the managed catalog scan, and the skills listing route.
2026-10-20 23:44:50 -07:00

125 lines
7.3 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { Command } from "commander";
import { bundleCommand } from "../src/commands/bundle.js";
import { upCommand } from "../src/commands/up.js";
import { runRemoteHttpOp } from "../src/remote-host-ops.js";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
vi.mock("../src/host-selection.js", () => ({ resolveEffectiveHost: (host?: string) => host }));
vi.mock("../src/remote-host-ops.js", () => ({ runRemoteHttpOp: vi.fn() }));
afterEach(() => { vi.restoreAllMocks(); });
function runningDeps(client: DaemonClient) {
const forbidden = vi.fn(() => { throw new Error("unexpected lifecycle mutation"); });
const lifecycleDeps = {
spawn: forbidden, fetch: vi.fn(async () => ({ ok: true })), kill: forbidden,
readFile: vi.fn((p: string) => p === STATE_FILE ? JSON.stringify({ pid: 123, port: 8123, db: "test.sqlite", startedAt: "2026-01-01T00:00:00Z" }) : null),
writeFile: forbidden, removeFile: forbidden, exists: vi.fn((p: string) => p === STATE_FILE),
mkdirp: forbidden, openForAppend: forbidden, isProcessAlive: vi.fn(() => true),
} as unknown as LifecycleDeps;
return { lifecycleDeps, clientFactory: () => client, forbidden };
}
describe("bundle view at the real CLI command boundary", () => {
it.each([
{ status: 201, expectedExit: undefined, plan: false },
{ status: 200, expectedExit: undefined, plan: true },
{ status: 409, expectedExit: 1, plan: false },
{ status: 500, expectedExit: 2, plan: false },
])("prints the diagnostic before one existing install request: $status / plan=$plan", async ({ status, expectedExit, plan }) => {
const events: string[] = [];
const stdout: string[] = [];
const stderr: string[] = [];
vi.spyOn(console, "log").mockImplementation(value => { stdout.push(String(value)); events.push("stdout"); });
vi.spyOn(console, "error").mockImplementation(value => { stderr.push(String(value)); events.push("header"); });
const client = new DaemonClient("http://127.0.0.1:8123");
const post = vi.spyOn(client, "post").mockImplementation(async (url, body) => {
events.push(url);
if (url === "/api/bundles/inspect") throw new Error("description unavailable");
expect(url).toBe("/api/bundles/install");
expect(body).toMatchObject({ plan, bundlePath: "/tmp/example.rigbundle" });
return { status, data: { status: status < 400 ? (plan ? "planned" : "completed") : "failed", retained: true } } as never;
});
const deps = runningDeps(client);
const program = new Command().exitOverride();
program.addCommand(bundleCommand(deps));
const oldExit = process.exitCode;
process.exitCode = undefined;
try {
await program.parseAsync(["node", "rig", "bundle", "install", "/tmp/example.rigbundle", "--target", "/tmp/destination", "--json", ...(plan ? ["--plan"] : [])]);
expect(process.exitCode).toBe(expectedExit);
expect(post.mock.calls.filter(([url]) => url === "/api/bundles/install")).toHaveLength(1);
expect(events.indexOf("header")).toBeGreaterThanOrEqual(0);
expect(events.indexOf("header")).toBeLessThan(events.indexOf("/api/bundles/install"));
expect(stderr.join(" ")).toContain("Bundle view not generated");
expect(stdout).toHaveLength(1);
expect(JSON.parse(stdout[0]!)).toMatchObject({ retained: true });
expect(deps.forbidden).not.toHaveBeenCalled();
} finally { process.exitCode = oldExit; }
});
it.each([false, true])("keeps local bundle up on /api/up after a failed view (plan=%s)", async (plan) => {
const events: string[] = [];
const output = vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => { events.push("header"); });
const client = new DaemonClient("http://127.0.0.1:8123");
const post = vi.spyOn(client, "post").mockImplementation(async url => {
events.push(url);
if (url === "/api/bundles/inspect") return { status: 500, data: { error: "view failed" } } as never;
return { status: 409, data: { error: "original conflict", retained: true } } as never;
});
const deps = runningDeps(client);
const program = new Command().exitOverride().addCommand(upCommand(deps));
const oldExit = process.exitCode;
process.exitCode = undefined;
try {
await program.parseAsync(["node", "rig", "up", "/tmp/example.rigbundle", "--json", ...(plan ? ["--plan"] : [])]);
expect(events).toEqual(["/api/bundles/inspect", "header", "/api/up"]);
expect(post.mock.calls[1]).toEqual([
"/api/up", expect.objectContaining({ sourceRef: "/tmp/example.rigbundle", plan }),
{ timeoutMs: 120_000 },
]);
expect(process.exitCode).toBe(1);
expect(output).toHaveBeenCalledTimes(1);
expect(JSON.parse(String(output.mock.calls[0]?.[0]))).toMatchObject({ retained: true });
expect(deps.forbidden).not.toHaveBeenCalled();
} finally { process.exitCode = oldExit; }
});
it("describes the selected-host archive on that host before preserving remote up", async () => {
const events: string[] = [];
const output = vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => { events.push("header"); });
vi.mocked(runRemoteHttpOp).mockImplementation(async (_host, _method, url) => {
events.push(url);
return url === "/api/bundles/inspect" ? { ok: false, failedStep: "remote-daemon-unreachable", error: "inspection unavailable" } : { ok: true, failedStep: "none", data: { retained: true } };
});
const deps = runningDeps(new DaemonClient("http://127.0.0.1:8123"));
const program = new Command().exitOverride().addCommand(upCommand(deps));
await program.parseAsync(["node", "rig", "up", "./remote.rigbundle", "--host", "selected", "--json"]);
expect(events).toEqual(["/api/bundles/inspect", "header", "/api/up"]);
expect(vi.mocked(runRemoteHttpOp).mock.calls[0]).toEqual([
"selected", "POST", "/api/bundles/inspect", { bundlePath: "./remote.rigbundle" }, deps, expect.anything(),
]);
expect(vi.mocked(runRemoteHttpOp).mock.calls[1]?.[3]).toMatchObject({ sourceRef: "./remote.rigbundle" });
expect(output).toHaveBeenCalledTimes(1);
expect(JSON.parse(String(output.mock.calls[0]?.[0]))).toMatchObject({ ok: true, data: { retained: true } });
expect(deps.forbidden).not.toHaveBeenCalled();
});
it.each(["/tmp/rig.yaml", "existing-rig"])("does not inspect or print a bundle header for %s", async source => {
const output = vi.spyOn(console, "log").mockImplementation(() => {});
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const client = new DaemonClient("http://127.0.0.1:8123");
vi.spyOn(client, "get").mockResolvedValue({ status: 200, data: [{ id: "existing", name: "existing-rig" }] } as never);
const post = vi.spyOn(client, "post").mockResolvedValue({ status: 201, data: { status: "completed" } } as never);
const program = new Command().exitOverride().addCommand(upCommand(runningDeps(client)));
await program.parseAsync(["node", "rig", "up", source, "--existing", "--json"]);
expect(post.mock.calls.map(([url]) => url)).toEqual(["/api/up"]);
expect(error).not.toHaveBeenCalled();
expect(output).toHaveBeenCalledTimes(1);
});
});