mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
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:
1 parent
1e6db0fe59
commit
9da75d6fc3
20 files changed
+1431
-3
No files matched your search
Generated
+18
-1
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
@@ -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>`,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["dist", "test"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["test/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user