feat(tui): Phase-1 shell skeleton — one view-state, four input adapters, §4.A-only daemon client

packages/tui (@openrig/tui, zero runtime deps) carried over from the Phase-0
spike shape: instance-scoped view-state with one mutation path (PIN 1,
FR-12/13 negative ACs tested), safe-core grammar, hand-rolled ANSI renderer
behind the renderScreen seam, control-socket adapter (arch boundary constraint
pinned in module + test; $OPENRIG_HOME/run socket home, sun_path guard),
DaemonClient as the single one-file §4.A surface (route audit test). Wired
into workspaces + root lint. 29 vitest + typecheck + build GREEN; repo
scripts 51/51.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
v-openrig-buildandClaude Fable 5 committed 2026-08-03 09:32:29 +00:00
1 parent 1e6db0fe59
commit 9da75d6fc3
20 files changed
+1431 -3

No files matched your search

+18 -1
View File
@@ -11,7 +11,8 @@
"workspaces": [
"packages/daemon",
"packages/ui",
"packages/cli"
"packages/cli",
"packages/tui"
],
"engines": {
"node": "^20 || ^22 || ^24"
@@ -1166,6 +1167,10 @@
"resolved": "packages/daemon",
"link": true
},
"node_modules/@openrig/tui": {
"resolved": "packages/tui",
"link": true
},
"node_modules/@openrig/ui": {
"resolved": "packages/ui",
"link": true
@@ -7504,6 +7509,18 @@
"node": "^20 || ^22 || ^24"
}
},
"packages/tui": {
"name": "@openrig/tui",
"version": "0.1.0",
"bin": {
"openrig-tui": "dist/main.js"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.4.7",
+3 -2
View File
@@ -7,14 +7,15 @@
"workspaces": [
"packages/daemon",
"packages/ui",
"packages/cli"
"packages/cli",
"packages/tui"
],
"scripts": {
"build": "npm run build --workspaces",
"test": "npm run test:repo && npm run test:workspaces",
"test:repo": "node --test scripts/*.test.mjs && node scripts/check-docs-guard.mjs && node scripts/mirror-skills.mjs --check",
"test:workspaces": "npm run test --workspaces",
"lint": "tsc --noEmit --project packages/daemon/tsconfig.json && tsc --noEmit --project packages/ui/tsconfig.json && tsc --noEmit --project packages/cli/tsconfig.json",
"lint": "tsc --noEmit --project packages/daemon/tsconfig.json && tsc --noEmit --project packages/ui/tsconfig.json && tsc --noEmit --project packages/cli/tsconfig.json && tsc --noEmit --project packages/tui/tsconfig.json",
"build:package": "bash scripts/build-package.sh",
"mirror-skills": "node scripts/mirror-skills.mjs",
"mirror-skills:check": "node scripts/mirror-skills.mjs --check"
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@openrig/tui",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Mission-control TUI — explorer/master-detail over the daemon's existing projections; one tile in herdr's wall",
"main": "dist/index.js",
"bin": {
"openrig-tui": "dist/main.js"
},
"scripts": {
"build": "tsc && chmod +x dist/main.js",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}
+70
View File
@@ -0,0 +1,70 @@
// The TUI's ONLY daemon surface — thin typed fetch wrappers over the §4.A
// endpoint table, ONE module by design: the R7 no-new-data source-check is a
// one-file read (every route below is an EXISTING, web-consumed daemon read).
// Daemon-direct, never via the Studio serve-shell (FR-9). Phase 1 ships the
// wrappers; Phase 2 binds the three sections to them.
export interface DaemonClientOptions {
baseUrl?: string;
fetchImpl?: typeof fetch;
}
export class DaemonClient {
readonly baseUrl: string;
private readonly fetchImpl: typeof fetch;
constructor(options: DaemonClientOptions = {}) {
this.baseUrl = (options.baseUrl ?? process.env["OPENRIG_URL"] ?? "http://127.0.0.1:7433").replace(/\/$/, "");
this.fetchImpl = options.fetchImpl ?? fetch;
}
private async get(route: string): Promise<unknown> {
const res = await this.fetchImpl(`${this.baseUrl}${route}`);
if (!res.ok) throw new Error(`daemon read failed: GET ${route} → ${res.status}`);
return res.json();
}
// --- Topology (§4.A rows 1–3) ---
rigGraph(rigId: string) {
return this.get(`/api/rigs/${encodeURIComponent(rigId)}/graph`);
}
ps() {
return this.get(`/api/ps`);
}
rigsSummary() {
return this.get(`/api/rigs/summary`);
}
rigNodes(rigId: string) {
return this.get(`/api/rigs/${encodeURIComponent(rigId)}/nodes`);
}
reviewAgents(scope: "rig" | "fleet" = "rig") {
return this.get(`/api/review/agents?scope=${scope}`);
}
// --- Specs (§4.A row 4) ---
specsLibrary(kind?: string) {
return this.get(`/api/specs/library${kind ? `?kind=${encodeURIComponent(kind)}` : ""}`);
}
rigSpec(rigId: string) {
return this.get(`/api/rigs/${encodeURIComponent(rigId)}/spec.json`);
}
specsReview() {
return this.get(`/api/specs/review`);
}
// --- Needs-You (§4.A rows 5–6): composeNeedsYou legs + host/rig-down beside ---
queueAttention() {
return this.get(`/api/queue/list?attention=1`);
}
reviewRig() {
return this.get(`/api/review/rig`);
}
reviewFleet() {
return this.get(`/api/review/fleet`);
}
attentionAggregate() {
return this.get(`/api/queue/attention-aggregate`);
}
rigStatus(rigId: string) {
return this.get(`/api/rigs/${encodeURIComponent(rigId)}/status`);
}
}
+50
View File
@@ -0,0 +1,50 @@
// Demo fixture (clearly labeled): the shape the §4.A reads hydrate, with stub
// values for tests + `openrig-tui --demo` manual runs. Never used unless
// --demo is passed; the real path is the daemon reads (Phase 2 binding).
import type { FleetSnapshot } from "./types.js";
export function demoSnapshot(): FleetSnapshot {
return {
hosts: [
{
name: "vm-host",
reachable: true,
rigs: [
{
name: "openrig-build",
pods: [
{
name: "dev50",
agents: [
{ name: "dev50.driver", runtime: "claude-code", spec: "driver-agent", context: 62, tokens: "118k", status: "running" },
{ name: "dev50.guard", runtime: "codex", spec: "guard-agent", context: 31, tokens: "54k", status: "idle" },
{ name: "dev50.qa", runtime: "codex", spec: "qa-agent", context: null, tokens: null, status: "unknown" },
],
},
{
name: "orch",
agents: [
{ name: "orch.lead", runtime: "codex", spec: "lead-agent", context: 88, tokens: "203k", status: "needs-attention" },
],
},
],
},
],
},
{ name: "mm2-host", reachable: false, rigs: [] },
],
specs: [
{ name: "openrig-build-rig", kind: "rig", agentRefs: ["driver-agent", "guard-agent", "qa-agent", "lead-agent"] },
{ name: "driver-agent", kind: "agent", runtime: "claude-code", usedByRigs: ["openrig-build-rig"] },
{ name: "guard-agent", kind: "agent", runtime: "codex", usedByRigs: ["openrig-build-rig"] },
{ name: "qa-agent", kind: "agent", runtime: "codex", usedByRigs: ["openrig-build-rig"] },
{ name: "lead-agent", kind: "agent", runtime: "codex", usedByRigs: ["openrig-build-rig"] },
],
needs: [
{ kind: "idle-with-work", target: "dev50.guard", detail: "assigned work, idle 42m" },
{ kind: "host-down", target: "mm2-host", detail: "unreachable 12m" },
],
humanQueueProbed: true,
humanQueue: [],
};
}
+49
View File
@@ -0,0 +1,49 @@
// Safe-core command grammar (§4.B / FR-1): :section jump · /text filter ·
// <resource> <name> drill · spec-of / running cross-nav. k9s-primary taxonomy.
// parseCommand is pure text → action; target existence is validated by
// dispatch, so every input adapter shares one failure surface.
// Richer grammar (compound commands, prefixes, history/completion) was
// RETIRED by the Phase-0 kill-criterion — see the spike verdict.
import type { Action, ResourceKind } from "./types.js";
const SECTIONS = ["topology", "specs", "needs"] as const;
const RESOURCES: ResourceKind[] = ["host", "rig", "pod", "agent", "spec"];
export function parseCommand(raw: string): Action {
const input = raw.trim();
if (input === "") return { type: "noop" };
if (input.startsWith(":")) {
const section = input.slice(1).trim();
if ((SECTIONS as readonly string[]).includes(section)) return { type: "jump", section };
return {
type: "error",
message: `unknown section ":${section}" — known: ${SECTIONS.map((s) => ":" + s).join(" ")}`,
};
}
if (input.startsWith("/")) {
return { type: "filter", text: input.slice(1).trim() };
}
const [verb = "", ...rest] = input.split(/\s+/);
const name = rest.join(" ");
if (verb === "spec-of" || verb === "running") {
if (!name) {
const example = verb === "spec-of" ? "spec-of dev.driver" : "running driver-agent";
return { type: "error", message: `${verb} needs a target name (e.g. "${example}")` };
}
return { type: "cross", kind: verb, name };
}
if ((RESOURCES as string[]).includes(verb)) {
if (!name) return { type: "error", message: `${verb} drill needs a name (e.g. "${verb} <name>")` };
return { type: "drill", resource: verb as ResourceKind, name };
}
return {
type: "error",
message: `unknown command "${verb}" — known: :<section> /<filter> ${RESOURCES.join("|")} <name>, spec-of <agent>, running <spec>`,
};
}
+8
View File
@@ -0,0 +1,8 @@
export * from "./types.js";
export { parseCommand } from "./grammar.js";
export { createViewState, defaultSections, emptySnapshot, computeExplorerRows, findAgent, findSpec, findRig, agentsRunningSpec } from "./state.js";
export { decodeInput, sgrClick, MOUSE_ENABLE, MOUSE_DISABLE, ALT_SCREEN_ON, ALT_SCREEN_OFF } from "./input.js";
export { renderScreen } from "./render.js";
export { createControlSocket, defaultSocketPath, describeState, MAX_SOCKET_PATH_BYTES } from "./socket-server.js";
export { DaemonClient } from "./daemon-client.js";
export { demoSnapshot } from "./demo-data.js";
+69
View File
@@ -0,0 +1,69 @@
// Keyboard + mouse byte decoding. Mouse uses xterm SGR (1006) reporting:
// ESC [ < b ; x ; y M/m — the standard tmux/iTerm/Terminal.app mouse encoding.
// Mouse events are resolved against the renderer's hit-map by the caller and
// then dispatched through the SAME dispatch as commands and keys (PIN 1).
import type { InputEvent } from "./types.js";
const SGR_MOUSE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
export function decodeInput(bytes: string | Buffer): InputEvent[] {
const text = typeof bytes === "string" ? bytes : bytes.toString("utf8");
const events: InputEvent[] = [];
const rest = text.replace(SGR_MOUSE, (_, b: string, x: string, y: string, kind: string) => {
const button = Number(b);
if (kind === "M" && (button & 3) !== 3 && button < 32) {
// press of button 0/1/2 (32+ are motion/wheel — out of the safe core)
events.push({ type: "mouse", button: button & 3, x: Number(x), y: Number(y) });
}
return "";
});
let i = 0;
while (i < rest.length) {
const ch = rest[i] ?? "";
if (ch === "\x1b" && rest[i + 1] === "[") {
const code = rest[i + 2];
const key = code === "A" ? "up" : code === "B" ? "down" : code === "C" ? "right" : code === "D" ? "left" : null;
if (key) {
events.push({
type: "key",
key,
action: { type: "select", delta: key === "down" ? 1 : key === "up" ? -1 : 0 },
});
i += 3;
continue;
}
i += 1;
continue;
}
if (ch === "\r" || ch === "\n") {
events.push({ type: "key", key: "enter", action: { type: "activate" } });
i += 1;
continue;
}
if (ch === "\x7f" || ch === "\b") {
events.push({ type: "key", key: "backspace" });
i += 1;
continue;
}
if (ch === "\x1b") {
events.push({ type: "key", key: "escape" });
i += 1;
continue;
}
if (ch >= " ") events.push({ type: "char", ch });
i += 1;
}
return events;
}
/** Test/automation helper: the SGR bytes a terminal emits for a left click at (x, y). */
export function sgrClick(x: number, y: number): string {
return `\x1b[<0;${x};${y}M\x1b[<0;${x};${y}m`;
}
export const MOUSE_ENABLE = "\x1b[?1000h\x1b[?1006h";
export const MOUSE_DISABLE = "\x1b[?1006l\x1b[?1000l";
export const ALT_SCREEN_ON = "\x1b[?1049h\x1b[?25l";
export const ALT_SCREEN_OFF = "\x1b[?25h\x1b[?1049l";
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env node
// Entry: wires the four input adapters (command bar / keyboard / mouse /
// control socket) onto ONE instance-scoped view-state (PIN 1). tmux send-keys
// against this process is the drivability floor and needs no adapter at all —
// keystrokes ARE the keyboard adapter.
//
// openrig-tui [--instance <id>] [--socket <path>] [--url <daemon>] [--demo]
import { createViewState, computeExplorerRows, emptySnapshot } from "./state.js";
import { parseCommand } from "./grammar.js";
import { decodeInput, MOUSE_ENABLE, MOUSE_DISABLE, ALT_SCREEN_ON, ALT_SCREEN_OFF } from "./input.js";
import { renderScreen } from "./render.js";
import { createControlSocket, defaultSocketPath } from "./socket-server.js";
import { demoSnapshot } from "./demo-data.js";
import type { FleetSnapshot, Screen } from "./types.js";
function argOf(args: string[], flag: string): string | undefined {
const i = args.indexOf(flag);
return i >= 0 ? args[i + 1] : undefined;
}
async function run(): Promise<void> {
const args = process.argv.slice(2);
const instanceId = argOf(args, "--instance") ?? "tui-1";
const demo = args.includes("--demo");
// Phase 1: the snapshot is honest-empty unless --demo; Phase 2 binds the
// §4.A daemon reads (DaemonClient) to hydrate it.
const snapshot: FleetSnapshot = demo ? demoSnapshot() : emptySnapshot();
const view = createViewState({ instanceId, getSnapshot: () => snapshot });
let inputLine = "";
let lastScreen: Screen | null = null;
function draw(): void {
const cols = process.stdout.columns ?? 120;
const rows = process.stdout.rows ?? 32;
lastScreen = renderScreen(view.get(), snapshot, { cols, rows }, inputLine);
process.stdout.write("\x1b[H" + lastScreen.lines.map((l) => "\x1b[2K" + l).join("\r\n"));
}
const socketPath = argOf(args, "--socket") ?? defaultSocketPath(instanceId);
const socket = await createControlSocket({ socketPath, view, onMutation: draw });
async function shutdown(): Promise<void> {
process.stdout.write(MOUSE_DISABLE + ALT_SCREEN_OFF);
await socket.close();
process.exit(0);
}
process.on("SIGINT", () => void shutdown());
process.on("SIGTERM", () => void shutdown());
if (process.stdin.isTTY) process.stdin.setRawMode(true);
process.stdin.on("data", (bytes: Buffer) => {
for (const ev of decodeInput(bytes)) {
if (ev.type === "char") {
if (ev.ch === "q" && inputLine === "") {
void shutdown();
return;
}
inputLine += ev.ch;
} else if (ev.type === "key" && ev.key === "backspace") {
inputLine = inputLine.slice(0, -1);
} else if (ev.type === "key" && ev.key === "escape") {
inputLine = "";
} else if (ev.type === "key" && ev.key === "enter") {
if (inputLine !== "") {
view.dispatch(parseCommand(inputLine));
inputLine = "";
} else {
view.dispatch({ type: "activate" });
}
} else if (ev.type === "key" && "action" in ev) {
const action = ev.action;
if (action.type === "select")
view.dispatch({ ...action, rowCount: computeExplorerRows(view.get(), snapshot).length });
else view.dispatch(action);
} else if (ev.type === "mouse" && lastScreen) {
const hit = lastScreen.hitMap.find((h) => h.y === ev.y && ev.x >= h.x1 && ev.x <= h.x2);
if (hit) view.dispatch(hit.action);
}
}
draw();
});
process.stdout.write(ALT_SCREEN_ON + MOUSE_ENABLE);
draw();
}
run().catch((err: unknown) => {
process.stdout.write(MOUSE_DISABLE + ALT_SCREEN_OFF);
console.error(err instanceof Error ? err.message : err);
process.exit(1);
});
+166
View File
@@ -0,0 +1,166 @@
// Hand-rolled ANSI renderer (Phase-0 substrate decision). Pure function:
// (state, snapshot) → {lines, hitMap, explorerRows}. The hit-map is how mouse
// clicks resolve to the SAME semantic actions commands produce (PIN 1).
// Isolated seam: a substrate swap touches only this module (spike verdict
// revisit trigger).
import { computeExplorerRows, findAgent, findSpec, agentsRunningSpec } from "./state.js";
import type { FleetSnapshot, Screen, ViewState } from "./types.js";
const EXPL_W = 30;
function pad(text: string | number | null | undefined, width: number): string {
const t = String(text ?? "");
return t.length >= width ? t.slice(0, width) : t + " ".repeat(width - t.length);
}
function padLeft(text: string | number | null | undefined, width: number): string {
const t = String(text ?? "");
return t.length >= width ? t.slice(0, width) : " ".repeat(width - t.length) + t;
}
type Align = "left" | "right";
const AGENT_COLS: Array<[string, number, Align]> = [
["POD", 8, "left"],
["AGENT", 16, "left"],
["RUNTIME", 13, "left"],
["CTX%", 4, "right"],
["TOKENS", 7, "right"],
["STATUS", 17, "left"],
["ACTIONS", 14, "left"],
];
function tableRow(cells: Array<string | null>): string {
return AGENT_COLS.map(([, w, align], i) => (align === "right" ? padLeft(cells[i], w) : pad(cells[i], w))).join(" ");
}
function contentLines(state: ViewState, snap: FleetSnapshot): string[] {
const lines: string[] = [];
if (state.section === "topology") {
if (state.runningOf) {
lines.push(`seats running spec "${state.runningOf}":`);
for (const seat of agentsRunningSpec(snap, state.runningOf)) lines.push(` ● ${seat} (open: agent ${seat})`);
if (lines.length === 1) lines.push(" (no seats currently run it)");
return lines;
}
const leaf = state.drill.at(-1);
if (leaf?.kind === "agent") {
const found = findAgent(snap, leaf.name);
if (!found) return [`agent "${leaf.name}" not in the current snapshot`];
const { agent, rig, pod } = found;
lines.push(`agent ${agent.name}`);
lines.push(` rig ${rig.name} · pod ${pod.name} · runtime ${agent.runtime}`);
lines.push(` spec ${agent.spec} (open: spec-of ${agent.name})`);
lines.push(` state ${agent.status}`);
return lines;
}
const rigName = state.drill.find((d) => d.kind === "rig")?.name ?? snap.hosts[0]?.rigs[0]?.name;
const rig = snap.hosts.flatMap((h) => h.rigs).find((r) => r.name === rigName);
if (!rig) return ["no rig in view — waiting on the daemon read (honest-empty, not fabricated)"];
const podFilter = leaf?.kind === "pod" ? leaf.name : null;
const all = rig.pods.flatMap((p) => p.agents.map((a) => ({ pod: p.name, ...a })));
const rows = all
.filter((a) => !podFilter || a.pod === podFilter)
.filter((a) => !state.filter || a.name.includes(state.filter) || a.pod.includes(state.filter));
lines.push(
`[ TABLE ] OVERVIEW rig ${rig.name}${podFilter ? ` · pod ${podFilter}` : ""}${state.filter ? ` · filter "${state.filter}"` : ""}`,
);
lines.push(tableRow(AGENT_COLS.map(([name]) => name)));
lines.push("─".repeat(AGENT_COLS.reduce((n, [, w]) => n + w + 1, -1)));
for (const a of rows)
lines.push(
tableRow([
a.pod,
a.name,
a.runtime,
a.context == null ? "—" : `${a.context}%`,
a.tokens ?? "—",
a.status,
"run ▸ · term",
]),
);
lines.push("");
lines.push(`${rows.length} of ${all.length} agents shown`);
return lines;
}
if (state.section === "specs") {
const leaf = state.drill.at(-1);
if (leaf?.kind === "spec") {
const spec = findSpec(snap, leaf.name);
if (!spec) return [`spec "${leaf.name}" not in the current snapshot`];
if (spec.agentRefs) {
lines.push(`rig spec ${spec.name} tabs: topology [ CONFIGURATION ] yaml`);
lines.push(" members:");
for (const ref of spec.agentRefs) lines.push(` ▪ ${ref} (open: spec ${ref})`);
} else {
lines.push(`agent spec ${spec.name}`);
lines.push(` runtime ${spec.runtime ?? "—"}`);
lines.push(` used by rigs: ${spec.usedByRigs?.join(", ") ?? "—"}`);
const seats = agentsRunningSpec(snap, spec.name);
lines.push(` seats now: ${seats.join(", ") || "(none)"} (open: running ${spec.name})`);
}
return lines;
}
lines.push("SPEC LIBRARY");
for (const kind of ["rig", "agent", "workflow"] as const) {
const shown = snap.specs.filter((s) => s.kind === kind).filter((s) => !state.filter || s.name.includes(state.filter));
if (shown.length === 0 && !snap.specs.some((s) => s.kind === kind)) continue;
lines.push(` ${kind.toUpperCase()} (${shown.length})`);
for (const s of shown) lines.push(` ▪ ${s.name}`);
}
if (snap.specs.length === 0) lines.push(" (library read pending — honest-empty)");
return lines;
}
if (state.section === "needs") {
lines.push("NEEDS-YOU");
for (const item of snap.needs) lines.push(` ⚑ ${item.kind} ${item.target} — ${item.detail} (open ▸)`);
if (snap.needs.length === 0) lines.push(" (no grounded exception items right now)");
lines.push("");
if (!snap.humanQueueProbed) lines.push(" human-queue: not yet known (read pending)");
else if (snap.humanQueue.length === 0)
lines.push(" human-queue: no items (proven empty — surfacing adoption pending)");
else for (const item of snap.humanQueue) lines.push(` ☐ ${item.kind} ${item.target} — ${item.detail} (open ▸)`);
return lines;
}
return [`(${state.section})`];
}
export interface RenderOptions {
cols?: number;
rows?: number;
}
export function renderScreen(state: ViewState, snap: FleetSnapshot, options: RenderOptions = {}, inputLine = ""): Screen {
const { cols = 120, rows = 32 } = options;
const lines: string[] = [];
const hitMap: Screen["hitMap"] = [];
lines.push(pad(`cmd ▸ ${inputLine}`, cols));
lines.push("─".repeat(cols));
const explorer = computeExplorerRows(state, snap);
const content = contentLines(state, snap);
const bodyRows = Math.max(explorer.length, content.length);
const explorerRows: Screen["explorerRows"] = [];
for (let i = 0; i < bodyRows; i++) {
const y = lines.length + 1; // 1-based terminal row this line will occupy
const row = explorer[i];
const marker = i === state.selection && row ? "›" : " ";
const left = pad(row ? `${marker}${row.label}` : "", EXPL_W);
const right = content[i] ?? "";
lines.push(`${left}│ ${right}`);
if (row) {
hitMap.push({ y, x1: 1, x2: EXPL_W, action: row.action });
explorerRows.push({ ...row, y });
}
}
const drillPath = state.drill.map((d) => d.name).join(" → ");
lines.push("─".repeat(cols));
lines.push(
pad(
`[${state.instanceId}] ${state.section}${drillPath ? " · " + drillPath : ""}${state.lastError ? " ✗ " + state.lastError : ""}`,
cols,
),
);
while (lines.length < rows) lines.push("");
return { lines: lines.slice(0, rows), hitMap, explorerRows };
}
+100
View File
@@ -0,0 +1,100 @@
// Control-socket adapter — the "addressable-screen API" adopted by the Phase-0
// spike verdict. One command per line; every line gets a one-line JSON reply.
//
// STANDING ARCH CONSTRAINT (arch-lead post-spike-review; same class as the
// BR-9 ACTIONS guard — the socket is a NAMED boundary-erosion point):
// 1. Every socket command goes through the ONE resolver/mutation path
// (parseCommand → dispatch). No programmatic shortcut may mutate state
// outside it, ever.
// 2. Socket verbs stay OBSERVE / NAVIGATE / DRIVE-STRUCTURE only. No
// ACT/PRODUCE verb lands here because the socket is an API. Any extension
// crossing either line routes to arch-lead BEFORE building.
// The only non-grammar verb is "state" — a read-only state query (OBSERVE).
import net from "node:net";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { parseCommand } from "./grammar.js";
import type { ViewState, ViewStateStore } from "./types.js";
/** macOS sun_path caps unix-socket paths at ~104 bytes; guard with margin. */
export const MAX_SOCKET_PATH_BYTES = 100;
export function describeState(state: ViewState) {
return {
ok: !state.lastError,
screen: state.section,
drill: state.drill.map((d) => `${d.kind}:${d.name}`),
filter: state.filter || undefined,
error: state.lastError ?? undefined,
};
}
/** Default socket home follows the shipped OPENRIG_HOME convention
* (openrig-compat: ~/.openrig), herdr-style env override on top. */
export function defaultSocketPath(instanceId: string): string {
const override = process.env["OPENRIG_TUI_SOCKET"];
if (override) return override;
const home = process.env["OPENRIG_HOME"] ?? path.join(os.homedir(), ".openrig");
return path.join(home, "run", `tui-${instanceId}.sock`);
}
export interface ControlSocket {
path: string;
close(): Promise<void>;
}
export async function createControlSocket(options: {
socketPath: string;
view: ViewStateStore;
onMutation?: () => void;
}): Promise<ControlSocket> {
const { socketPath, view, onMutation } = options;
const bytes = Buffer.byteLength(socketPath);
if (bytes > MAX_SOCKET_PATH_BYTES) {
throw new Error(
`socket path too long (${bytes} bytes; unix sun_path caps ~104): ${socketPath} — use a short runtime dir (default: $OPENRIG_HOME/run)`,
);
}
fs.mkdirSync(path.dirname(socketPath), { recursive: true });
if (fs.existsSync(socketPath)) fs.unlinkSync(socketPath);
const server = net.createServer((conn) => {
let buf = "";
conn.on("data", (d) => {
buf += d.toString("utf8");
let nl: number;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
if (line === "state") {
conn.write(
JSON.stringify({ ok: true, instanceId: view.instanceId, state: describeState(view.get()) }) + "\n",
);
continue;
}
// The one mutation path: grammar → dispatch. Nothing else.
const next = view.dispatch(parseCommand(line));
conn.write(JSON.stringify(describeState(next)) + "\n");
onMutation?.();
}
});
});
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(socketPath, () => {
resolve({
path: socketPath,
close: () =>
new Promise<void>((res) => {
server.close(() => {
if (fs.existsSync(socketPath)) fs.unlinkSync(socketPath);
res();
});
}),
});
});
});
}
+268
View File
@@ -0,0 +1,268 @@
// ONE instance-scoped view-state with ONE mutation path (dispatch) — PIN 1.
// No module-level state anywhere in this file (FR-13). The section set is a
// data registry, not a switch (FR-12). Ported from the Phase-0 spike verbatim
// in shape: the parity-by-construction property lives in the reducer resolving
// 'activate' against the SAME row model the renderer draws.
import type {
Action,
DrillSegment,
ExplorerRow,
FleetSnapshot,
GetSnapshot,
SectionDef,
ViewState,
ViewStateStore,
} from "./types.js";
export function defaultSections(): SectionDef[] {
return [
{
name: "topology",
sourceRead: "GET /api/rigs/:id/graph + /api/ps + /api/rigs/summary (existing)",
drillShape: "host>rig>pod>agent",
},
{
name: "specs",
sourceRead: "GET /api/specs/library + /api/rigs/:rigId/spec (existing)",
drillShape: "kind>spec",
},
{
name: "needs",
sourceRead: "GET /api/review/rig|fleet + /api/queue/list?attention=1 (existing)",
drillShape: "flat",
},
];
}
export function emptySnapshot(): FleetSnapshot {
return { hosts: [], specs: [], needs: [], humanQueueProbed: false, humanQueue: [] };
}
export interface CreateViewStateOptions {
instanceId: string;
sections?: SectionDef[];
getSnapshot?: GetSnapshot;
}
export function createViewState(options: CreateViewStateOptions): ViewStateStore {
const { instanceId, sections = defaultSections(), getSnapshot = emptySnapshot } = options;
if (!instanceId) throw new Error("createViewState requires an instanceId (A2: instances are addressable)");
let state: ViewState = {
instanceId,
sections,
section: sections[0]?.name ?? "topology",
drill: [],
filter: "",
selection: 0,
runningOf: null,
lastError: null,
};
const listeners = new Set<(s: ViewState) => void>();
function dispatch(action: Action): ViewState {
state = reduce(state, action, getSnapshot());
for (const fn of listeners) fn(state);
return state;
}
return {
instanceId,
get: () => state,
dispatch,
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
};
}
function reduce(state: ViewState, action: Action, snap: FleetSnapshot): ViewState {
const next: ViewState = { ...state, lastError: null };
switch (action.type) {
case "noop":
return next;
case "error":
return { ...next, lastError: action.message };
case "jump": {
if (!state.sections.some((s) => s.name === action.section))
return { ...next, lastError: `unknown section "${action.section}"` };
return { ...next, section: action.section, drill: [], filter: "", selection: 0, runningOf: null };
}
case "filter":
return { ...next, filter: action.text, selection: 0 };
case "select": {
const count = Math.max(action.rowCount ?? Number.MAX_SAFE_INTEGER, 1);
const target = action.index ?? state.selection + (action.delta ?? 0);
return { ...next, selection: Math.min(Math.max(target, 0), count - 1) };
}
case "activate": {
// Enter activates the selected explorer row — resolved against the SAME
// row model the renderer draws, so keyboard and mouse cannot diverge.
const row = computeExplorerRows(state, snap)[state.selection];
if (!row) return { ...next, lastError: "nothing selected" };
return reduce(next, row.action, snap);
}
case "drill":
return drillTo(next, action.resource, action.name, snap);
case "cross":
return crossNav(next, action.kind, action.name, snap);
default:
return { ...next, lastError: "unknown action" };
}
}
// --- snapshot lookups (pure; no daemon calls here) ---
export function findAgent(snap: FleetSnapshot, name: string) {
for (const host of snap.hosts)
for (const rig of host.rigs)
for (const pod of rig.pods)
for (const agent of pod.agents) if (agent.name === name) return { host, rig, pod, agent };
return null;
}
export function findSpec(snap: FleetSnapshot, name: string) {
return snap.specs.find((s) => s.name === name) ?? null;
}
export function findRig(snap: FleetSnapshot, name: string) {
for (const host of snap.hosts) for (const rig of host.rigs) if (rig.name === name) return { host, rig };
return null;
}
export function agentsRunningSpec(snap: FleetSnapshot, specName: string): string[] {
const out: string[] = [];
for (const host of snap.hosts)
for (const rig of host.rigs)
for (const pod of rig.pods)
for (const agent of pod.agents) if (agent.spec === specName) out.push(agent.name);
return out;
}
function drillTo(state: ViewState, resource: string, name: string, snap: FleetSnapshot): ViewState {
switch (resource) {
case "host": {
if (!snap.hosts.some((h) => h.name === name)) return { ...state, lastError: `no such host "${name}"` };
return { ...state, section: "topology", drill: [{ kind: "host", name }], selection: 0, runningOf: null };
}
case "rig": {
const found = findRig(snap, name);
if (!found) return { ...state, lastError: `no such rig "${name}"` };
return {
...state,
section: "topology",
drill: [
{ kind: "host", name: found.host.name },
{ kind: "rig", name },
],
selection: 0,
runningOf: null,
};
}
case "pod": {
for (const host of snap.hosts)
for (const rig of host.rigs)
if (rig.pods.some((p) => p.name === name))
return {
...state,
section: "topology",
drill: [
{ kind: "host", name: host.name },
{ kind: "rig", name: rig.name },
{ kind: "pod", name },
],
selection: 0,
runningOf: null,
};
return { ...state, lastError: `no such pod "${name}"` };
}
case "agent": {
const found = findAgent(snap, name);
if (!found) return { ...state, lastError: `no such agent "${name}"` };
const drill: DrillSegment[] = [
{ kind: "host", name: found.host.name },
{ kind: "rig", name: found.rig.name },
{ kind: "pod", name: found.pod.name },
{ kind: "agent", name },
];
return { ...state, section: "topology", drill, selection: 0, runningOf: null };
}
case "spec": {
if (!findSpec(snap, name)) return { ...state, lastError: `no such spec "${name}"` };
return { ...state, section: "specs", drill: [{ kind: "spec", name }], selection: 0, runningOf: null };
}
default:
return { ...state, lastError: `unknown resource "${resource}"` };
}
}
function crossNav(state: ViewState, kind: "spec-of" | "running", name: string, snap: FleetSnapshot): ViewState {
if (kind === "spec-of") {
const found = findAgent(snap, name);
if (!found) return { ...state, lastError: `no such agent "${name}"` };
return {
...state,
section: "specs",
drill: [{ kind: "spec", name: found.agent.spec }],
selection: 0,
runningOf: null,
};
}
if (!findSpec(snap, name)) return { ...state, lastError: `no such spec "${name}"` };
return { ...state, section: "topology", drill: [], runningOf: name, filter: "", selection: 0 };
}
// The explorer row model — pure function of (state, snapshot), shared by the
// reducer ('activate') and the renderer (drawing + hit-map). One source of truth.
export function computeExplorerRows(state: ViewState, snap: FleetSnapshot): ExplorerRow[] {
const rows: ExplorerRow[] = [];
for (const section of state.sections) {
const active = section.name === state.section;
const label =
section.name === "topology"
? "TOPOLOGY"
: section.name === "specs"
? "SPECS"
: section.name === "needs"
? "NEEDS-YOU"
: section.name.toUpperCase();
rows.push({ label: `${active ? "▾" : "▸"} ${label}`, action: { type: "jump", section: section.name } });
if (!active) continue;
if (section.name === "topology") {
for (const host of snap.hosts) {
rows.push({
label: ` ▾ ${host.name}${host.reachable ? "" : " (unreachable)"}`,
action: { type: "drill", resource: "host", name: host.name },
});
for (const rig of host.rigs) {
rows.push({ label: ` ▾ ${rig.name}`, action: { type: "drill", resource: "rig", name: rig.name } });
for (const pod of rig.pods) {
rows.push({
label: ` ▾ ${pod.name} (${pod.agents.length})`,
action: { type: "drill", resource: "pod", name: pod.name },
});
for (const agent of pod.agents)
rows.push({
label: ` ● ${agent.name}`,
action: { type: "drill", resource: "agent", name: agent.name },
});
}
}
}
} else if (section.name === "specs") {
const kinds = ["rig", "agent", "workflow"] as const;
for (const kind of kinds) {
const list = snap.specs.filter((s) => s.kind === kind);
if (list.length === 0) continue;
rows.push({ label: ` ${kind.toUpperCase()} SPECS (${list.length})`, action: { type: "jump", section: "specs" } });
for (const spec of list)
rows.push({ label: ` ▪ ${spec.name}`, action: { type: "drill", resource: "spec", name: spec.name } });
}
} else if (section.name === "needs") {
for (const item of snap.needs)
rows.push({ label: ` ⚑ ${item.kind}: ${item.target}`, action: { type: "jump", section: "needs" } });
}
}
return rows;
}
+128
View File
@@ -0,0 +1,128 @@
// Core types for the mission-control TUI. The TUI is a renderer + navigation
// shell over the daemon's EXISTING projections (two-renderers-over-one-
// projection); FleetSnapshot is the render-side shape those reads hydrate —
// it introduces no new data model.
export interface AgentRow {
name: string;
runtime: string;
spec: string;
/** null = the projection has no value → renders honest-unknown, never fabricated (PIN 2) */
context: number | null;
tokens: string | null;
status: string;
}
export interface PodNode {
name: string;
agents: AgentRow[];
}
export interface RigNode {
name: string;
pods: PodNode[];
}
export interface HostNode {
name: string;
reachable: boolean;
rigs: RigNode[];
}
export type SpecKind = "rig" | "agent" | "workflow";
export interface SpecEntry {
name: string;
kind: SpecKind;
/** rig specs: member agent-refs, each clickable → that agent spec */
agentRefs?: string[];
runtime?: string;
usedByRigs?: string[];
}
export interface NeedsItem {
kind: string;
target: string;
detail: string;
}
export interface FleetSnapshot {
hosts: HostNode[];
specs: SpecEntry[];
/** grounded composeNeedsYou legs + host/rig-down, composed beside (PIN 3) */
needs: NeedsItem[];
/** false until the human-queue read has answered — honest-unknown vs proven-empty */
humanQueueProbed: boolean;
humanQueue: NeedsItem[];
}
export type GetSnapshot = () => FleetSnapshot;
export type ResourceKind = "host" | "rig" | "pod" | "agent" | "spec";
export interface DrillSegment {
kind: ResourceKind;
name: string;
}
export type Action =
| { type: "noop" }
| { type: "error"; message: string }
| { type: "jump"; section: string }
| { type: "filter"; text: string }
| { type: "select"; delta?: number; index?: number; rowCount?: number }
| { type: "activate" }
| { type: "drill"; resource: ResourceKind; name: string }
| { type: "cross"; kind: "spec-of" | "running"; name: string };
/** FR-12: the section set is ONE in-code registry — adding a section is a
* localized edit to this data structure, never a scattered switch. */
export interface SectionDef {
name: string;
/** provenance note: which EXISTING daemon read feeds it (the R7 no-new-data trail) */
sourceRead: string;
drillShape: string;
}
export interface ViewState {
instanceId: string;
sections: SectionDef[];
section: string;
drill: DrillSegment[];
filter: string;
selection: number;
runningOf: string | null;
lastError: string | null;
}
export interface ViewStateStore {
instanceId: string;
get(): ViewState;
dispatch(action: Action): ViewState;
subscribe(fn: (state: ViewState) => void): () => void;
}
export interface ExplorerRow {
label: string;
action: Action;
}
export interface HitTarget {
y: number;
x1: number;
x2: number;
action: Action;
}
export interface Screen {
lines: string[];
hitMap: HitTarget[];
explorerRows: Array<ExplorerRow & { y: number }>;
}
export type InputEvent =
| { type: "char"; ch: string }
| { type: "key"; key: "up" | "down" | "left" | "right"; action: Action }
| { type: "key"; key: "enter"; action: Action }
| { type: "key"; key: "backspace" | "escape" }
| { type: "mouse"; button: number; x: number; y: number };
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { DaemonClient } from "../src/daemon-client.js";
// FR-8 / R7 no-new-data: the TUI's entire daemon surface is this ONE module,
// and every route it can emit is on the §4.A table of EXISTING web-consumed
// reads. This test pins the audit: exercise every wrapper, collect every URL.
const SPEC_4A_ROUTES = [
"/api/rigs/openrig-build/graph",
"/api/ps",
"/api/rigs/summary",
"/api/rigs/openrig-build/nodes",
"/api/review/agents?scope=rig",
"/api/specs/library?kind=rig",
"/api/rigs/openrig-build/spec.json",
"/api/specs/review",
"/api/queue/list?attention=1",
"/api/review/rig",
"/api/review/fleet",
"/api/queue/attention-aggregate",
"/api/rigs/openrig-build/status",
];
describe("daemon client = the §4.A table, one module, nothing else (FR-8/FR-9)", () => {
it("every wrapper emits exactly a §4.A route", async () => {
const seen: string[] = [];
const fetchImpl = (async (url: unknown) => {
seen.push(String(url).replace("http://x", ""));
return { ok: true, json: async () => ({}) } as Response;
}) as typeof fetch;
const c = new DaemonClient({ baseUrl: "http://x", fetchImpl });
await c.rigGraph("openrig-build");
await c.ps();
await c.rigsSummary();
await c.rigNodes("openrig-build");
await c.reviewAgents("rig");
await c.specsLibrary("rig");
await c.rigSpec("openrig-build");
await c.specsReview();
await c.queueAttention();
await c.reviewRig();
await c.reviewFleet();
await c.attentionAggregate();
await c.rigStatus("openrig-build");
expect(seen.sort()).toEqual([...SPEC_4A_ROUTES].sort());
});
it("is the ONLY module that talks HTTP (one-file source-check stays one file)", () => {
const srcDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "src");
const files = ["state.ts", "render.ts", "grammar.ts", "input.ts", "socket-server.ts", "main.ts", "types.ts", "demo-data.ts", "index.ts"];
for (const file of files) {
const text = readFileSync(path.join(srcDir, file), "utf8");
expect(text, `${file} must not fetch`).not.toMatch(/fetch\(/);
// state.ts carries §4.A provenance notes in the section registry
// (sourceRead metadata) — those are documentation, not request paths.
if (file !== "state.ts") expect(text, `${file} must not carry routes`).not.toMatch(/\/api\//);
}
});
it("surfaces a failed read as a NAMED error (route + status), never silent", async () => {
const fetchImpl = (async () => ({ ok: false, status: 503, json: async () => ({}) }) as Response) as typeof fetch;
const c = new DaemonClient({ baseUrl: "http://x", fetchImpl });
await expect(c.ps()).rejects.toThrow(/GET \/api\/ps → 503/);
});
});
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { parseCommand } from "../src/grammar.js";
describe("safe-core grammar (FR-1, §4.B)", () => {
it("parses :section jump for the three launch sections", () => {
expect(parseCommand(":topology")).toEqual({ type: "jump", section: "topology" });
expect(parseCommand(":specs")).toEqual({ type: "jump", section: "specs" });
expect(parseCommand(":needs")).toEqual({ type: "jump", section: "needs" });
});
it("names the unknown section, never a silent no-op", () => {
const r = parseCommand(":bogus");
expect(r.type).toBe("error");
if (r.type === "error") expect(r.message).toMatch(/unknown section ":bogus"/);
});
it("parses /text filter; bare / clears", () => {
expect(parseCommand("/driver")).toEqual({ type: "filter", text: "driver" });
expect(parseCommand("/")).toEqual({ type: "filter", text: "" });
});
it("parses <resource> <name> drill for known resource kinds", () => {
expect(parseCommand("rig openrig-build")).toEqual({ type: "drill", resource: "rig", name: "openrig-build" });
expect(parseCommand("agent dev50.driver")).toEqual({ type: "drill", resource: "agent", name: "dev50.driver" });
expect(parseCommand("host vm-host")).toEqual({ type: "drill", resource: "host", name: "vm-host" });
expect(parseCommand("spec driver-agent")).toEqual({ type: "drill", resource: "spec", name: "driver-agent" });
});
it("parses cross-nav verbs", () => {
expect(parseCommand("spec-of dev50.driver")).toEqual({ type: "cross", kind: "spec-of", name: "dev50.driver" });
expect(parseCommand("running driver-agent")).toEqual({ type: "cross", kind: "running", name: "driver-agent" });
});
it("names the unknown command with the offending token", () => {
const r = parseCommand("frobnicate xyz");
expect(r.type).toBe("error");
if (r.type === "error") expect(r.message).toMatch(/unknown command "frobnicate"/);
});
it("names a drill missing its target", () => {
const r = parseCommand("agent");
expect(r.type).toBe("error");
if (r.type === "error") expect(r.message).toMatch(/agent/);
});
it("treats empty input as an explicit typed no-op", () => {
expect(parseCommand("")).toEqual({ type: "noop" });
expect(parseCommand(" ")).toEqual({ type: "noop" });
});
});
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { createViewState } from "../src/state.js";
import { parseCommand } from "../src/grammar.js";
import { decodeInput, sgrClick } from "../src/input.js";
import { renderScreen } from "../src/render.js";
import { demoSnapshot } from "../src/demo-data.js";
import type { ViewState } from "../src/types.js";
// PIN 1: command / mouse / keyboard are adapters over ONE dispatch. Parity is
// proven by reaching the IDENTICAL state through each input kind.
const snap = demoSnapshot();
function fresh(id: string) {
return createViewState({ instanceId: id, getSnapshot: () => snap });
}
function comparable(state: ViewState) {
const { instanceId: _i, sections: _s, ...rest } = state;
return rest;
}
describe("parity by construction (FR-7 / PIN 1)", () => {
it("command vs mouse click on the explorer reach identical state", () => {
const byCommand = fresh("cmd");
const byMouse = fresh("ui");
byCommand.dispatch(parseCommand(":specs"));
const screen = renderScreen(byMouse.get(), snap, { cols: 100, rows: 30 });
const target = screen.hitMap.find((h) => h.action.type === "jump" && h.action.section === "specs");
expect(target).toBeDefined();
const click = decodeInput(sgrClick(target!.x1, target!.y)).find((e) => e.type === "mouse");
expect(click).toBeDefined();
if (click?.type !== "mouse") throw new Error("unreachable");
const hit = screen.hitMap.find((h) => h.y === click.y && click.x >= h.x1 && click.x <= h.x2);
expect(hit).toBeDefined();
byMouse.dispatch(hit!.action);
expect(comparable(byMouse.get())).toEqual(comparable(byCommand.get()));
});
it("command vs keyboard (arrows + enter) reach identical state", () => {
const byCommand = fresh("cmd");
const byKeys = fresh("kbd");
byCommand.dispatch(parseCommand("rig openrig-build"));
const screen = renderScreen(byKeys.get(), snap, { cols: 100, rows: 30 });
const rigIndex = screen.explorerRows.findIndex((r) => r.action.type === "drill" && r.action.resource === "rig");
expect(rigIndex).toBeGreaterThanOrEqual(0);
for (let i = 0; i < rigIndex; i++)
for (const ev of decodeInput("\x1b[B"))
if (ev.type === "key" && "action" in ev) byKeys.dispatch(ev.action);
const enter = decodeInput("\r")[0];
if (enter && enter.type === "key" && "action" in enter) byKeys.dispatch(enter.action);
expect(comparable(byKeys.get())).toEqual(comparable(byCommand.get()));
});
it("renders the agents table with fixed-width columns and right-aligned numerics (honest-unknown as —)", () => {
const s = fresh("t");
s.dispatch(parseCommand("rig openrig-build"));
const screen = renderScreen(s.get(), snap, { cols: 100, rows: 30 });
const header = screen.lines.find((l) => l.includes("AGENT") && l.includes("STATUS"));
expect(header).toBeDefined();
const ctxCol = header!.indexOf("CTX%");
const rows = screen.lines.filter((l) => /\b(running|idle|needs-attention|unknown)\b/.test(l));
expect(rows.length).toBeGreaterThanOrEqual(2);
for (const row of rows) {
expect(row.slice(ctxCol, ctxCol + 4)).toMatch(/^\s*(\d+%|—)$/);
}
});
it("keeps STATUS verbatim from the snapshot — never fabricated (PIN 2 render leg)", () => {
const s = fresh("t");
s.dispatch(parseCommand("rig openrig-build"));
const screen = renderScreen(s.get(), snap, { cols: 100, rows: 30 });
const qaRow = screen.lines.find((l) => l.includes("dev50.qa"));
expect(qaRow).toBeDefined();
expect(qaRow).toMatch(/unknown/);
const deadRow = screen.lines.find((l) => l.includes("orch.lead"));
expect(deadRow).toMatch(/needs-attention/);
});
});
+82
View File
@@ -0,0 +1,82 @@
import { afterEach, describe, expect, it } from "vitest";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { createViewState } from "../src/state.js";
import { createControlSocket, defaultSocketPath, MAX_SOCKET_PATH_BYTES } from "../src/socket-server.js";
import { demoSnapshot } from "../src/demo-data.js";
import type { ControlSocket } from "../src/socket-server.js";
const snap = demoSnapshot();
let open: ControlSocket | null = null;
afterEach(async () => {
if (open) await open.close();
open = null;
});
function shortSockPath(): string {
return path.join(os.tmpdir(), `tui-t-${process.pid}-${Math.floor(Math.random() * 1e6)}.sock`);
}
function ask(sockPath: string, lines: string[]): Promise<string[]> {
return new Promise((resolve, reject) => {
const conn = net.createConnection({ path: sockPath });
let buf = "";
conn.on("data", (d) => {
buf += d.toString("utf8");
if (buf.split("\n").filter(Boolean).length >= lines.length) {
conn.end();
resolve(buf.split("\n").filter(Boolean));
}
});
conn.on("error", reject);
conn.on("connect", () => conn.write(lines.map((l) => l + "\n").join("")));
});
}
describe("control-socket adapter (spike-adopted; arch boundary constraint)", () => {
it("deep-links a named screen through the ONE resolver/mutation path and replies with structured state", async () => {
const view = createViewState({ instanceId: "tui-sock", getSnapshot: () => snap });
open = await createControlSocket({ socketPath: shortSockPath(), view });
const [reply] = await ask(open.path, ["agent dev50.driver"]);
const parsed = JSON.parse(reply!);
expect(parsed.ok).toBe(true);
expect(parsed.screen).toBe("topology");
expect(parsed.drill).toEqual(["host:vm-host", "rig:openrig-build", "pod:dev50", "agent:dev50.driver"]);
expect(view.get().drill.at(-1)).toEqual({ kind: "agent", name: "dev50.driver" });
});
it("answers a read-only state query (OBSERVE class)", async () => {
const view = createViewState({ instanceId: "tui-sock", getSnapshot: () => snap });
open = await createControlSocket({ socketPath: shortSockPath(), view });
const [reply] = await ask(open.path, ["state"]);
const parsed = JSON.parse(reply!);
expect(parsed.instanceId).toBe("tui-sock");
expect(parsed.state.screen).toBe("topology");
});
it("rejects non-grammar verbs with the grammar's NAMED error — the socket has NO verb surface beyond the one resolver (arch line 1+2)", async () => {
const view = createViewState({ instanceId: "tui-sock", getSnapshot: () => snap });
open = await createControlSocket({ socketPath: shortSockPath(), view });
const [reply] = await ask(open.path, ["resolve item-42"]);
const parsed = JSON.parse(reply!);
expect(parsed.ok).toBe(false);
expect(parsed.error).toMatch(/unknown command "resolve"/);
// and the state was NOT mutated by the rejected verb
expect(view.get().section).toBe("topology");
expect(view.get().drill).toEqual([]);
});
it("refuses socket paths beyond the sun_path limit with a named error", async () => {
const view = createViewState({ instanceId: "tui-sock", getSnapshot: () => snap });
const tooLong = path.join(os.tmpdir(), "x".repeat(MAX_SOCKET_PATH_BYTES + 1) + ".sock");
await expect(createControlSocket({ socketPath: tooLong, view })).rejects.toThrow(/socket path too long/);
});
it("defaults the socket home to $OPENRIG_HOME/run (herdr-style convention) and stays under the limit", () => {
const p = defaultSocketPath("tui-1");
expect(p).toMatch(/[/\\]run[/\\]tui-tui-1\.sock$/);
expect(Buffer.byteLength(p)).toBeLessThanOrEqual(MAX_SOCKET_PATH_BYTES);
});
});
+83
View File
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { createViewState, defaultSections } from "../src/state.js";
import { demoSnapshot } from "../src/demo-data.js";
import type { NeedsItem, SectionDef } from "../src/types.js";
const snap = demoSnapshot();
const withSnap = { getSnapshot: () => snap };
describe("one instance-scoped view-state (PIN 1, FR-12/13)", () => {
it("is instance-scoped: two instances never share state (FR-13 negative AC)", () => {
const a = createViewState({ instanceId: "tui-a", ...withSnap });
const b = createViewState({ instanceId: "tui-b", ...withSnap });
a.dispatch({ type: "jump", section: "specs" });
expect(a.get().section).toBe("specs");
expect(b.get().section).toBe("topology");
expect(a.get().instanceId).toBe("tui-a");
expect(b.get().instanceId).toBe("tui-b");
});
it("keeps the section set as ONE in-code registry: adding a section is a localized edit (FR-12 negative AC)", () => {
const extra: SectionDef = { name: "extra", sourceRead: "GET /api/ps (existing read)", drillShape: "flat" };
const s = createViewState({ instanceId: "t", sections: [...defaultSections(), extra], ...withSnap });
expect(s.dispatch({ type: "jump", section: "extra" }).section).toBe("extra");
});
it("reaches every registered view by a command (R1.2 drivability by construction)", () => {
const s = createViewState({ instanceId: "t", ...withSnap });
for (const sec of s.get().sections) {
s.dispatch({ type: "jump", section: sec.name });
expect(s.get().section).toBe(sec.name);
}
});
it("drills to a known agent; an unknown target is a NAMED error in state", () => {
const s = createViewState({ instanceId: "t", ...withSnap });
s.dispatch({ type: "drill", resource: "agent", name: "dev50.driver" });
expect(s.get().section).toBe("topology");
expect(s.get().drill.at(-1)).toEqual({ kind: "agent", name: "dev50.driver" });
expect(s.get().lastError).toBeNull();
s.dispatch({ type: "drill", resource: "agent", name: "nobody.here" });
expect(s.get().lastError).toMatch(/no such agent "nobody\.here"/);
});
it("cross-navs spec-of: running agent → its agent spec", () => {
const s = createViewState({ instanceId: "t", ...withSnap });
s.dispatch({ type: "cross", kind: "spec-of", name: "dev50.driver" });
expect(s.get().section).toBe("specs");
expect(s.get().drill.at(-1)).toEqual({ kind: "spec", name: "driver-agent" });
});
it("cross-navs running: spec → topology scoped to its seats", () => {
const s = createViewState({ instanceId: "t", ...withSnap });
s.dispatch({ type: "cross", kind: "running", name: "driver-agent" });
expect(s.get().section).toBe("topology");
expect(s.get().runningOf).toBe("driver-agent");
});
it("filters the current view and clears with empty text", () => {
const s = createViewState({ instanceId: "t", ...withSnap });
s.dispatch({ type: "filter", text: "dev50" });
expect(s.get().filter).toBe("dev50");
s.dispatch({ type: "filter", text: "" });
expect(s.get().filter).toBe("");
});
it("renders honest-empty against an empty snapshot: errors name the miss, nothing is fabricated", () => {
const s = createViewState({ instanceId: "t" }); // default emptySnapshot
s.dispatch({ type: "drill", resource: "agent", name: "dev50.driver" });
expect(s.get().lastError).toMatch(/no such agent/);
expect(s.get().drill).toEqual([]);
});
it("mutates needs snapshots only through the registry data (no hidden globals)", () => {
const localNeeds: NeedsItem[] = [{ kind: "idle-with-work", target: "x", detail: "d" }];
const s = createViewState({
instanceId: "t",
getSnapshot: () => ({ ...demoSnapshot(), needs: localNeeds }),
});
s.dispatch({ type: "jump", section: "needs" });
expect(s.get().section).toBe("needs");
});
});
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src"],
"exclude": ["dist", "test"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
},
});