Files
billbenetti a34cb55ad7 fix(cli): add --full to rig workflow trace so a long trail comes back complete (#2656) (#2667)
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.
2026-11-07 05:04:32 -08:00

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" });
});
});