mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
Multi-host + Living Notes. Multi-host foundation: shareable whole-topology staged spin-up brings a rig topology up across multiple hosts in stages; VPS product-factory multi-host hardening ships with rig host verbs (add / list / doctor, capped at three; one built-in product-factory-vps posture, three- valued per item); the transport posture is documented (ssh for pane ops, http-bearer for daemon REST, no cross-transport fallback); the For-You feed aggregates activity across every registered host; and rig file moves files across the topology's registered hosts. Living Notes: durable INTENT / PLAN / DELIVERED signal layer at mission and slice altitude with agent authorship + timestamps; cheap composer surfaces make it the first-class place agents record decisions, plans, and delivered work; the one-structure review contract reads left-to-right as a single vertical stack (INTENT above, PLAN + mockup in the middle, DELIVERED with paired proof at the bottom); a plan change deletes the old plan and writes a new one - never demotes or stacks multiple competing plans. Operationalize the SDLC control plane: conventions SSOT ships in source at docs/reference/sdlc-conventions.md (Living Notes UI section names, proof-contract format + plannedRef mockup pairing, staged- approval locks, C1 proof header + closed sets, three role contracts, curation rule, elastic-middle doctrine, advisory fail-open audit posture); rig scope slice create scaffolds the convention sections + proof + PROOF.md + an IMPLEMENTATION-PRD.md skeleton for every template kind; rig scope audit + rig workspace doctor gain sdlc- convention advisories (records-and-advises, gates on HIGH only); the mission-slice-sop skill ships in canonical source with byte-parity guard; rig proof / rig scope slice create / rig scope slice approve help + cli-reference teach the SDLC flow. BREAKING: rig ps default flips to consolidated all-active-rigs compact projection (the v0.4.0 current-rig-only default is retired - it hid running rigs from the operator's field of view). --json is scope-not-shape (still a bare array with existing per-entry keys; scope widens to all non-archived rigs including stopped ones; one-line migration for the old fleet firehose: rig ps --nodes -A --full). -A / --all-rigs keeps exactly ONE meaning (the --nodes fleet widener; bare -A is a structured teaching error). --nodes names its scope everywhere (session default local-only; --host <id> --nodes requires --rig or -A; multi-host fan-out is rollup-only by default; the explicit ladder --all-hosts --nodes -A --full fans out per-node with hostId-stamped projected rows). --all-hosts / --hosts --json emits the shared AggregatedPayload (items + hosts with closed-enum statuses: ok | unreachable | unsupported-transport | auth-failed). Operator UX: agent altitude coordination panel scopes multi-agent state to the right altitude (workspace / mission / slice) so the coordination surface reads without per-seat drowning; composes with the workspace observability tabs from 0.4.1. Docs closeout catches docs/as-built/architecture.md, docs/as-built/cli-reference.md, and the codemaps up to shipped state. Migrations: additive only. Existing v0.4.3 databases upgrade by running rig daemon start. Behavior change (rig ps default view): the compact all-active-rigs projection replaces the current-rig-only default. If your call sites relied on the v0.4.3 default shape, add --full for the rich per-node projection or --rig <name> for the prior scope. Public-safety scrub applied inline to packages/ui/twin/corrective/ fixtures-corrective.ts (one demo-fixture role identifier neutralized to the demo-namespace baseline established at 0.4.1). See CHANGELOG.md and docs/releases/v0.4.4.md.
215 lines
7.9 KiB
TypeScript
215 lines
7.9 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
import http from "node:http";
|
|
import { DaemonClient, DaemonConnectionError } from "../src/client.js";
|
|
|
|
// Lightweight test server that echoes request info as JSON
|
|
function createEchoServer(): { server: http.Server; port: number; close: () => Promise<void> } {
|
|
const server = http.createServer((req, res) => {
|
|
const url = new URL(req.url!, `http://localhost`);
|
|
|
|
// POST /api/conflict -> 409 (for non-2xx test)
|
|
if (req.method === "POST" && url.pathname === "/api/conflict") {
|
|
res.writeHead(409, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: "already exists" }));
|
|
return;
|
|
}
|
|
|
|
// GET /api/rigs/:id/spec -> text/yaml (for getText test)
|
|
if (req.method === "GET" && url.pathname.endsWith("/spec")) {
|
|
res.writeHead(200, { "Content-Type": "text/yaml" });
|
|
res.end("schema_version: 1\nname: test-rig\n");
|
|
return;
|
|
}
|
|
|
|
// POST /api/echo-raw -> echoes content-type and raw body (for postText test)
|
|
if (req.method === "POST" && url.pathname === "/api/echo-raw") {
|
|
let body = "";
|
|
req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
|
|
req.on("end", () => {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ contentType: req.headers["content-type"], body }));
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Collect body for POST
|
|
let body = "";
|
|
req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
|
|
req.on("end", () => {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({
|
|
method: req.method,
|
|
path: url.pathname,
|
|
body: body || null,
|
|
}));
|
|
});
|
|
});
|
|
|
|
return {
|
|
server,
|
|
port: 0,
|
|
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
|
|
};
|
|
}
|
|
|
|
describe("DaemonClient", () => {
|
|
let echoServer: ReturnType<typeof createEchoServer>;
|
|
let baseUrl: string;
|
|
|
|
beforeAll(async () => {
|
|
echoServer = createEchoServer();
|
|
await new Promise<void>((resolve) => {
|
|
echoServer.server.listen(0, () => {
|
|
const addr = echoServer.server.address();
|
|
if (addr && typeof addr === "object") {
|
|
echoServer.port = addr.port;
|
|
baseUrl = `http://localhost:${addr.port}`;
|
|
}
|
|
resolve();
|
|
});
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await echoServer.close();
|
|
});
|
|
|
|
// Test 1: Client constructs correct URLs from base
|
|
it("constructs correct URLs from base", () => {
|
|
const client = new DaemonClient("http://localhost:9999");
|
|
expect(client.baseUrl).toBe("http://localhost:9999");
|
|
});
|
|
|
|
// Test 2: Client GET returns { status, data } with parsed JSON
|
|
it("GET returns { status, data } with parsed JSON", async () => {
|
|
const client = new DaemonClient(baseUrl);
|
|
const res = await client.get("/api/rigs");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.data).toEqual({
|
|
method: "GET",
|
|
path: "/api/rigs",
|
|
body: null,
|
|
});
|
|
});
|
|
|
|
// Test 3: Client POST sends body, returns { status, data }
|
|
it("POST sends body and returns { status, data }", async () => {
|
|
const client = new DaemonClient(baseUrl);
|
|
const res = await client.post("/api/rigs", { name: "test-rig" });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.data).toEqual({
|
|
method: "POST",
|
|
path: "/api/rigs",
|
|
body: JSON.stringify({ name: "test-rig" }),
|
|
});
|
|
});
|
|
|
|
// Test 4: Client handles connection refused -> throws DaemonConnectionError
|
|
it("connection refused throws DaemonConnectionError", async () => {
|
|
const client = new DaemonClient("http://localhost:1");
|
|
await expect(client.get("/api/rigs")).rejects.toThrow(DaemonConnectionError);
|
|
});
|
|
|
|
it("bounded timeout throws DaemonConnectionError instead of hanging forever", async () => {
|
|
const neverFetch: typeof fetch = ((_url: string | URL | Request, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
|
|
init?.signal?.addEventListener("abort", () => {
|
|
reject(init.signal?.reason ?? new Error("aborted"));
|
|
}, { once: true });
|
|
})) as typeof fetch;
|
|
const client = new DaemonClient("http://localhost:9999", { fetchImpl: neverFetch, timeoutMs: 20 });
|
|
|
|
await expect(client.get("/api/rigs")).rejects.toThrow(DaemonConnectionError);
|
|
await expect(client.get("/api/rigs")).rejects.toThrow(/timed out/i);
|
|
});
|
|
|
|
it("per-request timeout override can extend a long-running call", async () => {
|
|
const delayedFetch: typeof fetch = ((url: string | URL | Request, init?: RequestInit) => new Promise<Response>((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
resolve(new Response(JSON.stringify({ ok: true, url: String(url) }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}));
|
|
}, 40);
|
|
init?.signal?.addEventListener("abort", () => {
|
|
clearTimeout(timer);
|
|
reject(init.signal?.reason ?? new Error("aborted"));
|
|
}, { once: true });
|
|
})) as typeof fetch;
|
|
const client = new DaemonClient("http://localhost:9999", { fetchImpl: delayedFetch, timeoutMs: 20 });
|
|
|
|
await expect(client.get("/api/rigs")).rejects.toThrow(/timed out/i);
|
|
|
|
const res = await client.get<{ ok: boolean; url: string }>("/api/rigs", { timeoutMs: 100 });
|
|
expect(res.status).toBe(200);
|
|
expect(res.data.ok).toBe(true);
|
|
expect(res.data.url).toContain("/api/rigs");
|
|
});
|
|
|
|
// Test 5: Client uses OPENRIG_URL env, falls back to http://127.0.0.1:7433
|
|
it("uses OPENRIG_URL env when set, falls back to http://127.0.0.1:7433", () => {
|
|
// Default (no env)
|
|
const saved = process.env["OPENRIG_URL"];
|
|
delete process.env["OPENRIG_URL"];
|
|
const defaultClient = new DaemonClient();
|
|
expect(defaultClient.baseUrl).toBe("http://127.0.0.1:7433");
|
|
|
|
// With env
|
|
process.env["OPENRIG_URL"] = "http://custom:9000";
|
|
const envClient = new DaemonClient();
|
|
expect(envClient.baseUrl).toBe("http://custom:9000");
|
|
|
|
// Cleanup
|
|
if (saved !== undefined) {
|
|
process.env["OPENRIG_URL"] = saved;
|
|
} else {
|
|
delete process.env["OPENRIG_URL"];
|
|
}
|
|
});
|
|
|
|
// Test 6: CLI --version prints version string
|
|
it("CLI --version prints version string", async () => {
|
|
const { execFile } = await import("node:child_process");
|
|
const { promisify } = await import("node:util");
|
|
const execFileAsync = promisify(execFile);
|
|
const { resolve } = await import("node:path");
|
|
|
|
const cliEntry = resolve(import.meta.dirname, "../dist/index.js");
|
|
const result = await execFileAsync("node", [cliEntry, "--version"], {
|
|
cwd: resolve(import.meta.dirname, ".."),
|
|
});
|
|
|
|
expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+(?: \([0-9a-f]{8}(?:, dirty)?\))?$/);
|
|
});
|
|
|
|
// Test 7: Client non-2xx (409) -> returns { status: 409, data: errorBody }
|
|
it("non-2xx response returns { status, data } without throwing", async () => {
|
|
const client = new DaemonClient(baseUrl);
|
|
const res = await client.post("/api/conflict", {});
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(res.data).toEqual({ error: "already exists" });
|
|
});
|
|
|
|
// Test 8: postText sends raw text body with correct Content-Type
|
|
it("postText sends raw text body with text/yaml Content-Type", async () => {
|
|
const client = new DaemonClient(baseUrl);
|
|
const yaml = "schema_version: 1\nname: test\n";
|
|
const res = await client.postText<{ contentType: string; body: string }>("/api/echo-raw", yaml);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.data.contentType).toBe("text/yaml");
|
|
expect(res.data.body).toBe(yaml); // Raw text, NOT JSON-stringified
|
|
});
|
|
|
|
// Test 9: getText returns raw text body for non-JSON content (YAML export)
|
|
it("getText returns raw text body for text/yaml response", async () => {
|
|
const client = new DaemonClient(baseUrl);
|
|
const res = await client.getText("/api/rigs/r1/spec");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.data).toBe("schema_version: 1\nname: test-rig\n");
|
|
});
|
|
});
|