mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
`rig export <rig>` with no -o no longer replaces an existing rig.yaml. It claims the next unused sibling (rig-export.yaml, then rig-export-2.yaml and so on) by creating it exclusively, writes the export there, and prints the path only after the write succeeds. An explicit -o keeps its replacement behaviour, including -o rig.yaml, and filesystem or export failures still exit nonzero. Command +39/-5, tests +79/-1.
769 lines
35 KiB
TypeScript
769 lines
35 KiB
TypeScript
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
|
import { createProgram } from "../src/index.js";
|
|
import http from "node:http";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import os from "node:os";
|
|
import { Command } from "commander";
|
|
import { exportCommand, type ExportDeps } from "../src/commands/export.js";
|
|
import { importCommand, type ImportDeps } from "../src/commands/import.js";
|
|
import { DaemonClient } from "../src/client.js";
|
|
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
|
|
|
|
function mockLifecycleDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
|
|
return {
|
|
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
|
|
fetch: vi.fn(async () => ({ ok: true })),
|
|
kill: vi.fn(() => true),
|
|
readFile: vi.fn(() => null),
|
|
writeFile: vi.fn(),
|
|
removeFile: vi.fn(),
|
|
exists: vi.fn(() => false),
|
|
mkdirp: vi.fn(),
|
|
openForAppend: vi.fn(() => 3),
|
|
isProcessAlive: vi.fn(() => true),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function captureLogs(fn: () => Promise<void>): Promise<string[]> {
|
|
return new Promise(async (resolve) => {
|
|
const logs: string[] = [];
|
|
const origLog = console.log;
|
|
const origErr = console.error;
|
|
const origWarn = console.warn;
|
|
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
|
console.error = (...args: unknown[]) => logs.push(args.join(" "));
|
|
console.warn = (...args: unknown[]) => logs.push(args.join(" "));
|
|
try { await fn(); } finally {
|
|
console.log = origLog;
|
|
console.error = origErr;
|
|
console.warn = origWarn;
|
|
}
|
|
resolve(logs);
|
|
});
|
|
}
|
|
|
|
function runningState(port: number): DaemonState {
|
|
return { pid: 123, port, db: "test.sqlite", startedAt: "2026-03-24T00:00:00Z" };
|
|
}
|
|
|
|
function runningLifecycleDeps(port: number): LifecycleDeps {
|
|
return mockLifecycleDeps({
|
|
exists: vi.fn((p: string) => p === STATE_FILE),
|
|
readFile: vi.fn((p: string) => {
|
|
if (p === STATE_FILE) return JSON.stringify(runningState(port));
|
|
return null;
|
|
}),
|
|
fetch: vi.fn(async () => ({ ok: true })),
|
|
});
|
|
}
|
|
|
|
function stoppedLifecycleDeps(): LifecycleDeps {
|
|
return mockLifecycleDeps({
|
|
exists: vi.fn(() => false),
|
|
fetch: vi.fn(async () => { throw new Error("refused"); }),
|
|
});
|
|
}
|
|
|
|
function unhealthyLifecycleDeps(): LifecycleDeps {
|
|
return mockLifecycleDeps({
|
|
exists: vi.fn((p: string) => p === STATE_FILE),
|
|
readFile: vi.fn((p: string) => {
|
|
if (p === STATE_FILE) return JSON.stringify(runningState(7433));
|
|
return null;
|
|
}),
|
|
isProcessAlive: vi.fn(() => true),
|
|
fetch: vi.fn(async () => { throw new Error("refused"); }),
|
|
});
|
|
}
|
|
|
|
// Track captured headers for import assertions
|
|
let capturedImportHeaders: Record<string, string | string[] | undefined> = {};
|
|
let capturedImportPath = "";
|
|
|
|
// Mock daemon for export/import
|
|
function createMockDaemon() {
|
|
const exports = new Map<string, { yaml: string; root: string }>();
|
|
const server = http.createServer((req, res) => {
|
|
const url = new URL(req.url!, "http://localhost");
|
|
|
|
// GET /api/rigs/:rigId/spec -> YAML
|
|
if (req.method === "GET" && url.pathname.match(/^\/api\/rigs\/[^/]+\/spec$/)) {
|
|
const rigId = url.pathname.split("/")[3]!;
|
|
const exported = exports.get(rigId);
|
|
if (exported) {
|
|
res.writeHead(200, { "Content-Type": "text/yaml", "X-Rig-Root": encodeURIComponent(exported.root) });
|
|
res.end(exported.yaml); return;
|
|
}
|
|
if (rigId === "missing") {
|
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: "rig not found" }));
|
|
return;
|
|
}
|
|
if (rigId === "broken") {
|
|
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
res.end("internal server error");
|
|
return;
|
|
}
|
|
if (rigId === "rig-with-startup") {
|
|
res.writeHead(200, { "Content-Type": "text/yaml", "X-Rig-Root": encodeURIComponent("/installed/项目/kernel") });
|
|
res.end("version: '0.2'\nname: rig-with-startup\nculture_file: missing-culture.md\nstartup:\n files:\n - path: missing-start.md\n required: true\n - path: optional-start.md\n required: false\n");
|
|
return;
|
|
}
|
|
res.writeHead(200, { "Content-Type": "text/yaml" });
|
|
res.end("schema_version: 1\nname: test-rig\nnodes: []\n");
|
|
return;
|
|
}
|
|
|
|
// POST /api/rigs/import/validate
|
|
if (req.method === "POST" && url.pathname === "/api/rigs/import/validate") {
|
|
let body = "";
|
|
req.on("data", (c: Buffer) => { body += c.toString(); });
|
|
req.on("end", () => {
|
|
if (body.includes("INVALID")) {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ valid: false, errors: ["bad yaml"] }));
|
|
} else {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ valid: true, errors: [] }));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
// POST /api/rigs/import/preflight
|
|
if (req.method === "POST" && url.pathname === "/api/rigs/import/preflight") {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ready: false, warnings: ["cmux unavailable"], errors: ["rig name exists"] }));
|
|
return;
|
|
}
|
|
|
|
// POST /api/rigs/import
|
|
if (req.method === "POST" && url.pathname === "/api/rigs/import") {
|
|
capturedImportHeaders = {
|
|
"x-rig-root": req.headers["x-rig-root"],
|
|
"x-cwd-override": req.headers["x-cwd-override"],
|
|
};
|
|
let body = "";
|
|
req.on("data", (c: Buffer) => { body += c.toString(); });
|
|
req.on("end", () => {
|
|
if (body.includes("CONFLICT")) {
|
|
res.writeHead(409, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: false, code: "preflight_failed", message: "conflict" }));
|
|
} else {
|
|
res.writeHead(201, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ rigId: "rig-new", specName: "imported-rig", specVersion: "0.1.0", nodes: [{ logicalId: "orchestrator", status: "launched" }, { logicalId: "worker", status: "launched" }], attachCommand: "tmux attach -t orch-lead@imported-rig", warnings: ["Startup submission unverified in worker@imported-rig"] }));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
// POST /api/rigs/import/materialize
|
|
if (req.method === "POST" && url.pathname === "/api/rigs/import/materialize") {
|
|
capturedImportPath = url.pathname;
|
|
capturedImportHeaders = {
|
|
"x-rig-root": req.headers["x-rig-root"],
|
|
"x-target-rig-id": req.headers["x-target-rig-id"],
|
|
"x-cwd-override": req.headers["x-cwd-override"],
|
|
};
|
|
res.writeHead(201, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({
|
|
rigId: req.headers["x-target-rig-id"] ?? "rig-new",
|
|
specName: "imported-rig",
|
|
specVersion: "0.2",
|
|
nodes: [{ logicalId: "research.scout", status: "materialized" }],
|
|
}));
|
|
return;
|
|
}
|
|
|
|
// POST /api/rigs/import/workspace
|
|
if (req.method === "POST" && url.pathname === "/api/rigs/import/workspace") {
|
|
capturedImportPath = url.pathname;
|
|
capturedImportHeaders = { "x-target-rig-id": req.headers["x-target-rig-id"] };
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({
|
|
rigId: req.headers["x-target-rig-id"],
|
|
changed: true,
|
|
workspace: { workspaceRoot: "/workspace", repos: [] },
|
|
}));
|
|
return;
|
|
}
|
|
|
|
// healthz
|
|
if (url.pathname === "/healthz") {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ status: "ok" }));
|
|
return;
|
|
}
|
|
|
|
res.writeHead(404);
|
|
res.end();
|
|
});
|
|
|
|
return {
|
|
server, exports,
|
|
close: () => new Promise<void>((r) => server.close(() => r())),
|
|
listen: () => new Promise<number>((r) => {
|
|
server.listen(0, () => {
|
|
const addr = server.address();
|
|
r(typeof addr === "object" && addr ? addr.port : 0);
|
|
});
|
|
}),
|
|
};
|
|
}
|
|
|
|
describe("rig export + import", () => {
|
|
let srv: ReturnType<typeof createMockDaemon>;
|
|
let port: number;
|
|
|
|
beforeAll(async () => {
|
|
srv = createMockDaemon();
|
|
port = await srv.listen();
|
|
});
|
|
afterAll(async () => { await srv.close(); });
|
|
|
|
function exportDeps(overrides?: Partial<ExportDeps>): ExportDeps {
|
|
return {
|
|
lifecycleDeps: runningLifecycleDeps(port),
|
|
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
|
|
writeFile: vi.fn(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function importDeps(fileContent: string, overrides?: Partial<ImportDeps>): ImportDeps {
|
|
return {
|
|
lifecycleDeps: runningLifecycleDeps(port),
|
|
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
|
|
readFile: vi.fn(() => fileContent),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// Test 1: export writes YAML to -o path
|
|
it("export: writes YAML to specified path", async () => {
|
|
const writeFile = vi.fn();
|
|
const deps = exportDeps({ writeFile });
|
|
const program = new Command();
|
|
program.addCommand(exportCommand(deps));
|
|
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "export", "rig-1", "-o", "out.yaml"]));
|
|
expect(writeFile).toHaveBeenCalledWith("out.yaml", "schema_version: 1\nname: test-rig\nnodes: []\n");
|
|
expect(logs.join("\n")).toContain("out.yaml");
|
|
});
|
|
|
|
// Test 2: export default path rig.yaml
|
|
it("export: default output path is rig.yaml", async () => {
|
|
const writeFile = vi.fn();
|
|
const deps = exportDeps({ writeFile });
|
|
const program = new Command();
|
|
program.addCommand(exportCommand(deps));
|
|
await captureLogs(() => program.parseAsync(["node", "rig", "export", "rig-1"]));
|
|
expect(writeFile).toHaveBeenCalledWith("rig.yaml", expect.any(String));
|
|
});
|
|
|
|
describe("export: a default export never replaces a file", () => {
|
|
const occupied = (...taken: string[]) => vi.fn((p: string) => {
|
|
if (taken.includes(p)) throw Object.assign(new Error(`EEXIST: ${p}`), { code: "EEXIST" });
|
|
});
|
|
const run = async (deps: Partial<ExportDeps>, ...args: string[]) => {
|
|
const program = new Command();
|
|
program.addCommand(exportCommand(exportDeps(deps)));
|
|
return captureLogs(() => program.parseAsync(["node", "rig", "export", "rig-1", ...args]));
|
|
};
|
|
|
|
it("the first export writes rig.yaml", async () => {
|
|
const writeNewFile = occupied();
|
|
const logs = await run({ writeNewFile });
|
|
expect(writeNewFile.mock.calls.map((c) => c[0])).toEqual(["rig.yaml"]);
|
|
expect(logs).toContain("Exported to rig.yaml");
|
|
expect(logs.join("\n")).not.toContain("left unchanged");
|
|
});
|
|
|
|
it("an occupied rig.yaml sends the export to a sibling, and says which", async () => {
|
|
const writeFile = vi.fn();
|
|
const writeNewFile = occupied("rig.yaml");
|
|
const exitCode = process.exitCode;
|
|
const logs = await run({ writeFile, writeNewFile });
|
|
expect(writeFile).not.toHaveBeenCalled();
|
|
expect(writeNewFile).toHaveBeenLastCalledWith("rig-export.yaml", expect.any(String));
|
|
expect(logs).toContain("Exported to rig-export.yaml");
|
|
expect(logs.join("\n")).toContain("rig.yaml already exists and was left unchanged");
|
|
expect(process.exitCode).toBe(exitCode);
|
|
});
|
|
|
|
it("occupied siblings are kept too", async () => {
|
|
const writeNewFile = occupied("rig.yaml", "rig-export.yaml", "rig-export-2.yaml");
|
|
const logs = await run({ writeNewFile });
|
|
expect(writeNewFile.mock.calls.map((c) => c[0])).toEqual(["rig.yaml", "rig-export.yaml", "rig-export-2.yaml", "rig-export-3.yaml"]);
|
|
expect(logs).toContain("Exported to rig-export-3.yaml");
|
|
});
|
|
|
|
it("-o replaces the file it names, including rig.yaml", async () => {
|
|
const writeFile = vi.fn();
|
|
const writeNewFile = occupied("rig.yaml");
|
|
const logs = await run({ writeFile, writeNewFile }, "-o", "rig.yaml");
|
|
expect(writeFile).toHaveBeenCalledWith("rig.yaml", expect.any(String));
|
|
expect(writeNewFile).not.toHaveBeenCalled();
|
|
expect(logs).toContain("Exported to rig.yaml");
|
|
});
|
|
|
|
it("a real write failure stays a failure, with no success reported", async () => {
|
|
const writeNewFile = vi.fn(() => { throw Object.assign(new Error("no space"), { code: "ENOSPC" }); });
|
|
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
try {
|
|
const program = new Command();
|
|
program.addCommand(exportCommand(exportDeps({ writeNewFile })));
|
|
await expect(program.parseAsync(["node", "rig", "export", "rig-1"])).rejects.toThrow("no space");
|
|
expect(writeNewFile).toHaveBeenCalledTimes(1);
|
|
expect(log).not.toHaveBeenCalledWith(expect.stringContaining("Exported to"));
|
|
} finally {
|
|
log.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
it("export: warns when culture_file or startup files do not resolve relative to output directory", async () => {
|
|
const writeFile = vi.fn();
|
|
const exists = vi.fn((p: string) => !p.includes("missing-culture.md") && !p.includes("missing-start.md") && !p.includes("optional-start.md"));
|
|
const deps = exportDeps({ writeFile, exists });
|
|
const program = new Command();
|
|
program.addCommand(exportCommand(deps));
|
|
|
|
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "export", "rig-with-startup", "-o", "specs/exported.yaml"]));
|
|
expect(writeFile).toHaveBeenCalled();
|
|
const output = logs.join("\n");
|
|
expect(output).toContain("Referenced file 'missing-culture.md' (came from /installed/项目/kernel) does not exist relative to");
|
|
expect(output).toContain("Referenced file 'missing-start.md' (came from /installed/项目/kernel) does not exist relative to");
|
|
expect(output).toContain("fails its startup until the file is copied next to it (or the export is imported with --rig-root /installed/项目/kernel)");
|
|
expect(output).not.toContain("optional-start.md");
|
|
});
|
|
|
|
it("export: checks required pod startup files in the output folder without duplicate warnings", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "export-pod-files-"));
|
|
const source = path.join(root, "source"), out = path.join(root, "out");
|
|
fs.mkdirSync(source);
|
|
fs.mkdirSync(out);
|
|
for (const file of ["shared.md", "pod.md", "present.md"]) fs.writeFileSync(path.join(source, file), file);
|
|
fs.copyFileSync(path.join(source, "present.md"), path.join(out, "present.md"));
|
|
const yaml = `version: '0.2'
|
|
name: pod-files
|
|
startup:
|
|
files:
|
|
- path: shared.md
|
|
pods:
|
|
- id: dev
|
|
label: Dev
|
|
startup:
|
|
files:
|
|
- path: shared.md
|
|
- path: pod.md
|
|
- path: present.md
|
|
- path: optional.md
|
|
required: false
|
|
members: []
|
|
edges: []
|
|
- id: quiet
|
|
label: Quiet
|
|
members: []
|
|
edges: []
|
|
edges: []
|
|
`;
|
|
srv.exports.set("pod-startup", { root: source, yaml });
|
|
try {
|
|
const output = path.join(out, "rig.yaml");
|
|
const deps = exportDeps({ writeFile: (p, text) => fs.writeFileSync(p, text) });
|
|
const run = () => new Command().addCommand(exportCommand(deps))
|
|
.parseAsync(["node", "rig", "export", "pod-startup", "-o", output]).then(() => {});
|
|
const logs = await captureLogs(run);
|
|
expect(fs.readFileSync(output, "utf-8")).toBe(yaml);
|
|
expect(logs.filter((line) => line.includes("Referenced file 'shared.md'"))).toHaveLength(1);
|
|
expect(logs.filter((line) => line.includes("Referenced file 'pod.md'"))).toHaveLength(1);
|
|
expect(logs.join("\n")).toContain(`does not exist relative to '${out}'`);
|
|
expect(logs.join("\n")).not.toContain("Referenced file 'present.md'");
|
|
expect(logs.join("\n")).not.toContain("optional.md");
|
|
|
|
for (const file of ["shared.md", "pod.md"]) fs.copyFileSync(path.join(source, file), path.join(out, file));
|
|
expect((await captureLogs(run)).join("\n")).not.toContain("Referenced file");
|
|
} finally {
|
|
srv.exports.delete("pod-startup");
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("#1631 export warns for a missing required member file and accepts it when copied beside the output", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "export-member-files-"));
|
|
const source = path.join(root, "source"), out = path.join(root, "out");
|
|
fs.mkdirSync(source); fs.mkdirSync(out);
|
|
fs.writeFileSync(path.join(source, "member.md"), "member guidance");
|
|
const yaml = `version: '0.2'
|
|
name: member-files
|
|
pods:
|
|
- id: dev
|
|
label: Dev
|
|
members:
|
|
- id: worker
|
|
agent_ref: local:agents/worker
|
|
runtime: codex
|
|
profile: default
|
|
cwd: .
|
|
startup:
|
|
files:
|
|
- path: member.md
|
|
required: true
|
|
- path: optional.md
|
|
required: false
|
|
edges: []
|
|
edges: []
|
|
`;
|
|
srv.exports.set("member-startup", { root: source, yaml });
|
|
try {
|
|
const output = path.join(out, "rig.yaml");
|
|
const client = { baseUrl: "http://127.0.0.1", get: vi.fn(async () => ({ status: 200, data: [{ id: "member-startup", name: "member-startup", archivedAt: null }] })),
|
|
getText: vi.fn(async () => ({ status: 200, data: yaml, headers: new Headers({ "X-Rig-Root": encodeURIComponent(source) }) })) } as unknown as DaemonClient;
|
|
const deps = exportDeps({ writeFile: (p, text) => fs.writeFileSync(p, text), clientFactory: () => client });
|
|
const run = () => new Command().addCommand(exportCommand(deps))
|
|
.parseAsync(["node", "rig", "export", "member-startup", "-o", output]).then(() => {});
|
|
const missing = (await captureLogs(run)).join("\n");
|
|
expect(missing).toContain("Referenced file 'member.md'");
|
|
expect(missing).not.toContain("optional.md");
|
|
fs.copyFileSync(path.join(source, "member.md"), path.join(out, "member.md"));
|
|
const delivered = (await captureLogs(run)).join("\n");
|
|
expect(delivered).not.toContain("Referenced file 'member.md'");
|
|
expect(fs.readFileSync(path.join(out, "member.md"), "utf8")).toBe("member guidance");
|
|
expect(fs.readFileSync(output, "utf8")).toBe(yaml);
|
|
} finally {
|
|
srv.exports.delete("member-startup");
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it.each(["copy", "existing", "symlink", "missing", "remote", "relative"].filter(mode => mode !== "symlink" || process.platform !== "win32"))("export: shares culture and explains nonportable refs/name (%s)", async mode => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "export-share-"));
|
|
const source = path.join(root, "source"), out = path.join(root, "out"), outside = path.join(root, "outside");
|
|
for (const dir of [source, out, outside]) fs.mkdirSync(dir);
|
|
fs.mkdirSync(path.join(source, "culture"));
|
|
const bytes = Buffer.from("# Team culture\r\nKeep authored bytes.\r\n");
|
|
if (mode !== "missing") fs.writeFileSync(path.join(source, "culture", "CULTURE.md"), bytes);
|
|
if (mode === "existing") { fs.mkdirSync(path.join(out, "culture")); fs.writeFileSync(path.join(out, "culture", "CULTURE.md"), "recipient's file"); }
|
|
if (mode === "symlink") fs.symlinkSync(outside, path.join(out, "culture"), "dir");
|
|
srv.exports.set("shared", { root: source, yaml: "version: '0.2'\nname: starter\nculture_file: culture/CULTURE.md\npods:\n - id: main\n members:\n - id: lead\n agent_ref: path:/author/agents/lead\n runtime: codex\n profile: default\n cwd: .\n edges: []\nedges: []\n".replace("path:/author/agents/lead", mode === "relative" ? "local:../../agents/x" : "path:/author/agents/lead") });
|
|
try {
|
|
const output = path.join(out, "rig.yaml");
|
|
const client = new DaemonClient(`http://127.0.0.1:${port}`);
|
|
const deps = exportDeps({ writeFile: (p, text) => fs.writeFileSync(p, text), clientFactory: () => mode === "remote"
|
|
? { baseUrl: "http://remote.example", get: client.get.bind(client), getText: client.getText.bind(client) } as DaemonClient : client });
|
|
const logs = await captureLogs(() => new Command().addCommand(exportCommand(deps))
|
|
.parseAsync(["node", "rig", "export", "shared", "-o", output]));
|
|
expect(fs.readFileSync(output, "utf8")).toContain("name: starter");
|
|
if (mode === "copy") expect(fs.readFileSync(path.join(out, "culture", "CULTURE.md"))).toEqual(bytes);
|
|
if (mode === "existing") {
|
|
expect(fs.readFileSync(path.join(out, "culture", "CULTURE.md"), "utf8")).toBe("recipient's file");
|
|
expect(logs.join("\n")).toContain("kept existing culture");
|
|
}
|
|
if (mode === "symlink") expect(fs.existsSync(path.join(outside, "CULTURE.md"))).toBe(false);
|
|
if (mode === "remote") expect(fs.existsSync(path.join(out, "culture", "CULTURE.md"))).toBe(false);
|
|
if (mode === "missing" || mode === "symlink" || mode === "remote") expect(logs.join("\n")).toContain("could not copy culture");
|
|
expect(logs.join("\n")).toContain("not portable");
|
|
expect(logs.join("\n")).toContain("main.lead");
|
|
expect(logs.join("\n")).toContain("starter");
|
|
expect(logs.join("\n")).toContain("rename");
|
|
} finally { srv.exports.delete("shared"); fs.rmSync(root, { recursive: true, force: true }); }
|
|
});
|
|
|
|
// Test 3: export rig not found (404)
|
|
it("export: rig not found -> error", async () => {
|
|
const deps = exportDeps();
|
|
const program = new Command();
|
|
program.addCommand(exportCommand(deps));
|
|
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "export", "missing"]));
|
|
expect(logs.join("\n")).toMatch(/not found/i);
|
|
});
|
|
|
|
// Test 4: import validate prints result
|
|
it("import validate: prints valid result", async () => {
|
|
const deps = importDeps("schema_version: 1\nname: test\n");
|
|
const program = new Command();
|
|
program.addCommand(importCommand(deps));
|
|
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "import", "rig.yaml"]));
|
|
expect(logs.join("\n")).toMatch(/valid/i);
|
|
});
|
|
|
|
// Test 5: import invalid YAML (400)
|
|
it("import: invalid YAML -> errors", async () => {
|
|
const deps = importDeps("INVALID");
|
|
const program = new Command();
|
|
program.addCommand(importCommand(deps));
|
|
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "import", "rig.yaml"]));
|
|
expect(logs.join("\n")).toMatch(/bad yaml|invalid/i);
|
|
});
|
|
|
|
// Test 6: import --instantiate prints per-node status
|
|
it("#729: import --instantiate: prints nodes", async () => {
|
|
const deps = importDeps("schema_version: 1\nname: test\n");
|
|
const program = new Command();
|
|
program.addCommand(importCommand(deps));
|
|
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "import", "rig.yaml", "--instantiate"]));
|
|
const output = logs.join("\n");
|
|
expect(output).toContain("imported-rig");
|
|
expect(output).toContain("orchestrator");
|
|
expect(output).toContain("worker");
|
|
// Must include per-node status, not just names
|
|
expect(output).toMatch(/orchestrator: launched/);
|
|
expect(output).toMatch(/worker: launched/);
|
|
expect(output).toContain("Warning: Startup submission unverified in worker@imported-rig");
|
|
});
|
|
|
|
// Test 7: import --preflight prints warnings + errors
|
|
it("import --preflight: prints warnings and errors", async () => {
|
|
const deps = importDeps("schema_version: 1\nname: test\n");
|
|
const program = new Command();
|
|
program.addCommand(importCommand(deps));
|
|
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "import", "rig.yaml", "--preflight"]));
|
|
const output = logs.join("\n");
|
|
expect(output).toContain("cmux unavailable");
|
|
expect(output).toContain("rig name exists");
|
|
});
|
|
|
|
it("import --materialize-only prints materialized nodes", async () => {
|
|
const deps = importDeps(`version: "0.2"\nname: test\npods: []\n`);
|
|
const program = new Command();
|
|
program.addCommand(importCommand(deps));
|
|
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "import", "rig.yaml", "--materialize-only", "--rig-root", "/tmp"]));
|
|
const output = logs.join("\n");
|
|
expect(output).toContain("imported-rig");
|
|
expect(output).toMatch(/research\.scout: materialized/);
|
|
});
|
|
|
|
it("import --materialize-only with --target-rig forwards target header", async () => {
|
|
const deps = importDeps(`version: "0.2"\nname: test\npods: []\n`);
|
|
const program = new Command();
|
|
program.addCommand(importCommand(deps));
|
|
await captureLogs(() => program.parseAsync(["node", "rig", "import", "rig.yaml", "--materialize-only", "--target-rig", "rig-123", "--rig-root", "/tmp"]));
|
|
expect(capturedImportHeaders["x-target-rig-id"]).toBe("rig-123");
|
|
});
|
|
|
|
it("import --materialize-only with --cwd forwards absolute cwd override", async () => {
|
|
capturedImportHeaders = {};
|
|
const deps = importDeps(`version: "0.2"\nname: test\npods: []\n`);
|
|
const program = new Command();
|
|
program.addCommand(importCommand(deps));
|
|
await captureLogs(() => program.parseAsync(["node", "rig", "import", "rig.yaml", "--materialize-only", "--cwd", "relative/project", "--rig-root", "/tmp"]));
|
|
expect(capturedImportHeaders["x-cwd-override"]).toMatch(/^\//);
|
|
expect(String(capturedImportHeaders["x-cwd-override"])).toContain("relative/project");
|
|
});
|
|
|
|
it("import --workspace-only targets the workspace-only route", async () => {
|
|
capturedImportHeaders = {};
|
|
capturedImportPath = "";
|
|
const deps = importDeps(`version: "0.2"\nname: test\nworkspace:\n workspace_root: /workspace\n repos: []\npods: []\n`);
|
|
const program = new Command();
|
|
program.addCommand(importCommand(deps));
|
|
const logs = await captureLogs(() => program.parseAsync([
|
|
"node", "rig", "import", "rig.yaml", "--workspace-only", "--target-rig", "rig-123",
|
|
]));
|
|
expect(capturedImportPath).toBe("/api/rigs/import/workspace");
|
|
expect(capturedImportHeaders["x-target-rig-id"]).toBe("rig-123");
|
|
expect(logs.join("\n")).toContain("Workspace applied to rig rig-123");
|
|
});
|
|
|
|
it("import --workspace-only requires --target-rig before HTTP", async () => {
|
|
capturedImportPath = "";
|
|
const deps = importDeps(`version: "0.2"\nname: test\nworkspace:\n workspace_root: /workspace\n repos: []\npods: []\n`);
|
|
const program = new Command();
|
|
program.addCommand(importCommand(deps));
|
|
const logs = await captureLogs(() => program.parseAsync([
|
|
"node", "rig", "import", "rig.yaml", "--workspace-only",
|
|
]));
|
|
expect(logs.join("\n")).toMatch(/--target-rig/);
|
|
expect(capturedImportPath).toBe("");
|
|
});
|
|
|
|
// Test 8: import --instantiate with preflight fail (409)
|
|
it("import --instantiate: preflight conflict (409)", async () => {
|
|
const deps = importDeps("CONFLICT");
|
|
const program = new Command();
|
|
program.addCommand(importCommand(deps));
|
|
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "import", "rig.yaml", "--instantiate"]));
|
|
expect(logs.join("\n")).toMatch(/conflict|failed/i);
|
|
});
|
|
|
|
// Test 9: export daemon stopped -> no HTTP
|
|
it("export: daemon stopped -> no HTTP", async () => {
|
|
const clientFactory = vi.fn();
|
|
const deps: ExportDeps = {
|
|
lifecycleDeps: stoppedLifecycleDeps(),
|
|
clientFactory: clientFactory as unknown as ExportDeps["clientFactory"],
|
|
writeFile: vi.fn(),
|
|
};
|
|
const program = new Command();
|
|
program.addCommand(exportCommand(deps));
|
|
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "export", "rig-1"]));
|
|
expect(logs.join("\n")).toMatch(/not running/i);
|
|
expect(clientFactory).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// Test 10: import uses stored port from daemon.json
|
|
it("import: uses stored port from daemon.json", async () => {
|
|
const usedUrls: string[] = [];
|
|
const deps: ImportDeps = {
|
|
lifecycleDeps: runningLifecycleDeps(port),
|
|
clientFactory: (baseUrl) => { usedUrls.push(baseUrl); return new DaemonClient(baseUrl); },
|
|
readFile: vi.fn(() => "schema_version: 1\nname: test\n"),
|
|
};
|
|
const program = new Command();
|
|
program.addCommand(importCommand(deps));
|
|
await captureLogs(() => program.parseAsync(["node", "rig", "import", "rig.yaml"]));
|
|
expect(usedUrls[0]).toBe(`http://127.0.0.1:${port}`);
|
|
});
|
|
|
|
// Test 11: createProgram: both export AND import mounted
|
|
it("createProgram mounts both export and import commands", async () => {
|
|
const stoppedLC = stoppedLifecycleDeps();
|
|
|
|
const exportD: ExportDeps = { lifecycleDeps: stoppedLC, clientFactory: vi.fn() as never, writeFile: vi.fn() };
|
|
const importD: ImportDeps = { lifecycleDeps: stoppedLC, clientFactory: vi.fn() as never, readFile: vi.fn(() => "yaml") };
|
|
|
|
// export mounted
|
|
const p1 = createProgram({ exportDeps: exportD });
|
|
const logs1 = await captureLogs(() => p1.parseAsync(["node", "rig", "export", "x"]));
|
|
expect(logs1.join("\n")).toMatch(/not running/i);
|
|
|
|
// import mounted
|
|
const p2 = createProgram({ importDeps: importD });
|
|
const logs2 = await captureLogs(() => p2.parseAsync(["node", "rig", "import", "x.yaml"]));
|
|
// Will hit "cannot read file" or "not running" — either proves it's mounted
|
|
expect(logs2.join("\n").length).toBeGreaterThan(0);
|
|
});
|
|
|
|
// Test 12: export with a busy daemon (process alive, health check failing) -> the real request runs.
|
|
// Changed: this used to refuse with no HTTP. A busy daemon is not refused; its answer decides.
|
|
it("export: busy daemon -> the real request is made", async () => {
|
|
const getText = vi.fn(async () => ({ status: 404, data: "" }));
|
|
const clientFactory = vi.fn(() => ({ getText }));
|
|
const deps: ExportDeps = {
|
|
lifecycleDeps: unhealthyLifecycleDeps(),
|
|
clientFactory: clientFactory as unknown as ExportDeps["clientFactory"],
|
|
writeFile: vi.fn(),
|
|
};
|
|
const program = new Command();
|
|
program.addCommand(exportCommand(deps));
|
|
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "export", "rig-1"]));
|
|
expect(getText).toHaveBeenCalledTimes(1);
|
|
expect(logs.join("\n")).toContain("Rig 'rig-1' not found");
|
|
});
|
|
|
|
// Test 13: import daemon stopped -> no HTTP
|
|
it("import: daemon stopped -> no HTTP", async () => {
|
|
const clientFactory = vi.fn();
|
|
const deps: ImportDeps = {
|
|
lifecycleDeps: stoppedLifecycleDeps(),
|
|
clientFactory: clientFactory as unknown as ImportDeps["clientFactory"],
|
|
readFile: vi.fn(() => "yaml"),
|
|
};
|
|
const program = new Command();
|
|
program.addCommand(importCommand(deps));
|
|
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "import", "rig.yaml"]));
|
|
expect(logs.join("\n")).toMatch(/not running/i);
|
|
expect(clientFactory).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// Test 14: import missing file -> clear error, no HTTP
|
|
it("import: missing file -> error, no HTTP", async () => {
|
|
const clientFactory = vi.fn();
|
|
const deps: ImportDeps = {
|
|
lifecycleDeps: runningLifecycleDeps(port),
|
|
clientFactory: clientFactory as unknown as ImportDeps["clientFactory"],
|
|
readFile: vi.fn(() => { throw new Error("ENOENT"); }),
|
|
};
|
|
const program = new Command();
|
|
program.addCommand(importCommand(deps));
|
|
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "import", "missing.yaml"]));
|
|
expect(logs.join("\n")).toMatch(/cannot read/i);
|
|
expect(clientFactory).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// Test 15: export 500 -> generic error
|
|
it("export: 500 -> generic error", async () => {
|
|
const deps = exportDeps();
|
|
const program = new Command();
|
|
program.addCommand(exportCommand(deps));
|
|
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "export", "broken"]));
|
|
expect(logs.join("\n")).toMatch(/failed|error/i);
|
|
});
|
|
|
|
// T5: import --instantiate pod-aware + --rig-root -> sends X-Rig-Root header
|
|
it("import --instantiate pod-aware with --rig-root sends X-Rig-Root header", async () => {
|
|
capturedImportHeaders = {};
|
|
const deps = importDeps("schema_version: 1\nname: test\npods:\n - name: pod-a\n");
|
|
const program = new Command();
|
|
program.addCommand(importCommand(deps));
|
|
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "import", "rig.yaml", "--instantiate", "--rig-root", "/my/project"]));
|
|
const output = logs.join("\n");
|
|
expect(output).toContain("imported-rig");
|
|
// Verify X-Rig-Root header was sent
|
|
expect(capturedImportHeaders["x-rig-root"]).toMatch(/\/my\/project/);
|
|
});
|
|
|
|
it("import --instantiate with --cwd forwards absolute cwd override", async () => {
|
|
capturedImportHeaders = {};
|
|
const deps = importDeps("schema_version: 1\nname: test\npods:\n - name: pod-a\n");
|
|
const program = new Command();
|
|
program.addCommand(importCommand(deps));
|
|
await captureLogs(() => program.parseAsync(["node", "rig", "import", "rig.yaml", "--instantiate", "--cwd", "relative/project", "--rig-root", "/my/project"]));
|
|
expect(capturedImportHeaders["x-cwd-override"]).toMatch(/^\//);
|
|
expect(String(capturedImportHeaders["x-cwd-override"])).toContain("relative/project");
|
|
});
|
|
|
|
// NS-T14: import --instantiate handoff includes attach command
|
|
it("import --instantiate success shows attach command", async () => {
|
|
const deps: ImportDeps = {
|
|
lifecycleDeps: runningLifecycleDeps(port),
|
|
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
|
|
readFile: () => 'version: "0.2"\nname: test\npods:\n - id: dev\n label: Dev\n members:\n - id: impl\n agent_ref: "local:agents/impl"\n profile: default\n runtime: claude-code\n cwd: .\n edges: []\nedges: []',
|
|
};
|
|
const program = new Command();
|
|
program.addCommand(importCommand(deps));
|
|
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "import", "rig.yaml", "--instantiate"]));
|
|
const output = logs.join("\n");
|
|
expect(output).toContain("Attach:");
|
|
expect(output).toContain("tmux attach -t orch-lead@imported-rig");
|
|
});
|
|
|
|
it("import --instantiate uses a long-running daemon timeout budget", async () => {
|
|
let timeoutMs: number | undefined;
|
|
const postText = vi.fn(async (
|
|
_path: string,
|
|
_text: string,
|
|
_contentType?: string,
|
|
_extraHeaders?: Record<string, string>,
|
|
options?: { timeoutMs?: number },
|
|
) => {
|
|
timeoutMs = options?.timeoutMs;
|
|
return {
|
|
status: 201,
|
|
data: {
|
|
rigId: "rig-new",
|
|
specName: "imported-rig",
|
|
specVersion: "0.1.0",
|
|
nodes: [{ logicalId: "orchestrator", status: "launched" }],
|
|
attachCommand: "tmux attach -t orch-lead@imported-rig",
|
|
},
|
|
};
|
|
});
|
|
|
|
const deps: ImportDeps = {
|
|
lifecycleDeps: runningLifecycleDeps(port),
|
|
clientFactory: () => ({ postText } as unknown as DaemonClient),
|
|
readFile: vi.fn(() => 'version: "0.2"\nname: test\npods:\n - id: dev\n label: Dev\n members:\n - id: impl\n agent_ref: "local:agents/impl"\n profile: default\n runtime: claude-code\n cwd: .\n edges: []\nedges: []'),
|
|
};
|
|
|
|
const program = new Command();
|
|
program.addCommand(importCommand(deps));
|
|
|
|
await captureLogs(() => program.parseAsync(["node", "rig", "import", "rig.yaml", "--instantiate"]));
|
|
|
|
expect(timeoutMs).toBe(120_000);
|
|
});
|
|
});
|