mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
rig workflow trace gains --full, which passes ?full=true to the daemon's existing GET /api/workflow/<id>/trace route; there is no new daemon route. Without the flag the request has no query, as before. --json prints the daemon payload unchanged. Human output adds one line naming the --full command when the trail came back with trailCompleteness recent. docs/as-built/cli-reference.md lists the option.
420 lines
18 KiB
TypeScript
420 lines
18 KiB
TypeScript
import { resolve } from "node:path";
|
|
import { writeFileSync, unlinkSync, mkdirSync, rmdirSync } from "node:fs";
|
|
import { randomUUID } from "node:crypto";
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import type { WorkflowDeps } from "../src/commands/workflow.js";
|
|
import { createProgram } from "../src/index.js";
|
|
|
|
vi.mock("../src/daemon-lifecycle.js", async () => {
|
|
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
|
|
return {
|
|
...actual,
|
|
getDaemonStatus: vi.fn(async () => ({
|
|
state: "running",
|
|
healthy: true,
|
|
pid: 1234,
|
|
port: 7433,
|
|
})),
|
|
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
|
|
};
|
|
});
|
|
|
|
interface StubResponse {
|
|
status: number;
|
|
data: unknown;
|
|
}
|
|
|
|
function makeDeps(opts?: {
|
|
routes?: Record<string, StubResponse>;
|
|
}): {
|
|
deps: WorkflowDeps;
|
|
calls: Array<{ method: string; path: string; body?: unknown }>;
|
|
} {
|
|
const calls: Array<{ method: string; path: string; body?: unknown }> = [];
|
|
const routes = opts?.routes ?? {};
|
|
return {
|
|
calls,
|
|
deps: {
|
|
lifecycleDeps: {} as WorkflowDeps["lifecycleDeps"],
|
|
clientFactory: () =>
|
|
({
|
|
get: vi.fn(async (path: string) => {
|
|
calls.push({ method: "GET", path });
|
|
return routes[`GET ${path}`] ?? { status: 200, data: {} };
|
|
}),
|
|
getText: vi.fn(async (path: string) => {
|
|
calls.push({ method: "GET", path });
|
|
return { status: 200, data: "" };
|
|
}),
|
|
post: vi.fn(async (path: string, body: unknown) => {
|
|
calls.push({ method: "POST", path, body });
|
|
return routes[`POST ${path}`] ?? { status: 200, data: {} };
|
|
}),
|
|
delete: vi.fn(async (path: string) => {
|
|
calls.push({ method: "DELETE", path });
|
|
return { status: 204, data: null };
|
|
}),
|
|
postText: vi.fn(async (path: string) => {
|
|
calls.push({ method: "POST", path });
|
|
return { status: 200, data: "" };
|
|
}),
|
|
postExpectText: vi.fn(async (path: string) => {
|
|
calls.push({ method: "POST", path });
|
|
return { status: 200, data: "" };
|
|
}),
|
|
}) as unknown as ReturnType<WorkflowDeps["clientFactory"]>,
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("rig workflow CLI (PL-004 Phase D)", () => {
|
|
let logs: string[];
|
|
let errors: string[];
|
|
|
|
beforeEach(() => {
|
|
logs = [];
|
|
errors = [];
|
|
vi.spyOn(console, "log").mockImplementation((...a) => logs.push(a.join(" ")));
|
|
vi.spyOn(console, "error").mockImplementation((...a) => errors.push(a.join(" ")));
|
|
process.exitCode = undefined;
|
|
});
|
|
|
|
it("workflow command registers the lifecycle inspection and control verbs", () => {
|
|
const { deps } = makeDeps();
|
|
const program = createProgram({ workflowDeps: deps });
|
|
const cmd = program.commands.find((c) => c.name() === "workflow");
|
|
expect(cmd).toBeDefined();
|
|
const subs = cmd!.commands.map((c) => c.name()).sort();
|
|
expect(subs).toContain("validate");
|
|
expect(subs).toContain("instantiate");
|
|
expect(subs).toContain("project");
|
|
expect(subs).toContain("list");
|
|
expect(subs).toContain("show");
|
|
expect(subs).toContain("trace");
|
|
expect(subs).toContain("continue");
|
|
expect(subs).toContain("compile");
|
|
expect(subs).toContain("instantiate-lifecycle");
|
|
expect(subs).toContain("abort");
|
|
});
|
|
|
|
it("validate POSTs /api/workflow/validate with specPath", async () => {
|
|
const { deps, calls } = makeDeps({
|
|
routes: { "POST /api/workflow/validate": { status: 200, data: { ok: true } } },
|
|
});
|
|
const program = createProgram({ workflowDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "workflow", "validate", "/abs/path.yaml", "--json"]);
|
|
const call = calls.find((c) => c.path === "/api/workflow/validate");
|
|
expect((call!.body as { specPath: string }).specPath).toBe("/abs/path.yaml");
|
|
});
|
|
|
|
it("compile POSTs the mission path and optional opaque operation key", async () => {
|
|
const { deps, calls } = makeDeps({
|
|
routes: { "POST /api/workflow/compile": { status: 200, data: { eligible: false, unknowns: ["missing"] } } },
|
|
});
|
|
const program = createProgram({ workflowDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "workflow", "compile", "/workspace/missions/release", "--operation-key", "release-op", "--json"]);
|
|
expect(calls.find((call) => call.path === "/api/workflow/compile")?.body).toEqual({
|
|
missionPath: "/workspace/missions/release",
|
|
operationKey: "release-op",
|
|
});
|
|
});
|
|
|
|
it("instantiate-lifecycle POSTs the complete keyed lifecycle request", async () => {
|
|
const { deps, calls } = makeDeps({
|
|
routes: { "POST /api/workflow/instantiate-lifecycle": { status: 201, data: { instance: { instanceId: "wf-1" }, entryQitemId: "q-1", replayed: false } } },
|
|
});
|
|
const program = createProgram({ workflowDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync([
|
|
"node", "rig", "workflow", "instantiate-lifecycle", "/workspace/missions/release",
|
|
"--operation-key", "release-op", "--root-objective", "ship", "--created-by", "orch@rig",
|
|
"--entry-owner", "builder@rig", "--rig", "rig", "--json",
|
|
]);
|
|
expect(calls.find((call) => call.path === "/api/workflow/instantiate-lifecycle")?.body).toEqual({
|
|
missionPath: "/workspace/missions/release",
|
|
operationKey: "release-op",
|
|
rootObjective: "ship",
|
|
createdBySession: "orch@rig",
|
|
entryOwnerSession: "builder@rig",
|
|
targetRig: "rig",
|
|
});
|
|
});
|
|
|
|
it.each(["compile", "instantiate-lifecycle"])("%s resolves paths in the caller before transport", async (verb) => {
|
|
const { deps, calls } = makeDeps();
|
|
for (const path of ["missions/release", resolve("missions/release")]) {
|
|
const program = createProgram({ workflowDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "workflow", verb, path, "--operation-key", "release-op", "--json",
|
|
...(verb === "instantiate-lifecycle" ? ["--root-objective", "ship", "--created-by", "orch@rig"] : [])]);
|
|
}
|
|
const requests = calls.filter((call) => call.path === `/api/workflow/${verb}`);
|
|
expect(requests).toHaveLength(2);
|
|
expect(requests[0]?.body).toEqual(requests[1]?.body);
|
|
expect(requests[0]?.body).toMatchObject({ missionPath: resolve("missions/release") });
|
|
});
|
|
|
|
it.each(["validate", "instantiate", "run"])("%s resolves caller-relative file paths before transport", async (verb) => {
|
|
const { deps, calls } = makeDeps();
|
|
const program = createProgram({ workflowDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "workflow", verb, "./workflows/selected.workflow.md", "--json",
|
|
...(verb === "validate" ? [] : ["--root-objective", "ship", "--created-by", "orch@rig"])]);
|
|
const route = verb === "validate" ? "/api/workflow/validate" : "/api/workflow/instantiate";
|
|
expect(calls.find(call => call.path === route)?.body).toMatchObject({ specPath: resolve("workflows/selected.workflow.md") });
|
|
});
|
|
|
|
it.each(["instantiate", "run"])("%s preserves bare discovered names while resolving an existing extensionless caller file", async (verb) => {
|
|
const { deps, calls } = makeDeps();
|
|
const file = `.workflow-path-${randomUUID()}`;
|
|
writeFileSync(file, "owned fixture");
|
|
try {
|
|
for (const source of ["conveyor", "cached.workflow.md", file]) {
|
|
const program = createProgram({ workflowDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "workflow", verb, source, "--json", "--root-objective", "ship", "--created-by", "orch@rig"]);
|
|
}
|
|
const requests = calls.filter(call => call.path === "/api/workflow/instantiate");
|
|
expect(requests[0]?.body).toMatchObject({ specPath: "conveyor" });
|
|
expect(requests[1]?.body).toMatchObject({ specPath: "cached.workflow.md" });
|
|
expect(requests[2]?.body).toMatchObject({ specPath: resolve(file) });
|
|
} finally {
|
|
unlinkSync(file);
|
|
}
|
|
});
|
|
|
|
it.each(["instantiate", "run"])("%s preserves a discovered name when a same-name directory exists", async (verb) => {
|
|
const { deps, calls } = makeDeps();
|
|
mkdirSync("conveyor");
|
|
try {
|
|
const program = createProgram({ workflowDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "workflow", verb, "conveyor", "--json", "--root-objective", "ship", "--created-by", "orch@rig"]);
|
|
expect(calls.find(call => call.path === "/api/workflow/instantiate")?.body).toMatchObject({ specPath: "conveyor" });
|
|
} finally {
|
|
rmdirSync("conveyor");
|
|
}
|
|
});
|
|
|
|
it("instantiate POSTs /api/workflow/instantiate with required fields", async () => {
|
|
const { deps, calls } = makeDeps({
|
|
routes: { "POST /api/workflow/instantiate": { status: 201, data: { instance: {}, entryQitemId: "q-1" } } },
|
|
});
|
|
const program = createProgram({ workflowDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync([
|
|
"node", "rig", "workflow", "instantiate", "/abs/spec.yaml",
|
|
"--root-objective", "test obj",
|
|
"--created-by", "ops@rig",
|
|
"--entry-owner", "alice@rig",
|
|
"--json",
|
|
]);
|
|
const call = calls.find((c) => c.path === "/api/workflow/instantiate");
|
|
const body = call!.body as Record<string, string>;
|
|
expect(body.specPath).toBe("/abs/spec.yaml");
|
|
expect(body.rootObjective).toBe("test obj");
|
|
expect(body.createdBySession).toBe("ops@rig");
|
|
expect(body.entryOwnerSession).toBe("alice@rig");
|
|
});
|
|
|
|
it("project POSTs /api/workflow/project with all fields incl exit", async () => {
|
|
const { deps, calls } = makeDeps({
|
|
routes: { "POST /api/workflow/project": { status: 200, data: { nextQitemId: "q-2" } } },
|
|
});
|
|
const program = createProgram({ workflowDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync([
|
|
"node", "rig", "workflow", "project",
|
|
"--instance", "inst-1",
|
|
"--current-packet", "q-1",
|
|
"--exit", "handoff",
|
|
"--actor-session", "producer@rig",
|
|
"--result-note", "produced",
|
|
"--json",
|
|
]);
|
|
const call = calls.find((c) => c.path === "/api/workflow/project");
|
|
const body = call!.body as Record<string, string>;
|
|
expect(body.instanceId).toBe("inst-1");
|
|
expect(body.currentPacketId).toBe("q-1");
|
|
expect(body.exit).toBe("handoff");
|
|
expect(body.actorSession).toBe("producer@rig");
|
|
expect(body.resultNote).toBe("produced");
|
|
});
|
|
|
|
it("instantiate summary names the instance and entry packet the daemon returned", async () => {
|
|
const { deps } = makeDeps({
|
|
routes: {
|
|
"POST /api/workflow/instantiate": {
|
|
status: 201,
|
|
data: {
|
|
instance: { instanceId: "wf-1", status: "active", boundRig: "rig" },
|
|
entryQitemId: "q-1",
|
|
entryOwnerSession: "dev-a@rig",
|
|
advisories: [],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const program = createProgram({ workflowDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync([
|
|
"node", "rig", "workflow", "instantiate", "/abs/spec.yaml",
|
|
"--root-objective", "test obj",
|
|
"--created-by", "ops@rig",
|
|
]);
|
|
const out = logs.join("\n");
|
|
expect(out).toMatch(/what:\s+Instantiated workflow from \/abs\/spec\.yaml \(instance wf-1\)/);
|
|
expect(out).toMatch(/state:\s+active; bound to rig rig; entry packet q-1 owned by dev-a@rig/);
|
|
expect(out).toMatch(/next:\s+Inspect: rig workflow show wf-1 \| Open packet: rig queue show q-1/);
|
|
});
|
|
|
|
it("project summary names the packet the daemon projected and its owner", async () => {
|
|
const { deps } = makeDeps({
|
|
routes: {
|
|
"POST /api/workflow/project": {
|
|
status: 200,
|
|
data: {
|
|
instance: { instanceId: "inst-1", status: "active" },
|
|
closurePriorPacketId: "q-1",
|
|
closureReason: "handoff",
|
|
nextQitemId: "q-2",
|
|
nextOwnerSession: "dev-b@rig",
|
|
nextStepId: "two",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const program = createProgram({ workflowDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync([
|
|
"node", "rig", "workflow", "project",
|
|
"--instance", "inst-1",
|
|
"--current-packet", "q-1",
|
|
"--exit", "handoff",
|
|
"--actor-session", "dev-a@rig",
|
|
]);
|
|
const out = logs.join("\n");
|
|
expect(out).toMatch(/what:\s+Closed q-1 \(handoff\) and projected q-2 to dev-b@rig/);
|
|
expect(out).toMatch(/state:\s+instance inst-1 = active/);
|
|
expect(out).toMatch(/next:\s+Inspect: rig queue show q-2/);
|
|
});
|
|
|
|
it("list GETs /api/workflow/list optionally with status filter", async () => {
|
|
const { deps, calls } = makeDeps({
|
|
routes: { "GET /api/workflow/list?status=active": { status: 200, data: [] } },
|
|
});
|
|
const program = createProgram({ workflowDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "workflow", "list", "--status", "active", "--json"]);
|
|
expect(calls.find((c) => c.path === "/api/workflow/list?status=active")).toBeDefined();
|
|
});
|
|
|
|
it("show GETs /api/workflow/:instanceId", async () => {
|
|
const { deps, calls } = makeDeps({
|
|
routes: { "GET /api/workflow/inst-1": { status: 200, data: { instanceId: "inst-1" } } },
|
|
});
|
|
const program = createProgram({ workflowDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "workflow", "show", "inst-1", "--json"]);
|
|
expect(calls.find((c) => c.path === "/api/workflow/inst-1")).toBeDefined();
|
|
});
|
|
|
|
it("trace GETs /api/workflow/:instanceId/trace", async () => {
|
|
const { deps, calls } = makeDeps({
|
|
routes: { "GET /api/workflow/inst-1/trace": { status: 200, data: { instance: {}, trail: [] } } },
|
|
});
|
|
const program = createProgram({ workflowDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "workflow", "trace", "inst-1", "--json"]);
|
|
expect(calls.find((c) => c.path === "/api/workflow/inst-1/trace")).toBeDefined();
|
|
});
|
|
|
|
it("trace without --full keeps the daemon's default (no ?full=true)", async () => {
|
|
const { deps, calls } = makeDeps({
|
|
routes: { "GET /api/workflow/inst-1/trace": { status: 200, data: { instance: {}, trail: [], trailCompleteness: "recent" } } },
|
|
});
|
|
const program = createProgram({ workflowDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "workflow", "trace", "inst-1", "--json"]);
|
|
expect(calls.map((c) => c.path)).toEqual(["/api/workflow/inst-1/trace"]);
|
|
});
|
|
|
|
it("trace --full passes ?full=true through and prints the complete trail", async () => {
|
|
const trail = Array.from({ length: 52 }, (_, i) => ({ stepId: `s${i}`, actorSession: "a@r", closureReason: "done" }));
|
|
const { deps, calls } = makeDeps({
|
|
routes: { "GET /api/workflow/inst-1/trace?full=true": { status: 200, data: { instance: {}, trail, trailCompleteness: "complete" } } },
|
|
});
|
|
const logs: string[] = [];
|
|
const spy = vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => { logs.push(a.join(" ")); });
|
|
try {
|
|
const program = createProgram({ workflowDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "workflow", "trace", "inst-1", "--full", "--json"]);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
expect(calls.map((c) => c.path)).toEqual(["/api/workflow/inst-1/trace?full=true"]);
|
|
const out = JSON.parse(logs.join("\n")) as { trail: unknown[]; trailCompleteness: string };
|
|
expect(out.trailCompleteness).toBe("complete");
|
|
expect(out.trail).toHaveLength(52);
|
|
});
|
|
|
|
it("trace in human mode says when the trail was cut and how to get all of it", async () => {
|
|
const { deps } = makeDeps({
|
|
routes: {
|
|
"GET /api/workflow/inst-1/trace": {
|
|
status: 200,
|
|
data: { instance: { instanceId: "inst-1", status: "active" }, trail: [], trailCompleteness: "recent" },
|
|
},
|
|
},
|
|
});
|
|
const logs: string[] = [];
|
|
const spy = vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => { logs.push(a.join(" ")); });
|
|
try {
|
|
const program = createProgram({ workflowDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "workflow", "trace", "inst-1"]);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
expect(logs.join("\n")).toContain("rig workflow trace inst-1 --full");
|
|
});
|
|
|
|
it("continue POSTs /api/workflow/:instanceId/continue", async () => {
|
|
const { deps, calls } = makeDeps({
|
|
routes: { "POST /api/workflow/inst-1/continue": { status: 200, data: { instance: {}, trail: [] } } },
|
|
});
|
|
const program = createProgram({ workflowDeps: deps });
|
|
program.exitOverride();
|
|
await program.parseAsync(["node", "rig", "workflow", "continue", "inst-1", "--json"]);
|
|
expect(calls.find((c) => c.path === "/api/workflow/inst-1/continue")).toBeDefined();
|
|
});
|
|
|
|
it("route, resume, and abort preserve exact packet/occurrence control selectors", async () => {
|
|
const { deps, calls } = makeDeps();
|
|
const routeProgram = createProgram({ workflowDeps: deps });
|
|
routeProgram.exitOverride();
|
|
await routeProgram.parseAsync([
|
|
"node", "rig", "workflow", "route", "wf-1", "--packet", "q-left", "--to", "left2@rig",
|
|
"--actor-session", "orch@rig", "--reason", "seat retired", "--json",
|
|
]);
|
|
const resumeProgram = createProgram({ workflowDeps: deps });
|
|
resumeProgram.exitOverride();
|
|
await resumeProgram.parseAsync([
|
|
"node", "rig", "workflow", "resume", "wf-1", "--occurrence", "q-failed",
|
|
"--actor-session", "orch@rig", "--decision", "retry exact branch", "--json",
|
|
]);
|
|
const abortProgram = createProgram({ workflowDeps: deps });
|
|
abortProgram.exitOverride();
|
|
await abortProgram.parseAsync([
|
|
"node", "rig", "workflow", "abort", "wf-1", "--reason", "operator stopped",
|
|
"--actor-session", "orch@rig", "--json",
|
|
]);
|
|
expect(calls.find((call) => call.path === "/api/workflow/wf-1/route")?.body).toMatchObject({ packetId: "q-left" });
|
|
expect(calls.find((call) => call.path === "/api/workflow/wf-1/resume")?.body).toMatchObject({ occurrenceId: "q-failed" });
|
|
expect(calls.find((call) => call.path === "/api/workflow/wf-1/abort")?.body).toEqual({ reason: "operator stopped", actorSession: "orch@rig" });
|
|
});
|
|
});
|