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.
89 lines
3.8 KiB
TypeScript
89 lines
3.8 KiB
TypeScript
// OPR.0.4.6.MH1 FR-1/FR-2 — the persisted host selection: local read
|
|
// resolution, the routing shim's precedence (flag > selection > local),
|
|
// and the zero-regression negative (no selection ≡ pre-MH1 behavior by
|
|
// construction).
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { readOwnHostName, readSelectedHost, resolveEffectiveHost } from "../src/host-selection.js";
|
|
|
|
describe("host selection (OPR.0.4.6.MH1)", () => {
|
|
let tmp: string;
|
|
const savedEnv: Record<string, string | undefined> = {};
|
|
|
|
beforeEach(() => {
|
|
tmp = mkdtempSync(join(tmpdir(), "mh1-sel-"));
|
|
for (const k of ["OPENRIG_HOME", "RIGGED_HOME", "OPENRIG_HOST_SELECTED", "OPENRIG_HOST_NAME"]) {
|
|
savedEnv[k] = process.env[k];
|
|
delete process.env[k];
|
|
}
|
|
process.env["OPENRIG_HOME"] = tmp;
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
for (const [k, v] of Object.entries(savedEnv)) {
|
|
if (v === undefined) delete process.env[k];
|
|
else process.env[k] = v;
|
|
}
|
|
});
|
|
|
|
it("defaults to 'local' with no config file (the zero-regression posture)", () => {
|
|
expect(readSelectedHost()).toBe("local");
|
|
// FR-2: no selection → undefined → every command's --host branch is
|
|
// byte-identically untaken.
|
|
expect(resolveEffectiveHost(undefined)).toBeUndefined();
|
|
});
|
|
|
|
it("reads the persisted selection from config.json (the daemon-written file)", () => {
|
|
writeFileSync(join(tmp, "config.json"), JSON.stringify({ host: { selected: "vps-a" } }));
|
|
expect(readSelectedHost()).toBe("vps-a");
|
|
expect(resolveEffectiveHost(undefined)).toBe("vps-a");
|
|
});
|
|
|
|
it("explicit --host wins over the persisted selection (precedence: flag > selection)", () => {
|
|
writeFileSync(join(tmp, "config.json"), JSON.stringify({ host: { selected: "vps-a" } }));
|
|
expect(resolveEffectiveHost("other-b")).toBe("other-b");
|
|
});
|
|
|
|
it("selection 'local' resolves to undefined (explicit return-to-local ≡ no selection)", () => {
|
|
writeFileSync(join(tmp, "config.json"), JSON.stringify({ host: { selected: "local" } }));
|
|
expect(resolveEffectiveHost(undefined)).toBeUndefined();
|
|
});
|
|
|
|
it("the env override (OPENRIG_HOST_SELECTED) wins over the file (store precedence env > file)", () => {
|
|
writeFileSync(join(tmp, "config.json"), JSON.stringify({ host: { selected: "vps-a" } }));
|
|
process.env["OPENRIG_HOST_SELECTED"] = "vps-env";
|
|
expect(readSelectedHost()).toBe("vps-env");
|
|
});
|
|
|
|
it("a malformed config file falls back to 'local' — read paths never break", () => {
|
|
writeFileSync(join(tmp, "config.json"), "{not json");
|
|
expect(readSelectedHost()).toBe("local");
|
|
expect(resolveEffectiveHost(undefined)).toBeUndefined();
|
|
});
|
|
|
|
// OPR.0.4.6.MH1 FR-4 — the own-host display name (same read discipline).
|
|
it("own-host name defaults to 'localhost' with no config (the FR-4 zero-regression posture)", () => {
|
|
expect(readOwnHostName()).toBe("localhost");
|
|
});
|
|
|
|
it("reads the persisted own-host name from config.json (the daemon-written file)", () => {
|
|
writeFileSync(join(tmp, "config.json"), JSON.stringify({ host: { name: "Mac mini 2" } }));
|
|
expect(readOwnHostName()).toBe("Mac mini 2");
|
|
});
|
|
|
|
it("the env override (OPENRIG_HOST_NAME) wins over the file for the own-host name", () => {
|
|
writeFileSync(join(tmp, "config.json"), JSON.stringify({ host: { name: "Mac mini 2" } }));
|
|
process.env["OPENRIG_HOST_NAME"] = "env-name";
|
|
expect(readOwnHostName()).toBe("env-name");
|
|
});
|
|
|
|
it("a malformed config file falls back to 'localhost' for the own-host name", () => {
|
|
writeFileSync(join(tmp, "config.json"), "{not json");
|
|
expect(readOwnHostName()).toBe("localhost");
|
|
});
|
|
});
|