Files
openrig/packages/cli/test/host-selection.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

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