Files
openrig/packages/cli/test/workflow-errors.test.ts
mvschwarz 8250d702a2 Release 0.4.6
Workflows + multi-host coordination + factory foundations.

Deterministic workflow engine: strict spec validation (unknown
keys reject, unreachable steps and unguarded cycles fail); loop-
guard max_hops enforced at projection; waiting-exit replay
absorption; per-instance keepalive watchdogs auto-armed in the
routing transaction; startup sweep re-arms keepalives and
surfaces stuck instances; rig workflow continue relabeled to its
read-only inspector semantics. Full-featured spec language: role,
target.rig, preferred_targets, next_hop.on, invariants,
loop_guards, exception_routing, closure, gates, skill_refs,
spawn_budget with strict-keyset validation and shipped-spec
compat pinned by fixture round-trip.

Workflow CLI: new verbs rig workflow run (instantiate + follow
live to terminal; exit 3 = workflow failed distinct from
transport 1/2), rig workflow watch (mid-flight attach; snapshot-
first; drops reconnect then announced poll fallback), rig
workflow status (needs-attention rollup; proven-empty on a clean
fleet), rig workflow route (frontier re-target in one
transaction; the step does NOT advance). trace/list/show human
output is now formatted; --json byte-identical to before. Out-of-
band terminal closure of a live workflow-frontier packet now
rejects with workflow_frontier_packet.

Workflow web UI: new workflow catalog, per-instance detail + trail,
in-band rollup, visual topology renderer, and step-trail timeline
pages; new hooks for workflow reads + SSE stream (unscoped by
rig); pure layout math extracted for unit tests. UI acceptance
carried by per-slice merge-time real screenshots + the two-host
integration proof; twin fixture backfill routed as a non-blocking
0.4.7 fast-follow.

Exception + human-gate model: rig workflow resume redrive from
failed step (rebound, fresh capability-aware re-resolution, one
fresh max_hops window); exceptions become durable attention items
in the same transaction as the failure; maturity dial
orchestrator-first vs human-only per class, per workflow, per
host with default orchestrator-first; the attention band is
workflow-aware; the happy-path guarantee unchanged. Events:
additive workflow.resumed.

Multi-host coordination happy path: hosts register + select via
new dashboard host-config + indicator surfaces with sticky rig
host select consumed by observe/interactive verbs; remote-host
workspace surfaces readable via --host on the workspace tabs;
cross-host queue routing via rig queue create / handoff /
handoff-and-complete --host and member@rig@<host> destination
form (explicit-only, at-least-once + idempotent, successor-first
close-second, closure_target audit metadata); cross-host direct
coordination verbs (rig send, rig capture, rig transcript, rig
broadcast) gain an http transport branch reaching remote daemon
routes CLI-direct with byte-verbatim ssh preserved; fleet-
attention altitude rollup on the attention band.

Factory foundations: workflow-to-rig binding layer (rig workflow
instantiate / run --rig; roles resolve to seats by capability;
resolve-once, record-in-the-packet; stable seat coordinate); the
single-rig recursive-self-improvement factory MVP shipped starter
with an inner plan-implement-check-review-release loop, dogfood
decoupled + running out-of-band against the shipped product,
publish held at a human sign-off gate; member-exists instantiate
advisory + cross-rig wrapping workflow travelling the cross-host
queue path.

New agent runtime adapter joins the shipped runtime dial as a
first-class option; terminal provider gets first-class treatment
across the terminal launcher, send/capture/broadcast, and the new
provider-agnostic skill; daemon read-path hardening for the
multi-host + workflow load with no functional behavior change.

Two new bundled skills ship canonical + plugin with byte-parity
guard: a rig terminal open/views/status model (AGPL clean-room
rail) and a provider-agnostic vs cmux-specific delta.

Migrations: additive only. Existing v0.4.4 databases upgrade by
running rig daemon start on the new daemon.

Note: v0.4.5 was skipped; there is no cut between v0.4.4 and
v0.4.6. A pre-release fix-batch was applied to the reviewed tree
(product-name neutralization, superlative-prose scrub, shipped-
doctrine touch-ups).

See CHANGELOG.md and docs/releases/v0.4.6.md.
2026-07-09 15:03:26 -07:00

144 lines
6.6 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from "vitest";
import { describeDaemonRejection, formatThreePart } from "../src/commands/workflow-errors.js";
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: 1, port: 7433 })),
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
};
});
/**
* OPR.0.4.6.WF3 FR-5 — error-UX pins (commit 4). Every named daemon
* rejection renders the 3-part what/why/fix in human mode with the
* correct fix pointer; --json stays the raw body byte-identically;
* exit codes unchanged.
*/
describe("describeDaemonRejection (WF3 FR-5)", () => {
it("packet_not_on_frontier → explains the moved frontier, points at trace", () => {
const rej = describeDaemonRejection({ error: "packet_not_on_frontier", instanceId: "WF1" });
expect(rej?.fact).toContain("not on the instance frontier");
expect(rej?.consequence).toContain("frontier has moved");
expect(rej?.action).toContain("rig workflow trace WF1");
});
it("instance_not_active → names the actual state; fix pointer names ONLY shipped verbs (BR-1 — flipped at WF-5: resume is real now)", () => {
const rej = describeDaemonRejection({ error: "instance_not_active", message: "failed", instanceId: "WF1" });
expect(rej?.fact).toContain("failed");
expect(rej?.action).toContain("rig workflow show WF1");
// OPR.0.4.6.WF5 FR-4: the WF-3-era negative asserted NO resume
// pointer because the verb did not ship yet (BR-1: never point at
// a verb that does not exist). WF-5 shipped it, and the pointer
// upgraded exactly as the WF-3 comment promised — same BR-1
// principle, inverted assertion.
expect(rej?.action).toContain("rig workflow resume");
});
it("instance_version_conflict → names expected/actual, says whole-rollback, fix = re-read + retry", () => {
const rej = describeDaemonRejection({ error: "instance_version_conflict", expectedVersion: 4, actualVersion: 5 });
expect(rej?.fact).toContain("expected version 4");
expect(rej?.fact).toContain("actual 5");
expect(rej?.consequence).toContain("rolled back whole");
expect(rej?.action).toContain("retry");
});
it("exit_not_allowed → lists allowed exits when the body carries them", () => {
const rej = describeDaemonRejection({ error: "exit_not_allowed", allowedExits: ["handoff", "failed"] });
expect(rej?.action).toBe("Use one of: handoff | failed.");
});
it("no_next_step / next_owner_unresolved / instance_not_found all render with fixes", () => {
expect(describeDaemonRejection({ error: "no_next_step" })?.action).toContain("--exit done | failed");
expect(describeDaemonRejection({ error: "next_owner_unresolved" })?.action).toContain("--next-owner");
expect(describeDaemonRejection({ error: "instance_not_found" })?.action).toContain("rig workflow list");
});
it("unrecognized bodies return null (raw-JSON fallback preserved)", () => {
expect(describeDaemonRejection({ error: "something_new" })).toBeNull();
expect(describeDaemonRejection({ message: "no error code" })).toBeNull();
expect(describeDaemonRejection("string body")).toBeNull();
expect(describeDaemonRejection(null)).toBeNull();
});
it("formatThreePart renders the emit3PartError shape", () => {
const lines = formatThreePart({ fact: "f", consequence: "c", action: "a" });
expect(lines).toEqual(["Error: f", "c", "a"]);
});
});
describe("wire-level FR-5 behavior", () => {
afterEach(() => vi.restoreAllMocks());
function makeDeps(routes: Record<string, { status: number; data: unknown }>): WorkflowDeps {
return {
lifecycleDeps: {} as WorkflowDeps["lifecycleDeps"],
clientFactory: () =>
({
get: async (path: string) => routes[`GET ${path}`] ?? { status: 200, data: {} },
post: async (path: string, _body: unknown) => routes[`POST ${path}`] ?? { status: 200, data: {} },
}) as never,
};
}
const CONFLICT = {
status: 409,
data: { error: "instance_version_conflict", message: "conflict", expectedVersion: 1, actualVersion: 2 },
};
it("human mode: named 409 renders 3-part on stderr, exit code stays 1", async () => {
const errSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const program = createProgram({
workflowDeps: makeDeps({ "POST /api/workflow/project": CONFLICT }),
});
program.exitOverride();
process.exitCode = undefined;
await program.parseAsync([
"node", "rig", "workflow", "project",
"--instance", "WF1", "--current-packet", "Q1", "--exit", "handoff", "--actor-session", "a@r",
]);
const stderrText = errSpy.mock.calls.map((c) => String(c[0])).join("");
expect(stderrText).toContain("Error: A concurrent writer advanced the instance first");
// The raw JSON blob is NOT dumped in human mode for a named rejection.
expect(logSpy.mock.calls.map((c) => String(c[0])).join("")).not.toContain("instance_version_conflict");
expect(process.exitCode).toBe(1);
process.exitCode = undefined;
});
it("--json: the raw daemon error body passes through byte-identically", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const program = createProgram({
workflowDeps: makeDeps({ "POST /api/workflow/project": CONFLICT }),
});
program.exitOverride();
process.exitCode = undefined;
await program.parseAsync([
"node", "rig", "workflow", "project",
"--instance", "WF1", "--current-packet", "Q1", "--exit", "handoff", "--actor-session", "a@r", "--json",
]);
expect(logSpy).toHaveBeenCalledWith(JSON.stringify(CONFLICT.data));
expect(process.exitCode).toBe(1);
process.exitCode = undefined;
});
it("human mode: unrecognized 500 keeps the raw-JSON fallback and exit 2", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const program = createProgram({
workflowDeps: makeDeps({
"GET /api/workflow/WF1": { status: 500, data: { error: "internal_error", message: "boom" } },
}),
});
program.exitOverride();
process.exitCode = undefined;
await program.parseAsync(["node", "rig", "workflow", "show", "WF1"]);
expect(logSpy.mock.calls.map((c) => String(c[0])).join("")).toContain("internal_error");
expect(process.exitCode).toBe(2);
process.exitCode = undefined;
});
});