mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
fix(tui,cli): slice-17 guard corrections — public-bin front door, spec hierarchy depth, runtime meta, locked │ rails
Guard BLOCKING at 6e8f2e8c, all four findings closed:
1. bin-wrapper (the real public path) now owns bare-TTY invocations via the
re-exported runFrontDoor (feature-detected); compiled PTY pin via tmux +
an in-process forced-TTY belt.
2. spec: rows derive depth from the shipped label indent (6-space = foldered)
so an expanded folder's child renders one level deeper, never a sibling;
PIN-1 actions/keys pinned unchanged.
3. agent meta = runtime · ctx% (mockup's first-token display form) under an
IDENTITY-FIRST width policy: richest meta that fits beside the FULL name,
ctx-only fallback, name truncates only as last resort; pinned all three.
4. locked │ rails render; the pane border is located by its FIXED boundary
column in stylize and in the two floor call sites (guard-sanctioned
truthful floor update — assertions unchanged).
TUI 133/133, CLI front-door 14/14, full CLI green vs the 31 pre-existing,
lint, builds. Captures refreshed through the PUBLIC bin.
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
1 parent
6e8f2e8c20
commit
b55d95c602
7 files changed
+175
-30
No files matched your search
@@ -77,7 +77,14 @@ export async function run(argv = process.argv): Promise<void> {
|
||||
const mod = await import(entryUrl) as {
|
||||
createProgram: () => import("commander").Command;
|
||||
runProgram: (program: import("commander").Command, argv: string[]) => Promise<number>;
|
||||
runFrontDoor?: (argv: readonly string[]) => Promise<boolean>;
|
||||
};
|
||||
// Slice 17: the PUBLIC bin is the real front door — bare `rig` in a TTY
|
||||
// opens the TUI here, not only under a direct `node dist/index.js` run
|
||||
// (guard finding 1: importing the entry makes its isDirectRun false).
|
||||
// Feature-detected so the wrapper still runs an older sibling entry.
|
||||
const owned = mod.runFrontDoor ? await mod.runFrontDoor(normalizedArgv) : false;
|
||||
if (owned) return;
|
||||
// Slice 15: run through the shared error path so `--json` failures emit a JSON
|
||||
// error object with a nonzero exit instead of plain Commander text.
|
||||
await mod.runProgram(mod.createProgram(), normalizedArgv);
|
||||
|
||||
@@ -248,6 +248,9 @@ export function isDirectRun(argv1 = process.argv[1], moduleUrl = import.meta.url
|
||||
|
||||
// Slice 15 — the shared CLI error/exit path (re-exported for bin-wrapper + tests).
|
||||
export { runProgram, wantsJsonOutput } from "./cli-error.js";
|
||||
// Slice 17 — the bare-rig front door (re-exported so the PUBLIC bin-wrapper
|
||||
// path owns bare TTY invocations too, not just direct entry runs).
|
||||
export { runFrontDoor } from "./front-door.js";
|
||||
|
||||
// Only parse when executed directly (not imported for testing)
|
||||
if (isDirectRun()) {
|
||||
|
||||
@@ -118,6 +118,63 @@ describe("resolveTuiPath — monorepo-first, bundled fallback (the daemon resolv
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUBLIC bin ownership (guard finding 1 — the wrapper is the real front door)", () => {
|
||||
const binWrapper = join(__dirname, "..", "dist", "bin-wrapper.js");
|
||||
const hasTmux = spawnSync("tmux", ["-V"], { encoding: "utf-8" }).status === 0;
|
||||
it.skipIf(!existsSync(binWrapper) || !hasTmux)("bare PUBLIC bin under a REAL PTY reaches the front-door path (daemon-down degrade proves ownership)", () => {
|
||||
// tmux gives the process a real TTY on BOTH streams (BSD script(1) cannot
|
||||
// allocate one under a piped test runner). A dead daemon URL forces the
|
||||
// front door's degrade branch — its distinctive message proves
|
||||
// runFrontDoor ran on the PUBLIC path (commander's usage text would mean
|
||||
// the bypass guard reported).
|
||||
const session = `frontdoor-pin-${process.pid}`;
|
||||
spawnSync("tmux", ["kill-session", "-t", session], { encoding: "utf-8" });
|
||||
const run = spawnSync(
|
||||
"tmux",
|
||||
["new-session", "-d", "-s", session, "-x", "120", "-y", "30",
|
||||
`OPENRIG_URL=http://127.0.0.1:9 ${process.execPath} ${binWrapper}; sleep 15`],
|
||||
{ encoding: "utf-8", timeout: 10000 },
|
||||
);
|
||||
expect(run.status).toBe(0);
|
||||
let text = "";
|
||||
for (let i = 0; i < 20 && !/daemon not running|Usage: rig/.test(text); i++) {
|
||||
spawnSync("sleep", ["0.5"]);
|
||||
text = spawnSync("tmux", ["capture-pane", "-t", session, "-p"], { encoding: "utf-8", timeout: 5000 }).stdout ?? "";
|
||||
}
|
||||
spawnSync("tmux", ["kill-session", "-t", session], { encoding: "utf-8" });
|
||||
expect(text).toMatch(/daemon not running — try: rig up/);
|
||||
expect(text).not.toMatch(/Usage: rig \[options\] \[command\]/); // NOT the commander bypass
|
||||
expect(text).not.toMatch(/\n\s+at /);
|
||||
});
|
||||
|
||||
it.skipIf(!existsSync(binWrapper))("compiled wrapper wiring: forced-TTY in-process run reaches the degrade branch (belt for PTY-less environments)", () => {
|
||||
const probe = spawnSync(
|
||||
process.execPath,
|
||||
["-e", `process.stdin.isTTY = true; process.stdout.isTTY = true; process.argv[1] = ${JSON.stringify(binWrapper)}; import(${JSON.stringify(binWrapper)});`],
|
||||
{ encoding: "utf-8", timeout: 10000, env: { ...process.env, OPENRIG_URL: "http://127.0.0.1:9" } },
|
||||
);
|
||||
const text = `${probe.stdout}${probe.stderr}`;
|
||||
expect(text).toMatch(/daemon not running — try: rig up/);
|
||||
expect(text).not.toMatch(/\n\s+at /);
|
||||
});
|
||||
|
||||
it.skipIf(!existsSync(binWrapper))("piped PUBLIC bin keeps the commander usage baseline (no TUI, fast exit)", () => {
|
||||
const result = spawnSync(process.execPath, [binWrapper], {
|
||||
input: "x\n",
|
||||
timeout: 5000,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(`${result.stderr}${result.stdout}`).toMatch(/Usage: rig/);
|
||||
});
|
||||
|
||||
it.skipIf(!existsSync(binWrapper))("PUBLIC bin subcommands are untouched (rig --version via wrapper)", () => {
|
||||
const result = spawnSync(process.execPath, [binWrapper, "--version"], { timeout: 5000, encoding: "utf-8" });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("script-safety integration (the compiled front door)", () => {
|
||||
const cliEntry = join(__dirname, "..", "dist", "index.js");
|
||||
it.skipIf(!existsSync(cliEntry))("`echo x | rig` returns usage fast — no hang (exit code preserves today's clean-help baseline)", () => {
|
||||
|
||||
@@ -26,8 +26,8 @@ function parseKey(key: string | undefined): KeyParts | null {
|
||||
return { kind: key.slice(0, at), parts: key.slice(at + 1).split("/") };
|
||||
}
|
||||
|
||||
function keyDepth(key: string | undefined): number {
|
||||
const parsed = parseKey(key);
|
||||
function keyDepth(row: ExplorerRow): number {
|
||||
const parsed = parseKey(row.key);
|
||||
if (!parsed) return -1; // keyless rows (filter, needs items) keep their label
|
||||
switch (parsed.kind) {
|
||||
case "section":
|
||||
@@ -41,7 +41,11 @@ function keyDepth(key: string | undefined): number {
|
||||
case "pod":
|
||||
return 3;
|
||||
case "spec":
|
||||
return parsed.parts[0]?.includes("/") ? 3 : 2;
|
||||
// The row model's spec keys carry no parent relation (guard finding 2);
|
||||
// the folder membership IS encoded in the shipped label indent — a
|
||||
// foldered spec indents 6 spaces, a root spec 4 (state.ts) — so the
|
||||
// child renders one level BELOW its folder, never as a sibling.
|
||||
return /^ {6}/.test(row.label) ? 3 : 2;
|
||||
case "agent":
|
||||
return 4;
|
||||
default:
|
||||
@@ -59,24 +63,32 @@ function contentOf(row: ExplorerRow, kind: string): string {
|
||||
return stripped;
|
||||
}
|
||||
|
||||
function metaOf(row: ExplorerRow, snap: FleetSnapshot): string {
|
||||
/** meta candidates, RICHEST first — the renderer picks the first that fits
|
||||
* WITHOUT sacrificing row identity (names never become ambiguous to make
|
||||
* room for telemetry) */
|
||||
function metaOf(row: ExplorerRow, snap: FleetSnapshot): string[] {
|
||||
const parsed = parseKey(row.key);
|
||||
if (!parsed) return "";
|
||||
if (!parsed) return [];
|
||||
if (parsed.kind === "agent") {
|
||||
const [host, rig, pod, ...name] = parsed.parts;
|
||||
const agent = snap.hosts
|
||||
.find((h) => h.name === host)?.rigs.find((r) => r.name === rig)
|
||||
?.pods.find((p) => p.name === pod)?.agents.find((a) => a.name === name.join("/"));
|
||||
if (!agent) return "";
|
||||
// honest-unknown: no served ctx → an explicit —, never a fabricated value
|
||||
return agent.context == null ? "—" : `${agent.context}%`;
|
||||
if (!agent) return [];
|
||||
// Locked meta = runtime · ctx% (mini-req 1). The runtime's DISPLAY form is
|
||||
// its first hyphen token — the literal mockups render exactly "claude 18%"
|
||||
// for claude-code seats (tui-ascii-aesthetic.html) — served data unchanged.
|
||||
// Honest-unknown: no served ctx → an explicit —, never a fabricated value.
|
||||
const runtimeToken = agent.runtime.split("-")[0] || agent.runtime;
|
||||
const ctx = agent.context == null ? "—" : `${agent.context}%`;
|
||||
return [`${runtimeToken} ${ctx}`, ctx];
|
||||
}
|
||||
if (parsed.kind === "pod") {
|
||||
const [host, rig, pod] = parsed.parts;
|
||||
const found = snap.hosts.find((h) => h.name === host)?.rigs.find((r) => r.name === rig)?.pods.find((p) => p.name === pod);
|
||||
return found ? String(found.agents.length) : "";
|
||||
return found ? [String(found.agents.length)] : [];
|
||||
}
|
||||
return "";
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,7 +97,7 @@ function metaOf(row: ExplorerRow, snap: FleetSnapshot): string {
|
||||
* rails read continuously (the `tree` command look).
|
||||
*/
|
||||
export function navigatorLabels(rows: ExplorerRow[], snap: FleetSnapshot, width: number): string[] {
|
||||
const depths = rows.map((row) => keyDepth(row.key));
|
||||
const depths = rows.map((row) => keyDepth(row));
|
||||
const isLast = rows.map((_, i) => {
|
||||
const depth = depths[i]!;
|
||||
if (depth <= 0) return true;
|
||||
@@ -98,26 +110,33 @@ export function navigatorLabels(rows: ExplorerRow[], snap: FleetSnapshot, width:
|
||||
return true;
|
||||
});
|
||||
|
||||
// NB: the continuation rail is ┊ (not │) — the pane BORDER is │ and the
|
||||
// shipped floor treats the first │ on a line as pane structure; the rail
|
||||
// must never masquerade as the border (same glyph-collision class as the
|
||||
// emoji-width lesson). Branch glyphs ├─ └─ are the mockup's exact chars.
|
||||
// The continuation rail is the mockup's literal │ (guard finding 4, locked
|
||||
// glyph). The pane border is ALSO │ but lives at a FIXED column (EXPL_W) —
|
||||
// the paint layer and floors locate it by that boundary, never by scanning
|
||||
// for the first │ (which a rail would shadow).
|
||||
const railOpen: boolean[] = []; // per depth level: does a later sibling exist?
|
||||
return rows.map((row, i) => {
|
||||
const depth = depths[i]!;
|
||||
if (depth < 0) return row.label; // keyless rows untouched
|
||||
if (depth === 0) return row.label; // section headers keep their ▾/▸ identity
|
||||
railOpen[depth] = !isLast[i]!;
|
||||
const guides = Array.from({ length: depth - 1 }, (_, level) => (railOpen[level + 1] ? "┊ " : " ")).join("");
|
||||
const guides = Array.from({ length: depth - 1 }, (_, level) => (railOpen[level + 1] ? "│ " : " ")).join("");
|
||||
const branch = isLast[i] ? "└─ " : "├─ ";
|
||||
const parsed = parseKey(row.key)!;
|
||||
let content = contentOf(row, parsed.kind);
|
||||
const meta = metaOf(row, snap);
|
||||
const candidates = metaOf(row, snap);
|
||||
const prefix = ` ${guides}${branch}`;
|
||||
if (!meta) return `${prefix}${content}`;
|
||||
// meta is glance data — guaranteed; over-long content truncates honestly
|
||||
const contentMax = width - prefix.length - meta.length - 1;
|
||||
if (content.length > contentMax) content = `${content.slice(0, Math.max(contentMax - 1, 0))}…`;
|
||||
if (candidates.length === 0) return `${prefix}${content}`;
|
||||
// IDENTITY-FIRST width policy: pick the richest meta that fits beside the
|
||||
// FULL name (runtime · ctx% where geometry allows, ctx% otherwise); only
|
||||
// when even the leanest meta cannot fit does the name truncate with … —
|
||||
// rows must never become ambiguous to make room for telemetry.
|
||||
const fits = candidates.find((m) => prefix.length + content.length + 1 + m.length <= width);
|
||||
const meta = fits ?? candidates.at(-1)!;
|
||||
if (!fits) {
|
||||
const contentMax = width - prefix.length - meta.length - 1;
|
||||
content = `${content.slice(0, Math.max(contentMax - 1, 0))}…`;
|
||||
}
|
||||
const gap = Math.max(width - prefix.length - content.length - meta.length, 1);
|
||||
return `${prefix}${content}${" ".repeat(gap)}${meta}`;
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ function paintExplorer(text: string, s: Style, focused: boolean): string {
|
||||
if (/[▾▸] (TOPOLOGY|SPECS|NEEDS-YOU)/.test(text)) return s.paint("bright", text, { bold: true });
|
||||
// Slice-17 re-skin: branch guides paint faint (chrome), the row body keeps
|
||||
// its own rules — the tree rails read as structure, never as content.
|
||||
const tree = text.match(/^( *(?:[┊ ] )*(?:├─|└─) )(.*)$/);
|
||||
const tree = text.match(/^( *(?:[│ ] )*(?:├─|└─) )(.*)$/);
|
||||
if (tree) return s.paint("chrome", tree[1]!) + paintExplorerBody(tree[2]!, s);
|
||||
return paintExplorerBody(text, s);
|
||||
}
|
||||
@@ -155,7 +155,10 @@ export function stylizeLines(screen: Screen, s: Style): string[] {
|
||||
else if (tail.startsWith("⚠")) tail = s.paint("warn", tail);
|
||||
return `${s.paint("accent", line.slice(0, closeAt + 1), { bold: true })}${s.paint("bright", path)}${tail}`;
|
||||
}
|
||||
const border = line.indexOf("│");
|
||||
// The pane border lives at the FIXED boundary column (EXPL_W) — located
|
||||
// positionally, never by scanning: the navigator's │ rails would shadow
|
||||
// a first-index search (slice-17 locked-rail resolution).
|
||||
const border = line.charAt(EXPL_W) === "│" ? EXPL_W : -1;
|
||||
if (border >= EXPL_W - 1 && border <= EXPL_W) {
|
||||
const left = line.slice(0, border);
|
||||
const marker = line.slice(border + 1, border + 2);
|
||||
|
||||
@@ -54,15 +54,67 @@ describe("file-tree re-skin (Direction B navigator)", () => {
|
||||
expect(pane.some((l) => l.includes("dev50.driver"))).toBe(true);
|
||||
});
|
||||
|
||||
it("agent rows carry right-aligned ctx% meta; a null ctx renders — (honest-unknown)", () => {
|
||||
it("agent meta is right-aligned runtime · ctx% where it fits, ctx% otherwise — IDENTITY-FIRST (locked mini-req + guard finding 3)", () => {
|
||||
// demo names are long: full identity is kept, the meta degrades to ctx-only
|
||||
const s = makeStore();
|
||||
s.dispatch({ type: "drill", resource: "pod", name: "dev50", target: { host: "vm-host", rig: "openrig-build" } });
|
||||
const screen = renderScreen(s.get(), snap, { cols: 120, rows: 32 });
|
||||
const pane = explorerPane(screen.lines);
|
||||
const pane = explorerPane(renderScreen(s.get(), snap, { cols: 120, rows: 32 }).lines);
|
||||
const driver = pane.find((l) => l.includes("dev50.driver"))!;
|
||||
expect(driver.trimEnd()).toMatch(/62%$/); // demo fixture: driver ctx 62
|
||||
expect(driver.trimEnd()).toMatch(/62%$/); // full name preserved, ctx at the edge
|
||||
const qa = pane.find((l) => l.includes("dev50.qa"))!;
|
||||
expect(qa.trimEnd()).toMatch(/—$/); // demo fixture: qa ctx null → honest
|
||||
expect(qa.trimEnd()).toMatch(/—$/); // ctx null → honest-unknown
|
||||
|
||||
// a short name leaves room: the FULL runtime · ctx meta renders (the
|
||||
// mockup's "claude 18%" display form for a claude-code seat)
|
||||
const shortSnap = {
|
||||
...snap,
|
||||
hosts: [{ name: "h", reachable: true, rigs: [{ name: "r", pods: [{ name: "p", agents: [
|
||||
{ name: "ok", runtime: "claude-code", spec: "", context: 5, tokens: null, status: "active", live: true },
|
||||
] }] }] }],
|
||||
};
|
||||
const s2 = createViewState({ instanceId: "nav-short", getSnapshot: () => shortSnap });
|
||||
s2.dispatch({ type: "drill", resource: "pod", name: "p", target: { host: "h", rig: "r" } });
|
||||
const shortPane = explorerPane(renderScreen(s2.get(), shortSnap, { cols: 120, rows: 32 }).lines);
|
||||
expect(shortPane.find((l) => l.includes("● ok"))!.trimEnd()).toMatch(/claude 5%$/);
|
||||
});
|
||||
|
||||
it("only an identity too long for even the leanest meta truncates with … — meta stays complete at the edge", () => {
|
||||
const longSnap = {
|
||||
...snap,
|
||||
hosts: [{ name: "h", reachable: true, rigs: [{ name: "r", pods: [{ name: "p", agents: [
|
||||
{ name: "an-extremely-long-agent-name-x", runtime: "codex", spec: "", context: 9, tokens: null, status: "active", live: true },
|
||||
] }] }] }],
|
||||
};
|
||||
const s = createViewState({ instanceId: "nav-long", getSnapshot: () => longSnap });
|
||||
s.dispatch({ type: "drill", resource: "pod", name: "p", target: { host: "h", rig: "r" } });
|
||||
const row = explorerPane(renderScreen(s.get(), longSnap, { cols: 120, rows: 32 }).lines).find((l) => l.includes("an-extre"))!;
|
||||
expect(row).toMatch(/…/);
|
||||
expect(row.trimEnd()).toMatch(/9%$/);
|
||||
});
|
||||
|
||||
it("an expanded namespaced spec folder renders its child ONE level deeper, never a sibling (guard finding 2)", () => {
|
||||
const nsSnap = {
|
||||
...snap,
|
||||
specs: [
|
||||
...snap.specs,
|
||||
{ name: "vault-specialist", kind: "agent" as const, runtime: "codex", namespace: "vault", usedByRigs: [] },
|
||||
],
|
||||
};
|
||||
const s = createViewState({ instanceId: "nav-ns", getSnapshot: () => nsSnap });
|
||||
s.dispatch({ type: "jump", section: "specs" });
|
||||
s.dispatch({ type: "toggle-expand", key: "folder:vault" });
|
||||
const screen = renderScreen(s.get(), nsSnap, { cols: 120, rows: 40 });
|
||||
const pane = explorerPane(screen.lines);
|
||||
const folder = pane.find((l) => l.includes("vault/"))!;
|
||||
const child = pane.find((l) => l.includes("vault-specialist"))!;
|
||||
const indentOf = (l: string) => (/^\s*(?:│ )*/.exec(l)?.[0] ?? "").length + (l.match(/├─|└─/)?.index ?? 0);
|
||||
const branchCol = (l: string) => l.search(/├─|└─/);
|
||||
expect(branchCol(child)).toBeGreaterThan(branchCol(folder)); // child branch sits deeper
|
||||
void indentOf;
|
||||
// PIN-1 untouched: the child's action is still the spec drill from the row model
|
||||
const rows = computeExplorerRows(s.get(), nsSnap);
|
||||
const childRow = rows.find((r) => r.key === "spec:vault-specialist")!;
|
||||
expect(childRow.action).toEqual({ type: "drill", resource: "spec", name: "vault-specialist" });
|
||||
});
|
||||
|
||||
it("pod rows carry their agent count right-aligned (moved out of the inline label)", () => {
|
||||
|
||||
@@ -46,7 +46,10 @@ describe("live visual regressions", () => {
|
||||
|
||||
view.dispatch({ type: "content-select", index: tabIndex });
|
||||
screen = renderScreen(view.get(), snap, { cols: 140, rows: 34 });
|
||||
expect(screen.lines[screen.contentTargets[tabIndex]!.y - 1]!.split("│")[1]).toMatch(/^›/);
|
||||
// pane delimiter located by its FIXED boundary (EXPL_W=30 → content at 31):
|
||||
// the slice-17 navigator's │ rails would shadow a first-│ split (guard-
|
||||
// sanctioned truthful floor update; the assertion is unchanged)
|
||||
expect(screen.lines[screen.contentTargets[tabIndex]!.y - 1]!.slice(31)).toMatch(/^›/);
|
||||
|
||||
view.dispatch({ type: "content-select", index: termIndex });
|
||||
screen = renderScreen(view.get(), snap, { cols: 140, rows: 34 });
|
||||
@@ -54,7 +57,8 @@ describe("live visual regressions", () => {
|
||||
|
||||
view.dispatch({ type: "content-select", index: rowIndex });
|
||||
screen = renderScreen(view.get(), snap, { cols: 140, rows: 34 });
|
||||
expect(screen.lines[screen.contentTargets[rowIndex]!.y - 1]!.split("│")[1]).toMatch(/^›/);
|
||||
// fixed-boundary pane delimiter (see the tab-focus pin above)
|
||||
expect(screen.lines[screen.contentTargets[rowIndex]!.y - 1]!.slice(31)).toMatch(/^›/);
|
||||
});
|
||||
|
||||
it("never emits a composed row wider than the terminal", () => {
|
||||
|
||||
Reference in new issue
Block a user