mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
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.
144 lines
6.6 KiB
TypeScript
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;
|
|
});
|
|
});
|