mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
fix(tui): close terminal review gaps
This commit is contained in:
1 parent
caae077c7a
commit
4f78f931d0
22 files changed
+516
-129
No files matched your search
@@ -17,6 +17,7 @@
|
||||
"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 && tsc --noEmit --project packages/tui/tsconfig.json",
|
||||
"build:package": "bash scripts/build-package.sh",
|
||||
"test:tui-package": "node scripts/test-tui-package.mjs",
|
||||
"mirror-skills": "node scripts/mirror-skills.mjs",
|
||||
"mirror-skills:check": "node scripts/mirror-skills.mjs --check"
|
||||
},
|
||||
|
||||
@@ -26,12 +26,14 @@
|
||||
"url": "https://esoteric.run"
|
||||
},
|
||||
"bin": {
|
||||
"rig": "dist/bin-wrapper.js"
|
||||
"rig": "dist/bin-wrapper.js",
|
||||
"openrig-tui": "tui/dist/main.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"daemon",
|
||||
"ui",
|
||||
"tui",
|
||||
"scripts"
|
||||
],
|
||||
"scripts": {
|
||||
|
||||
@@ -16,15 +16,15 @@ export function demoSnapshot(): FleetSnapshot {
|
||||
{
|
||||
name: "dev50",
|
||||
agents: [
|
||||
{ name: "dev50.driver", runtime: "claude-code", spec: "driver-agent", context: 62, tokens: "118k", status: "running", session: "dev50-driver@openrig-build", attach: "tmux attach -t dev50-driver@openrig-build" },
|
||||
{ name: "dev50.guard", runtime: "codex", spec: "guard-agent", context: 31, tokens: "54k", status: "idle", session: "dev50-guard@openrig-build" },
|
||||
{ name: "dev50.qa", runtime: "codex", spec: "qa-agent", context: null, tokens: null, status: "unknown", session: "dev50-qa@openrig-build" },
|
||||
{ name: "dev50.driver", runtime: "claude-code", spec: "driver-agent", context: 62, tokens: "118k", status: "active", live: true, canRun: false, session: "dev50-driver@openrig-build", attach: "tmux attach -t dev50-driver@openrig-build" },
|
||||
{ name: "dev50.guard", runtime: "codex", spec: "guard-agent", context: 31, tokens: "54k", status: "idle", live: true, canRun: false, session: "dev50-guard@openrig-build" },
|
||||
{ name: "dev50.qa", runtime: "codex", spec: "qa-agent", context: null, tokens: null, status: "unknown", live: false, canRun: true, session: "dev50-qa@openrig-build" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "orch",
|
||||
agents: [
|
||||
{ name: "orch.lead", runtime: "codex", spec: "lead-agent", context: 88, tokens: "203k", status: "needs-attention" },
|
||||
{ name: "orch.lead", runtime: "codex", spec: "lead-agent", context: 88, tokens: "203k", status: "needs-attention", live: true, canRun: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -4,21 +4,22 @@
|
||||
// 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";
|
||||
import { SECTION_REGISTRY } from "./sections.js";
|
||||
import type { Action, ResourceKind, SectionDef } from "./types.js";
|
||||
|
||||
const SECTIONS = ["topology", "specs", "needs"] as const;
|
||||
const RESOURCES: ResourceKind[] = ["host", "rig", "pod", "agent", "spec"];
|
||||
|
||||
export function parseCommand(raw: string): Action {
|
||||
export function parseCommand(raw: string, sections: readonly SectionDef[] = SECTION_REGISTRY): 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 };
|
||||
const names = sections.map((entry) => entry.name);
|
||||
if (names.includes(section)) return { type: "jump", section };
|
||||
return {
|
||||
type: "error",
|
||||
message: `unknown section ":${section}" — known: ${SECTIONS.map((s) => ":" + s).join(" ")}`,
|
||||
message: `unknown section ":${section}" — known: ${names.map((s) => ":" + s).join(" ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ interface NodeInventoryRead {
|
||||
nodeKind: "agent" | "infrastructure";
|
||||
runtime: string | null;
|
||||
lifecycleState: string;
|
||||
sessionStatus?: string | null;
|
||||
startupStatus?: string | null;
|
||||
terminalActive?: boolean | null;
|
||||
agentActivity?: { state?: string } | null;
|
||||
canonicalSessionName: string | null;
|
||||
tmuxAttachCommand?: string | null;
|
||||
resolvedSpecName: string | null;
|
||||
@@ -95,8 +99,9 @@ interface NeedsYouItemRead {
|
||||
destinationSession: string | null;
|
||||
derived: { kind: string; evidence: string; threshold: string } | null;
|
||||
}
|
||||
interface ReviewRigRead {
|
||||
interface ReviewFleetRead {
|
||||
needsYou?: { items?: NeedsYouItemRead[] };
|
||||
hosts?: Array<{ hostId: string; status: { status: string } }>;
|
||||
}
|
||||
interface AttentionAggregateRead {
|
||||
hosts?: Array<{ hostId: string; status: string; error?: string }>;
|
||||
@@ -123,8 +128,13 @@ function toAgentRow(node: NodeInventoryRead): AgentRow {
|
||||
// honest-unknown: no value in the projection → null → renders "—"
|
||||
context: known && ctx.usedPercentage != null ? Math.round(ctx.usedPercentage) : null,
|
||||
tokens: known ? fmtTokens(ctx.totalInputTokens, ctx.totalOutputTokens) : null,
|
||||
// PIN 2: the maintained projection's lifecycleState VERBATIM
|
||||
status: node.lifecycleState,
|
||||
// Mirror the maintained web projection: lifecycle truth drives actions,
|
||||
// while session/terminal activity drives the visible status label.
|
||||
status: node.sessionStatus === "running" || node.sessionStatus === "ready"
|
||||
? (node.terminalActive === true || (node.terminalActive == null && node.agentActivity?.state === "running") ? "active" : "idle")
|
||||
: (node.sessionStatus ?? "unknown"),
|
||||
live: node.lifecycleState === "running",
|
||||
canRun: node.lifecycleState !== "running",
|
||||
session: node.canonicalSessionName,
|
||||
attach: node.tmuxAttachCommand ?? null,
|
||||
};
|
||||
@@ -207,7 +217,7 @@ export async function hydrateSnapshot(client: DaemonClient, reviewCache?: SpecRe
|
||||
safe<AttentionAggregateRead>("attention-aggregate", () => client.attentionAggregate()),
|
||||
safe<RigSummaryRead[]>("rigs-summary", () => client.rigsSummary()),
|
||||
safe<SpecLibraryRead[]>("specs-library", () => client.specsLibrary()),
|
||||
safe<ReviewRigRead>("review-rig", () => client.reviewRig()),
|
||||
safe<ReviewFleetRead>("review-fleet", () => client.reviewFleet()),
|
||||
safe<StreamItemRead[]>("stream-list", () => client.streamList()),
|
||||
]);
|
||||
|
||||
@@ -372,7 +382,8 @@ export async function hydrateSnapshot(client: DaemonClient, reviewCache?: SpecRe
|
||||
hosts: [localHost, ...remoteHosts],
|
||||
specs,
|
||||
needs,
|
||||
humanQueueProbed: review != null,
|
||||
humanQueueProbed: review != null && Array.isArray(review.hosts) && review.hosts.length > 0
|
||||
&& review.hosts.every((host) => host.status.status === "ok"),
|
||||
humanQueue,
|
||||
hostsDown,
|
||||
stream: (streamItems ?? []).map((s) => ({ tsEmitted: s.tsEmitted, sourceSession: s.sourceSession, body: s.body })),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { createViewState, defaultSections, emptySnapshot, computeExplorerRows, findAgent, findSpec, findRig, agentsRunningSpec, agentsRunningSpecTargets } from "./state.js";
|
||||
export { decodeInput, resolveKeyAction, 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";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 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";
|
||||
import type { Action, InputEvent, Screen, ViewState } from "./types.js";
|
||||
|
||||
const SGR_MOUSE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
|
||||
|
||||
@@ -73,6 +73,29 @@ export function sgrClick(x: number, y: number): string {
|
||||
return `\x1b[<0;${x};${y}M\x1b[<0;${x};${y}m`;
|
||||
}
|
||||
|
||||
/** Resolve directional/Enter keys against the currently rendered pane. */
|
||||
export function resolveKeyAction(
|
||||
event: Extract<InputEvent, { type: "key" }>,
|
||||
state: ViewState,
|
||||
screen: Screen,
|
||||
explorerCount: number,
|
||||
): Action | null {
|
||||
if (event.key === "left") return { type: "focus", pane: "explorer" };
|
||||
if (event.key === "right") return screen.contentTargets.length > 0 ? { type: "focus", pane: "content" } : null;
|
||||
if (event.key === "up" || event.key === "down") {
|
||||
const delta = event.key === "down" ? 1 : -1;
|
||||
return state.focusedPane === "content"
|
||||
? { type: "content-select", delta }
|
||||
: { type: "select", delta, rowCount: explorerCount };
|
||||
}
|
||||
if (event.key === "enter") {
|
||||
return state.focusedPane === "content"
|
||||
? (screen.contentTargets[state.contentSelection]?.action ?? { type: "error", message: "nothing selected in content" })
|
||||
: { type: "activate" };
|
||||
}
|
||||
return "action" in event ? event.action : null;
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// 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 { decodeInput, resolveKeyAction, 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";
|
||||
@@ -42,6 +42,10 @@ async function run(): Promise<void> {
|
||||
const cols = process.stdout.columns ?? 120;
|
||||
const rows = process.stdout.rows ?? 32;
|
||||
lastScreen = renderScreen(view.get(), snapshot, { cols, rows }, inputLine);
|
||||
if (view.get().contentMaxOffset !== lastScreen.contentMaxOffset || view.get().contentTargetCount !== lastScreen.contentTargets.length) {
|
||||
view.dispatch({ type: "layout", contentMaxOffset: lastScreen.contentMaxOffset, contentTargetCount: lastScreen.contentTargets.length });
|
||||
lastScreen = renderScreen(view.get(), snapshot, { cols, rows }, inputLine);
|
||||
}
|
||||
process.stdout.write("\x1b[H" + lastScreen.lines.map((l) => "\x1b[2K" + l).join("\r\n"));
|
||||
}
|
||||
|
||||
@@ -121,16 +125,19 @@ async function run(): Promise<void> {
|
||||
inputLine = "";
|
||||
} else if (ev.type === "key" && ev.key === "enter") {
|
||||
if (inputLine !== "") {
|
||||
view.dispatch(parseCommand(inputLine));
|
||||
perform(parseCommand(inputLine, view.get().sections));
|
||||
inputLine = "";
|
||||
} else {
|
||||
view.dispatch({ type: "activate" });
|
||||
if (lastScreen) {
|
||||
const action = resolveKeyAction(ev, view.get(), lastScreen, computeExplorerRows(view.get(), snapshot).length);
|
||||
if (action) perform(action);
|
||||
}
|
||||
}
|
||||
} 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);
|
||||
if (lastScreen) {
|
||||
const action = resolveKeyAction(ev, view.get(), lastScreen, computeExplorerRows(view.get(), snapshot).length);
|
||||
if (action) perform(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) perform(hit.action);
|
||||
|
||||
+40
-20
@@ -4,7 +4,7 @@
|
||||
// agent-refs, Needs-You items) — so a mouse click anywhere resolves 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, findAgentBySession, agentsRunningSpec } from "./state.js";
|
||||
import { computeExplorerRows, findAgent, findSpec, findAgentBySession, agentsRunningSpec, agentsRunningSpecTargets } from "./state.js";
|
||||
import type { Action, FleetSnapshot, NeedsItem, Screen, ViewState } from "./types.js";
|
||||
|
||||
const EXPL_W = 30;
|
||||
@@ -68,10 +68,10 @@ function specTabsLine(state: ViewState, name: string): ContentLine {
|
||||
}
|
||||
|
||||
function needsLine(prefix: string, item: NeedsItem, snap: FleetSnapshot): ContentLine {
|
||||
const agent = findAgentBySession(snap, item.target);
|
||||
const found = findAgentBySession(snap, item.target);
|
||||
return {
|
||||
text: `${prefix}${item.kind} ${item.target} — ${item.detail}${agent ? " (open ▸)" : ""}`,
|
||||
...(agent ? { action: { type: "drill", resource: "agent", name: agent.name } as const } : {}),
|
||||
text: `${prefix}${item.kind} ${item.target} — ${item.detail}${found ? " (open ▸)" : ""}`,
|
||||
...(found ? { action: { type: "drill", resource: "agent", name: found.agent.name, target: { host: found.host.name, rig: found.rig.name, pod: found.pod.name } } as const } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,28 +116,37 @@ function contentLines(state: ViewState, snap: FleetSnapshot): ContentLine[] {
|
||||
if (state.section === "topology") {
|
||||
if (state.runningOf) {
|
||||
lines.push({ text: `seats running spec "${state.runningOf}":` });
|
||||
for (const seat of agentsRunningSpec(snap, state.runningOf))
|
||||
lines.push({ text: ` ● ${seat} (open: agent ${seat})`, action: { type: "drill", resource: "agent", name: seat } });
|
||||
for (const seat of agentsRunningSpecTargets(snap, state.runningOf))
|
||||
lines.push({
|
||||
text: ` ● ${seat.agent.name} (open: agent ${seat.agent.name})`,
|
||||
action: { type: "drill", resource: "agent", name: seat.agent.name, target: { host: seat.host.name, rig: seat.rig.name, pod: seat.pod.name } },
|
||||
});
|
||||
if (lines.length === 1) lines.push({ text: " (no seats currently run it)" });
|
||||
return lines;
|
||||
}
|
||||
const leaf = state.drill.at(-1);
|
||||
if (leaf?.kind === "agent") {
|
||||
const found = findAgent(snap, leaf.name);
|
||||
const hostName = state.drill.find((part) => part.kind === "host")?.name;
|
||||
const rigName = state.drill.find((part) => part.kind === "rig")?.name;
|
||||
const podName = state.drill.find((part) => part.kind === "pod")?.name;
|
||||
const found = hostName ? findAgent(snap, leaf.name, { host: hostName, rig: rigName, pod: podName }) : findAgent(snap, leaf.name);
|
||||
if (!found) return [{ text: `agent "${leaf.name}" not in the current snapshot` }];
|
||||
const { agent, rig, pod } = found;
|
||||
lines.push({ text: `agent ${agent.name}` });
|
||||
lines.push({ text: ` rig ${rig.name} · pod ${pod.name} · runtime ${agent.runtime}` });
|
||||
if (agent.spec)
|
||||
lines.push({ text: ` spec ${agent.spec} (open: spec-of ${agent.name})`, action: { type: "cross", kind: "spec-of", name: agent.name } });
|
||||
if (agent.spec && findSpec(snap, agent.spec))
|
||||
lines.push({ text: ` spec ${agent.spec} (open: spec-of ${agent.name})`, action: { type: "cross", kind: "spec-of", name: agent.name, target: { host: found.host.name, rig: rig.name, pod: pod.name } } });
|
||||
else if (agent.spec) lines.push({ text: ` spec ${agent.spec} (not in library)` });
|
||||
lines.push({ text: ` state ${agent.status}` });
|
||||
if (agent.attach) lines.push({ text: ` attach: ${agent.attach}` });
|
||||
lines.push({ text: ` term ▸ (open pod terminal)`, action: { type: "act", act: "open-terminal", view: `pod:${rig.name}/${pod.name}` } });
|
||||
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 [{ text: "no rig in view — waiting on the daemon read (honest-empty, not fabricated)" }];
|
||||
const hostName = state.drill.find((d) => d.kind === "host")?.name;
|
||||
const host = (hostName ? snap.hosts.find((candidate) => candidate.name === hostName) : snap.hosts[0]);
|
||||
const rig = host?.rigs.find((candidate) => candidate.name === rigName);
|
||||
if (!rig || !host) return [{ text: "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
|
||||
@@ -151,7 +160,7 @@ function contentLines(state: ViewState, snap: FleetSnapshot): ContentLine[] {
|
||||
for (const pod of rig.pods)
|
||||
lines.push({
|
||||
text: ` ▾ ${pod.name} — ${pod.agents.length} agents`,
|
||||
action: { type: "drill", resource: "pod", name: pod.name },
|
||||
action: { type: "drill", resource: "pod", name: pod.name, target: { host: host.name, rig: rig.name } },
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
@@ -163,7 +172,7 @@ function contentLines(state: ViewState, snap: FleetSnapshot): ContentLine[] {
|
||||
// write contract: `run ▸` = the rig-restore write (rendered only where
|
||||
// it applies — the seat is not running); `term ▸` = the terminal-open
|
||||
// view contract (pod-scoped, the web's granularity). No false affordance.
|
||||
const canRun = a.status !== "running";
|
||||
const canRun = a.canRun ?? !a.live;
|
||||
const actionsCell = canRun ? "run ▸ · term ▸" : "term ▸";
|
||||
const zones: ContentLine["zones"] = [];
|
||||
const termOffset = actionsColStart + actionsCell.indexOf("term ▸");
|
||||
@@ -187,7 +196,7 @@ function contentLines(state: ViewState, snap: FleetSnapshot): ContentLine[] {
|
||||
a.status,
|
||||
actionsCell,
|
||||
]),
|
||||
action: { type: "drill", resource: "agent", name: a.name },
|
||||
action: { type: "drill", resource: "agent", name: a.name, target: { host: host.name, rig: rig.name, pod: a.pod } },
|
||||
zones,
|
||||
});
|
||||
}
|
||||
@@ -327,7 +336,7 @@ export function renderScreen(state: ViewState, snap: FleetSnapshot, options: Ren
|
||||
const content = contentLines(state, snap);
|
||||
const footer = state.footerOn ? snap.stream.at(-1) : undefined;
|
||||
const chromeRows = footer ? 3 : 2; // bottom rule + status line (+ footer)
|
||||
const bodyRows = Math.min(Math.max(explorer.length, content.length), Math.max(rows - 2 - chromeRows, 1));
|
||||
const bodyRows = Math.max(rows - 2 - chromeRows, 1);
|
||||
const explorerStart = Math.min(
|
||||
Math.max(state.selection - bodyRows + 1, 0),
|
||||
Math.max(explorer.length - bodyRows, 0),
|
||||
@@ -349,6 +358,7 @@ export function renderScreen(state: ViewState, snap: FleetSnapshot, options: Ren
|
||||
});
|
||||
}
|
||||
const explorerRows: Screen["explorerRows"] = [];
|
||||
const contentTargets: Screen["contentTargets"] = [];
|
||||
for (let i = 0; i < bodyRows; i++) {
|
||||
const y = lines.length + 1; // 1-based terminal row this line will occupy
|
||||
const explorerIndex = explorerStart + i;
|
||||
@@ -356,15 +366,25 @@ export function renderScreen(state: ViewState, snap: FleetSnapshot, options: Ren
|
||||
const marker = explorerIndex === state.selection && row ? "›" : " ";
|
||||
const left = pad(row ? `${marker}${row.label}` : "", EXPL_W);
|
||||
const item = visibleContent[i];
|
||||
lines.push(pad(`${left}│ ${item?.text ?? ""}`, cols));
|
||||
const hasContentTarget = !!item?.action || (item?.zones?.length ?? 0) > 0;
|
||||
const targetIndex = contentTargets.length;
|
||||
const contentMarker = state.focusedPane === "content" && hasContentTarget && targetIndex === state.contentSelection ? "›" : " ";
|
||||
lines.push(pad(`${left}│${contentMarker}${item?.text ?? ""}`, cols));
|
||||
if (row) {
|
||||
hitMap.push({ y, x1: 1, x2: EXPL_W, action: row.action });
|
||||
explorerRows.push({ ...row, y });
|
||||
}
|
||||
// zones first: hit lookup takes the first match, so a zone wins over the row-wide action
|
||||
for (const z of item?.zones ?? [])
|
||||
hitMap.push({ y, x1: EXPL_W + 3 + z.start, x2: EXPL_W + 2 + z.end, action: z.action });
|
||||
if (item?.action) hitMap.push({ y, x1: EXPL_W + 3, x2: cols, action: item.action });
|
||||
for (const z of item?.zones ?? []) {
|
||||
const target = { y, x1: EXPL_W + 3 + z.start, x2: EXPL_W + 2 + z.end, action: z.action };
|
||||
hitMap.push(target);
|
||||
contentTargets.push(target);
|
||||
}
|
||||
if (item?.action) {
|
||||
const target = { y, x1: EXPL_W + 3, x2: cols, action: item.action };
|
||||
hitMap.push(target);
|
||||
contentTargets.push(target);
|
||||
}
|
||||
}
|
||||
|
||||
if (footer) lines.push(pad(`≋ ${footer.tsEmitted.slice(11, 16)} ${footer.sourceSession}: ${footer.body}`, cols));
|
||||
@@ -378,5 +398,5 @@ export function renderScreen(state: ViewState, snap: FleetSnapshot, options: Ren
|
||||
),
|
||||
);
|
||||
while (lines.length < rows) lines.push("");
|
||||
return { lines: lines.slice(0, rows), hitMap, explorerRows };
|
||||
return { lines: lines.slice(0, rows), hitMap, contentTargets, contentMaxOffset: maxContentOffset, explorerRows };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { SectionDef } from "./types.js";
|
||||
|
||||
/** The sole section registry consumed by state and command parsing. */
|
||||
export const SECTION_REGISTRY: readonly SectionDef[] = [
|
||||
{
|
||||
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/fleet + /api/queue/list?attention=1 (existing)",
|
||||
drillShape: "flat",
|
||||
},
|
||||
];
|
||||
@@ -26,6 +26,7 @@ export function describeState(state: ViewState) {
|
||||
screen: state.section,
|
||||
drill: state.drill.map((d) => `${d.kind}:${d.name}`),
|
||||
filter: state.filter || undefined,
|
||||
viewTab: state.viewTab,
|
||||
error: state.lastError ?? undefined,
|
||||
};
|
||||
}
|
||||
@@ -75,7 +76,7 @@ export async function createControlSocket(options: {
|
||||
continue;
|
||||
}
|
||||
// The one mutation path: grammar → dispatch. Nothing else.
|
||||
const next = view.dispatch(parseCommand(line));
|
||||
const next = view.dispatch(parseCommand(line, view.get().sections));
|
||||
conn.write(JSON.stringify(describeState(next)) + "\n");
|
||||
onMutation?.();
|
||||
}
|
||||
|
||||
+123
-60
@@ -13,25 +13,10 @@ import type {
|
||||
ViewState,
|
||||
ViewStateStore,
|
||||
} from "./types.js";
|
||||
import { SECTION_REGISTRY } from "./sections.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",
|
||||
},
|
||||
];
|
||||
return SECTION_REGISTRY.map((section) => ({ ...section }));
|
||||
}
|
||||
|
||||
export function emptySnapshot(): FleetSnapshot {
|
||||
@@ -58,6 +43,10 @@ export function createViewState(options: CreateViewStateOptions): ViewStateStore
|
||||
runningOf: null,
|
||||
viewTab: "table",
|
||||
contentOffset: 0,
|
||||
contentMaxOffset: 0,
|
||||
contentTargetCount: 0,
|
||||
contentSelection: 0,
|
||||
focusedPane: "explorer",
|
||||
footerOn: true,
|
||||
notice: null,
|
||||
lastError: null,
|
||||
@@ -91,12 +80,31 @@ function reduce(state: ViewState, action: Action, snap: FleetSnapshot): ViewStat
|
||||
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, viewTab: "table", contentOffset: 0 };
|
||||
return resetContent({ ...next, section: action.section, drill: [], filter: "", selection: 0, runningOf: null, viewTab: "table" });
|
||||
}
|
||||
case "tab": {
|
||||
const rigSpec = state.section === "specs" && state.drill.at(-1)?.kind === "spec" && findSpec(snap, state.drill.at(-1)!.name)?.kind === "rig";
|
||||
const allowed = rigSpec ? ["topology", "configuration", "yaml"] : state.section === "topology" ? ["table", "overview"] : [];
|
||||
if (!allowed.includes(action.tab)) return { ...next, lastError: `tab ${action.tab} is not available in this content context` };
|
||||
return resetContent({ ...next, viewTab: action.tab });
|
||||
}
|
||||
case "tab":
|
||||
return { ...next, viewTab: action.tab, contentOffset: 0 };
|
||||
case "content-scroll":
|
||||
return { ...next, contentOffset: Math.max(0, state.contentOffset + action.delta) };
|
||||
return { ...next, contentOffset: Math.min(Math.max(0, state.contentOffset + action.delta), state.contentMaxOffset) };
|
||||
case "focus":
|
||||
return { ...next, focusedPane: action.pane };
|
||||
case "content-select": {
|
||||
const count = Math.max(state.contentTargetCount, 1);
|
||||
const target = action.index ?? state.contentSelection + (action.delta ?? 0);
|
||||
return { ...next, contentSelection: Math.min(Math.max(target, 0), count - 1) };
|
||||
}
|
||||
case "layout":
|
||||
return {
|
||||
...next,
|
||||
contentMaxOffset: Math.max(action.contentMaxOffset, 0),
|
||||
contentTargetCount: Math.max(action.contentTargetCount, 0),
|
||||
contentOffset: Math.min(state.contentOffset, Math.max(action.contentMaxOffset, 0)),
|
||||
contentSelection: Math.min(state.contentSelection, Math.max(action.contentTargetCount - 1, 0)),
|
||||
};
|
||||
case "footer":
|
||||
return { ...next, footerOn: action.on ?? !state.footerOn };
|
||||
case "act":
|
||||
@@ -106,7 +114,7 @@ function reduce(state: ViewState, action: Action, snap: FleetSnapshot): ViewStat
|
||||
case "notice":
|
||||
return { ...next, notice: action.message };
|
||||
case "filter":
|
||||
return { ...next, filter: action.text, selection: 0, contentOffset: 0 };
|
||||
return resetContent({ ...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);
|
||||
@@ -120,26 +128,38 @@ function reduce(state: ViewState, action: Action, snap: FleetSnapshot): ViewStat
|
||||
return reduce(next, row.action, snap);
|
||||
}
|
||||
case "drill": {
|
||||
const drilled = drillTo(next, action.resource, action.name, snap);
|
||||
const drilled = drillTo(next, action.resource, action.name, snap, action.target);
|
||||
if (drilled.lastError) return drilled;
|
||||
const spec = action.resource === "spec" ? findSpec(snap, action.name) : null;
|
||||
return { ...drilled, viewTab: spec?.kind === "rig" ? "configuration" : "table", contentOffset: 0 };
|
||||
return resetContent({ ...drilled, viewTab: spec?.kind === "rig" ? "configuration" : "table" });
|
||||
}
|
||||
case "cross":
|
||||
return crossNav(next, action.kind, action.name, snap);
|
||||
return crossNav(next, action.kind, action.name, snap, action.target);
|
||||
default:
|
||||
return { ...next, lastError: "unknown action" };
|
||||
}
|
||||
}
|
||||
|
||||
function resetContent(state: ViewState): ViewState {
|
||||
return { ...state, contentOffset: 0, contentMaxOffset: 0, contentTargetCount: 0, contentSelection: 0, focusedPane: "explorer" };
|
||||
}
|
||||
|
||||
// --- snapshot lookups (pure; no daemon calls here) ---
|
||||
|
||||
export function findAgent(snap: FleetSnapshot, name: string) {
|
||||
function agentMatches(snap: FleetSnapshot, name: string, target?: { host: string; rig?: string; pod?: string }) {
|
||||
const matches = [];
|
||||
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;
|
||||
for (const agent of pod.agents)
|
||||
if (agent.name === name && (!target || (host.name === target.host && (!target.rig || rig.name === target.rig) && (!target.pod || pod.name === target.pod))))
|
||||
matches.push({ host, rig, pod, agent });
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function findAgent(snap: FleetSnapshot, name: string, target?: { host: string; rig?: string; pod?: string }) {
|
||||
const matches = agentMatches(snap, name, target);
|
||||
return matches.length === 1 ? matches[0]! : null;
|
||||
}
|
||||
|
||||
export function findSpec(snap: FleetSnapshot, name: string) {
|
||||
@@ -151,69 +171,97 @@ export function findAgentBySession(snap: FleetSnapshot, session: 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.session === session) return agent;
|
||||
for (const agent of pod.agents) if (agent.session === session) return { host, rig, pod, agent };
|
||||
return 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;
|
||||
function rigMatches(snap: FleetSnapshot, name: string, hostName?: string) {
|
||||
return snap.hosts.flatMap((host) => host.rigs
|
||||
.filter((rig) => rig.name === name && (!hostName || host.name === hostName))
|
||||
.map((rig) => ({ host, rig })));
|
||||
}
|
||||
|
||||
export function findRig(snap: FleetSnapshot, name: string, hostName?: string) {
|
||||
const matches = rigMatches(snap, name, hostName);
|
||||
return matches.length === 1 ? matches[0]! : null;
|
||||
}
|
||||
|
||||
export function agentsRunningSpec(snap: FleetSnapshot, specName: string): string[] {
|
||||
const out: string[] = [];
|
||||
return agentsRunningSpecTargets(snap, specName).map(({ agent }) => agent.name);
|
||||
}
|
||||
|
||||
export function agentsRunningSpecTargets(snap: FleetSnapshot, specName: string) {
|
||||
const out = [];
|
||||
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);
|
||||
for (const agent of pod.agents) if (agent.live && agent.spec === specName) out.push({ host, rig, pod, agent });
|
||||
return out;
|
||||
}
|
||||
|
||||
function drillTo(state: ViewState, resource: string, name: string, snap: FleetSnapshot): ViewState {
|
||||
function drillTo(state: ViewState, resource: string, name: string, snap: FleetSnapshot, target?: { host: string; rig?: string; pod?: string }): 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);
|
||||
const qualified = !target ? parseQualified(name, 2) : null;
|
||||
const rigName = qualified?.at(-1) ?? name;
|
||||
const hostName = qualified?.[0] ?? target?.host;
|
||||
const matches = rigMatches(snap, rigName, hostName);
|
||||
if (matches.length > 1) return { ...state, lastError: `ambiguous rig "${name}" — use rig <host>/<rig>` };
|
||||
const found = matches[0];
|
||||
if (!found) return { ...state, lastError: `no such rig "${name}"` };
|
||||
return {
|
||||
...state,
|
||||
section: "topology",
|
||||
drill: [
|
||||
{ kind: "host", name: found.host.name },
|
||||
{ kind: "rig", name },
|
||||
{ kind: "rig", name: rigName },
|
||||
],
|
||||
selection: 0,
|
||||
runningOf: null,
|
||||
};
|
||||
}
|
||||
case "pod": {
|
||||
const qualified = !target ? parseQualified(name, 3) : null;
|
||||
const podName = qualified?.at(-1) ?? name;
|
||||
const hostName = qualified?.[0] ?? target?.host;
|
||||
const rigName = qualified?.[1] ?? target?.rig;
|
||||
const matches = [];
|
||||
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,
|
||||
};
|
||||
for (const pod of rig.pods)
|
||||
if (pod.name === podName && (!hostName || host.name === hostName) && (!rigName || rig.name === rigName)) matches.push({ host, rig, pod });
|
||||
if (matches.length > 1) return { ...state, lastError: `ambiguous pod "${name}" — use pod <host>/<rig>/<pod>` };
|
||||
const found = matches[0];
|
||||
if (found) return {
|
||||
...state,
|
||||
section: "topology",
|
||||
drill: [
|
||||
{ kind: "host", name: found.host.name },
|
||||
{ kind: "rig", name: found.rig.name },
|
||||
{ kind: "pod", name: podName },
|
||||
],
|
||||
selection: 0,
|
||||
runningOf: null,
|
||||
};
|
||||
return { ...state, lastError: `no such pod "${name}"` };
|
||||
}
|
||||
case "agent": {
|
||||
const found = findAgent(snap, name);
|
||||
const qualified = !target ? parseQualifiedAgent(name) : null;
|
||||
const agentName = qualified?.name ?? name;
|
||||
const exactTarget = qualified?.target ?? target;
|
||||
const matches = agentMatches(snap, agentName, exactTarget);
|
||||
if (matches.length > 1) return { ...state, lastError: `ambiguous agent "${name}" — use agent <host>/<rig>/<pod>/<agent>` };
|
||||
const found = matches[0];
|
||||
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 },
|
||||
{ kind: "agent", name: agentName },
|
||||
];
|
||||
return { ...state, section: "topology", drill, selection: 0, runningOf: null };
|
||||
}
|
||||
@@ -226,22 +274,37 @@ function drillTo(state: ViewState, resource: string, name: string, snap: FleetSn
|
||||
}
|
||||
}
|
||||
|
||||
function crossNav(state: ViewState, kind: "spec-of" | "running", name: string, snap: FleetSnapshot): ViewState {
|
||||
function parseQualifiedAgent(value: string): { name: string; target: { host: string; rig: string; pod: string } } | null {
|
||||
const [host, rig, pod, ...agentParts] = value.split("/");
|
||||
if (!host || !rig || !pod || agentParts.length === 0) return null;
|
||||
return { name: agentParts.join("/"), target: { host, rig, pod } };
|
||||
}
|
||||
|
||||
function parseQualified(value: string, count: number): string[] | null {
|
||||
const parts = value.split("/");
|
||||
return parts.length === count && parts.every(Boolean) ? parts : null;
|
||||
}
|
||||
|
||||
function crossNav(state: ViewState, kind: "spec-of" | "running", name: string, snap: FleetSnapshot, target?: { host: string; rig?: string; pod?: string }): ViewState {
|
||||
if (kind === "spec-of") {
|
||||
const found = findAgent(snap, name);
|
||||
const qualified = !target ? parseQualifiedAgent(name) : null;
|
||||
const agentName = qualified?.name ?? name;
|
||||
const matches = agentMatches(snap, agentName, qualified?.target ?? target);
|
||||
if (matches.length > 1) return { ...state, lastError: `ambiguous agent "${name}" — use spec-of <host>/<rig>/<pod>/<agent>` };
|
||||
const found = matches[0];
|
||||
if (!found) return { ...state, lastError: `no such agent "${name}"` };
|
||||
return {
|
||||
if (!findSpec(snap, found.agent.spec)) return { ...state, lastError: `spec "${found.agent.spec}" not in the library` };
|
||||
return resetContent({
|
||||
...state,
|
||||
section: "specs",
|
||||
drill: [{ kind: "spec", name: found.agent.spec }],
|
||||
selection: 0,
|
||||
runningOf: null,
|
||||
viewTab: "table",
|
||||
contentOffset: 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
if (!findSpec(snap, name)) return { ...state, lastError: `no such spec "${name}"` };
|
||||
return { ...state, section: "topology", drill: [], runningOf: name, filter: "", selection: 0, viewTab: "table", contentOffset: 0 };
|
||||
return resetContent({ ...state, section: "topology", drill: [], runningOf: name, filter: "", selection: 0, viewTab: "table" });
|
||||
}
|
||||
|
||||
// The explorer row model — pure function of (state, snapshot), shared by the
|
||||
@@ -268,16 +331,16 @@ export function computeExplorerRows(state: ViewState, snap: FleetSnapshot): Expl
|
||||
});
|
||||
for (const rig of host.rigs) {
|
||||
const stateSuffix = rig.lifecycleState && rig.lifecycleState !== "running" ? ` (${rig.lifecycleState})` : "";
|
||||
rows.push({ label: ` ▾ ${rig.name}${stateSuffix}`, action: { type: "drill", resource: "rig", name: rig.name } });
|
||||
rows.push({ label: ` ▾ ${rig.name}${stateSuffix}`, action: { type: "drill", resource: "rig", name: rig.name, target: { host: host.name } } });
|
||||
for (const pod of rig.pods) {
|
||||
rows.push({
|
||||
label: ` ▾ ${pod.name} (${pod.agents.length})`,
|
||||
action: { type: "drill", resource: "pod", name: pod.name },
|
||||
action: { type: "drill", resource: "pod", name: pod.name, target: { host: host.name, rig: rig.name } },
|
||||
});
|
||||
for (const agent of pod.agents)
|
||||
rows.push({
|
||||
label: ` ● ${agent.name}`,
|
||||
action: { type: "drill", resource: "agent", name: agent.name },
|
||||
action: { type: "drill", resource: "agent", name: agent.name, target: { host: host.name, rig: rig.name, pod: pod.name } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ export interface AgentRow {
|
||||
context: number | null;
|
||||
tokens: string | null;
|
||||
status: string;
|
||||
/** lifecycle truth, separate from the displayed activity status */
|
||||
live: boolean;
|
||||
/** whether the lifecycle restore action is valid */
|
||||
canRun?: boolean;
|
||||
/** canonicalSessionName where served — joins Needs-You targets to topology */
|
||||
session?: string | null;
|
||||
/** served tmuxAttachCommand, displayed verbatim in the detail view (web parity) */
|
||||
@@ -134,6 +138,12 @@ export type GetSnapshot = () => FleetSnapshot;
|
||||
|
||||
export type ResourceKind = "host" | "rig" | "pod" | "agent" | "spec";
|
||||
|
||||
export interface ResourceTarget {
|
||||
host: string;
|
||||
rig?: string;
|
||||
pod?: string;
|
||||
}
|
||||
|
||||
export interface DrillSegment {
|
||||
kind: ResourceKind;
|
||||
name: string;
|
||||
@@ -148,10 +158,13 @@ export type Action =
|
||||
| { 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 }
|
||||
| { type: "drill"; resource: ResourceKind; name: string; target?: ResourceTarget }
|
||||
| { type: "cross"; kind: "spec-of" | "running"; name: string; target?: ResourceTarget }
|
||||
| { type: "tab"; tab: ViewTab }
|
||||
| { type: "content-scroll"; delta: number }
|
||||
| { type: "focus"; pane: "explorer" | "content" }
|
||||
| { type: "content-select"; delta?: number; index?: number }
|
||||
| { type: "layout"; contentMaxOffset: number; contentTargetCount: number }
|
||||
| { type: "footer"; on?: boolean }
|
||||
/** drive-structure daemon writes (BR-8/BR-9): executed by the driver loop
|
||||
* against EXISTING write contracts; never a view-state mutation */
|
||||
@@ -178,6 +191,10 @@ export interface ViewState {
|
||||
runningOf: string | null;
|
||||
viewTab: ViewTab;
|
||||
contentOffset: number;
|
||||
contentMaxOffset: number;
|
||||
contentTargetCount: number;
|
||||
contentSelection: number;
|
||||
focusedPane: "explorer" | "content";
|
||||
/** the rig-stream footer is ambient: toggleable, never a navigable view (FR-10) */
|
||||
footerOn: boolean;
|
||||
/** transient result line from an executed act (daemon reply, verbatim) */
|
||||
@@ -207,6 +224,8 @@ export interface HitTarget {
|
||||
export interface Screen {
|
||||
lines: string[];
|
||||
hitMap: HitTarget[];
|
||||
contentTargets: HitTarget[];
|
||||
contentMaxOffset: number;
|
||||
explorerRows: Array<ExplorerRow & { y: number }>;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,12 @@ describe("ACTIONS column = real drive-structure acts (BR-9)", () => {
|
||||
const termHit = hitAt(screen, row.indexOf("term ▸") + 1, rowIdx + 1);
|
||||
expect(termHit?.action).toEqual({ type: "act", act: "open-terminal", view: "pod:openrig-build/dev50" });
|
||||
const cellHit = hitAt(screen, row.indexOf("codex") + 1, rowIdx + 1);
|
||||
expect(cellHit?.action).toEqual({ type: "drill", resource: "agent", name: "dev50.guard" });
|
||||
expect(cellHit?.action).toEqual({
|
||||
type: "drill",
|
||||
resource: "agent",
|
||||
name: "dev50.guard",
|
||||
target: { host: "vm-host", rig: "openrig-build", pod: "dev50" },
|
||||
});
|
||||
});
|
||||
|
||||
it("acts NEVER mutate the view-state; the outcome arrives as a notice (PIN 1 intact)", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCommand } from "../src/grammar.js";
|
||||
import { defaultSections } from "../src/state.js";
|
||||
|
||||
describe("safe-core grammar (FR-1, §4.B)", () => {
|
||||
it("parses :section jump for the three launch sections", () => {
|
||||
@@ -14,6 +15,11 @@ describe("safe-core grammar (FR-1, §4.B)", () => {
|
||||
if (r.type === "error") expect(r.message).toMatch(/unknown section ":bogus"/);
|
||||
});
|
||||
|
||||
it("derives section commands from the same supplied registry", () => {
|
||||
const sections = [...defaultSections(), { name: "extra", sourceRead: "GET /extra", drillShape: "flat" }];
|
||||
expect(parseCommand(":extra", sections)).toEqual({ type: "jump", section: "extra" });
|
||||
});
|
||||
|
||||
it("parses /text filter; bare / clears", () => {
|
||||
expect(parseCommand("/driver")).toEqual({ type: "filter", text: "driver" });
|
||||
expect(parseCommand("/")).toEqual({ type: "filter", text: "" });
|
||||
|
||||
@@ -53,6 +53,7 @@ const FIXTURES: Record<string, unknown> = {
|
||||
rigId: "01JRIG", rigName: "myrig", logicalId: "dev.impl", podId: "01JPOD", podNamespace: "dev",
|
||||
role: "implementer", canonicalSessionName: "dev-impl@myrig", nodeKind: "agent", runtime: "claude-code",
|
||||
sessionStatus: "running", startupStatus: "ready", restoreOutcome: "resumed", oriented: "verified",
|
||||
terminalActive: false,
|
||||
lifecycleState: "running", occupantLifecycle: "active", continuityOutcome: null, handoverResult: null,
|
||||
previousOccupant: null, handoverAt: null, tmuxAttachCommand: null, resumeCommand: null,
|
||||
recoveryGuidance: null, latestError: null, model: null, agentRef: "implementer", profile: "default",
|
||||
@@ -65,6 +66,7 @@ const FIXTURES: Record<string, unknown> = {
|
||||
rigId: "01JRIG", rigName: "myrig", logicalId: "dev.qa", podId: "01JPOD", podNamespace: "dev",
|
||||
role: "qa", canonicalSessionName: "dev-qa@myrig", nodeKind: "agent", runtime: "codex",
|
||||
sessionStatus: null, startupStatus: null, restoreOutcome: "n-a", oriented: "n-a",
|
||||
terminalActive: null,
|
||||
lifecycleState: "detached", occupantLifecycle: "unknown", continuityOutcome: null, handoverResult: null,
|
||||
previousOccupant: null, handoverAt: null, tmuxAttachCommand: null, resumeCommand: null,
|
||||
recoveryGuidance: null, latestError: null, model: null, agentRef: "qa", profile: "default",
|
||||
@@ -85,11 +87,12 @@ const FIXTURES: Record<string, unknown> = {
|
||||
{ id: "a2", kind: "agent", name: "implementer", version: "0.1.0", sourceType: "builtin", sourcePath: "/s/implementer.yaml", relativePath: "agents/implementer.yaml", updatedAt: "2026-08-01T00:00:00.000Z" },
|
||||
{ id: "a3", kind: "workflow", name: "conveyor", version: "0.3.0", sourceType: "user_file", sourcePath: "/s/conveyor.yaml", relativePath: "workflows/conveyor.yaml", updatedAt: "2026-08-01T00:00:00.000Z", isBuiltIn: false, rolesCount: 4, stepsCount: 9, status: "valid", errorMessage: null },
|
||||
],
|
||||
"/api/review/rig": {
|
||||
"/api/review/fleet": {
|
||||
scope: "rig",
|
||||
needsYou: {
|
||||
items: [
|
||||
{ source: "agent", identity: "qitem-2026080201", summary: "approve slice 11 proof", leg: "human-routed", where: "human@kernel", ageIso: "2026-08-02T08:40:00.000Z", priority: "high", tier: "human-gate", evidenceRef: "proof/qa.md", unblocks: null, qitemId: "qitem-2026080201", destinationSession: "human@kernel", derived: null },
|
||||
{ source: "agent", identity: "qitem-remote", summary: "remote founder gate", leg: "human-routed", where: "human@kernel", ageIso: "2026-08-02T08:41:00.000Z", priority: "high", tier: "human-gate", evidenceRef: null, unblocks: null, qitemId: "qitem-remote", destinationSession: "human@kernel", derived: null, hostId: "mm2-host" },
|
||||
{ source: "derived", identity: "dev-impl@myrig|stuck|2026-08-02T08:43:00.000Z", summary: "impl looks stuck", leg: "stuck", where: "rig", ageIso: null, priority: null, tier: null, evidenceRef: null, unblocks: null, qitemId: null, destinationSession: null, derived: { kind: "stuck", evidence: "idle 47m >= 30m default · holds 2", threshold: "idle-with-work >= 30m" } },
|
||||
{ source: "derived", identity: "dev-qa@myrig|too-long-in-state|2026-08-02T05:00:00.000Z", summary: "qa has not transitioned in 180m", leg: "stuck", where: "rig", ageIso: null, priority: null, tier: null, evidenceRef: null, unblocks: null, qitemId: null, destinationSession: null, derived: { kind: "stuck", evidence: "no transition for 180m >= 120m default · holds 2", threshold: "too-long-in-state >= 120m" } },
|
||||
],
|
||||
@@ -97,6 +100,7 @@ const FIXTURES: Record<string, unknown> = {
|
||||
},
|
||||
agents: { scope: "rig", rows: [], provenance: "computed", coordinationHealth: null },
|
||||
settled: [], settledProvenance: "computed", composedAt: "2026-08-02T09:30:00.000Z",
|
||||
hosts: [{ hostId: "local", status: { hostId: "local", status: "ok" } }, { hostId: "mm2-host", status: { hostId: "mm2-host", status: "ok" } }],
|
||||
},
|
||||
"/api/stream/list?limit=5": [
|
||||
{ streamItemId: "si-1", tsEmitted: "2026-08-02T10:00:00.000Z", streamSortKey: "k1", sourceSession: "dev-guard@myrig", body: "gate cleared: slice-11", format: "text", hintType: null, hintUrgency: null, hintDestination: null, hintTags: null, interrupt: false, archivedAt: null },
|
||||
@@ -110,11 +114,12 @@ const FIXTURES: Record<string, unknown> = {
|
||||
},
|
||||
};
|
||||
|
||||
function fixtureClient(overrides: Record<string, { status: number } | undefined> = {}): DaemonClient {
|
||||
function fixtureClient(overrides: Record<string, { status: number } | undefined> = {}, responses: Record<string, unknown> = {}): DaemonClient {
|
||||
const fetchImpl = (async (url: unknown) => {
|
||||
const route = String(url).replace("http://x", "");
|
||||
const failure = overrides[route];
|
||||
if (failure) return { ok: false, status: failure.status, json: async () => ({}) } as Response;
|
||||
if (route in responses) return { ok: true, json: async () => responses[route] } as Response;
|
||||
if (!(route in FIXTURES)) throw new Error(`unexpected route in test: ${route}`);
|
||||
return { ok: true, json: async () => FIXTURES[route] } as Response;
|
||||
}) as typeof fetch;
|
||||
@@ -129,10 +134,10 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
|
||||
const dev = local?.rigs[0]?.pods.find((p) => p.name === "dev");
|
||||
expect(dev?.agents.map((a) => a.name)).toEqual(["dev.impl", "dev.qa"]);
|
||||
const impl = dev?.agents[0];
|
||||
expect(impl).toMatchObject({ runtime: "claude-code", spec: "implementer", context: 43, tokens: "129k", status: "running" });
|
||||
expect(impl).toMatchObject({ runtime: "claude-code", spec: "implementer", context: 43, tokens: "129k", status: "idle", canRun: false });
|
||||
// honest-unknown: availability "unknown" → null cells; lifecycleState verbatim
|
||||
const qa = dev?.agents[1];
|
||||
expect(qa).toMatchObject({ context: null, tokens: null, status: "detached" });
|
||||
expect(qa).toMatchObject({ context: null, tokens: null, status: "unknown", canRun: true });
|
||||
// infrastructure nodes are not agent rows
|
||||
expect(local?.rigs[0]?.pods.flatMap((p) => p.agents.map((a) => a.name))).not.toContain("svc.db");
|
||||
});
|
||||
@@ -207,7 +212,24 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
|
||||
it("maps the human-queue leg and marks it PROBED (proven-empty vs not-yet-known)", async () => {
|
||||
const snap = await hydrateSnapshot(fixtureClient());
|
||||
expect(snap.humanQueueProbed).toBe(true);
|
||||
expect(snap.humanQueue).toEqual([{ kind: "human-routed", target: "human@kernel", detail: "approve slice 11 proof" }]);
|
||||
expect(snap.humanQueue).toEqual([
|
||||
{ kind: "human-routed", target: "human@kernel", detail: "approve slice 11 proof" },
|
||||
{ kind: "human-routed", target: "human@kernel", detail: "remote founder gate" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks fleet attention incomplete when any remote host is absent, never proven-empty", async () => {
|
||||
const snap = await hydrateSnapshot(fixtureClient({}, {
|
||||
"/api/review/fleet": {
|
||||
needsYou: { items: [] },
|
||||
hosts: [
|
||||
{ hostId: "local", status: { hostId: "local", status: "ok" } },
|
||||
{ hostId: "mm2-host", status: { hostId: "mm2-host", status: "unreachable" } },
|
||||
],
|
||||
},
|
||||
}));
|
||||
expect(snap.humanQueueProbed).toBe(false);
|
||||
expect(snap.humanQueue).toEqual([]);
|
||||
});
|
||||
|
||||
it("joins Specs↔Topology over existing reads: rig agentRefs + agent usedByRigs", async () => {
|
||||
@@ -218,10 +240,10 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
|
||||
});
|
||||
|
||||
it("leaves a failed read honest-empty with a NAMED error; other sections still hydrate", async () => {
|
||||
const snap = await hydrateSnapshot(fixtureClient({ "/api/review/rig": { status: 503 } }));
|
||||
const snap = await hydrateSnapshot(fixtureClient({ "/api/review/fleet": { status: 503 } }));
|
||||
expect(snap.humanQueueProbed).toBe(false);
|
||||
expect(snap.needs).toEqual([]);
|
||||
expect(snap.readErrors).toEqual([expect.stringMatching(/review-rig: .*503/)]);
|
||||
expect(snap.readErrors).toEqual([expect.stringMatching(/review-fleet: .*503/)]);
|
||||
expect(snap.hosts.find((h) => h.name === "local")?.rigs[0]?.name).toBe("myrig");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createViewState } from "../src/state.js";
|
||||
import { computeExplorerRows, createViewState } from "../src/state.js";
|
||||
import { parseCommand } from "../src/grammar.js";
|
||||
import { decodeInput, sgrClick } from "../src/input.js";
|
||||
import { decodeInput, resolveKeyAction, sgrClick } from "../src/input.js";
|
||||
import { renderScreen } from "../src/render.js";
|
||||
import { demoSnapshot } from "../src/demo-data.js";
|
||||
import type { ViewState, ViewStateStore } from "../src/types.js";
|
||||
import type { FleetSnapshot, Screen, ViewState, ViewStateStore } from "../src/types.js";
|
||||
|
||||
// Phase-3 parity hardening: CONTENT-pane surfaces are hit targets too.
|
||||
// Hit-target realism: these tests click coordinates on the rendered SURFACE
|
||||
@@ -38,6 +38,25 @@ function findContentLine(store: ViewStateStore, match: RegExp): { y: number; tex
|
||||
return { y: idx + 1, text: screen.lines[idx]! };
|
||||
}
|
||||
|
||||
function syncedScreen(store: ViewStateStore, snapshot: FleetSnapshot = snap): Screen {
|
||||
let screen = renderScreen(store.get(), snapshot, { cols: 120, rows: 32 });
|
||||
store.dispatch({
|
||||
type: "layout",
|
||||
contentMaxOffset: screen.contentMaxOffset,
|
||||
contentTargetCount: screen.contentTargets.length,
|
||||
});
|
||||
screen = renderScreen(store.get(), snapshot, { cols: 120, rows: 32 });
|
||||
return screen;
|
||||
}
|
||||
|
||||
function press(store: ViewStateStore, bytes: string, snapshot: FleetSnapshot = snap): void {
|
||||
const screen = syncedScreen(store, snapshot);
|
||||
const event = decodeInput(bytes)[0];
|
||||
if (!event || event.type !== "key") throw new Error("expected key event");
|
||||
const action = resolveKeyAction(event, store.get(), screen, computeExplorerRows(store.get(), snapshot).length);
|
||||
if (action) store.dispatch(action);
|
||||
}
|
||||
|
||||
describe("content-pane parity (Phase 3): click the surface, not a control", () => {
|
||||
it("clicking a table row's STATUS cell opens the agent — identical to the command", () => {
|
||||
const byCommand = fresh("cmd");
|
||||
@@ -108,12 +127,58 @@ describe("content-pane parity (Phase 3): click the surface, not a control", () =
|
||||
it("PageDown scrolls content without moving the Explorer selection", () => {
|
||||
const s = fresh("t");
|
||||
const selected = s.get().selection;
|
||||
const screen = renderScreen(s.get(), snap, { cols: 120, rows: 8 });
|
||||
s.dispatch({ type: "layout", contentMaxOffset: screen.contentMaxOffset, contentTargetCount: screen.contentTargets.length });
|
||||
const pageDown = decodeInput("\x1b[6~")[0];
|
||||
if (!pageDown || pageDown.type !== "key" || !("action" in pageDown)) throw new Error("PageDown was not decoded");
|
||||
s.dispatch(pageDown.action);
|
||||
expect(s.get().contentOffset).toBe(10);
|
||||
expect(s.get().contentOffset).toBe(Math.min(10, screen.contentMaxOffset));
|
||||
expect(s.get().selection).toBe(selected);
|
||||
});
|
||||
|
||||
it("raw right/down/Enter reaches content tabs, table rows, spec refs, and Needs links", () => {
|
||||
const structured: FleetSnapshot = {
|
||||
...snap,
|
||||
specs: [{
|
||||
name: "openrig-build-rig",
|
||||
kind: "rig",
|
||||
format: "pod_aware",
|
||||
pods: [{ id: "dev50", members: [{ id: "guard", agentRef: "guard-agent", runtime: "codex" }], edges: [] }],
|
||||
graph: { nodes: [], edges: [] },
|
||||
raw: "name: openrig-build-rig",
|
||||
}, ...snap.specs.filter((spec) => spec.name !== "openrig-build-rig")],
|
||||
};
|
||||
|
||||
const tab = createViewState({ instanceId: "tab", getSnapshot: () => structured });
|
||||
tab.dispatch(parseCommand("spec openrig-build-rig"));
|
||||
press(tab, "\x1b[C", structured);
|
||||
press(tab, "\r", structured);
|
||||
expect(tab.get().viewTab).toBe("topology");
|
||||
|
||||
const row = fresh("row");
|
||||
row.dispatch(parseCommand("rig openrig-build"));
|
||||
press(row, "\x1b[C");
|
||||
let screen = syncedScreen(row);
|
||||
const rowTarget = screen.contentTargets.findIndex((target) => target.action.type === "drill" && target.action.resource === "agent" && target.action.name === "dev50.guard");
|
||||
for (let i = 0; i < rowTarget; i++) press(row, "\x1b[B");
|
||||
press(row, "\r");
|
||||
expect(row.get().drill.at(-1)).toEqual({ kind: "agent", name: "dev50.guard" });
|
||||
|
||||
const ref = createViewState({ instanceId: "ref", getSnapshot: () => structured });
|
||||
ref.dispatch(parseCommand("spec openrig-build-rig"));
|
||||
press(ref, "\x1b[C", structured);
|
||||
screen = syncedScreen(ref, structured);
|
||||
const refTarget = screen.contentTargets.findIndex((target) => target.action.type === "drill" && target.action.resource === "spec" && target.action.name === "guard-agent");
|
||||
for (let i = 0; i < refTarget; i++) press(ref, "\x1b[B", structured);
|
||||
press(ref, "\r", structured);
|
||||
expect(ref.get().drill.at(-1)).toEqual({ kind: "spec", name: "guard-agent" });
|
||||
|
||||
const needs = fresh("needs");
|
||||
needs.dispatch(parseCommand(":needs"));
|
||||
press(needs, "\x1b[C");
|
||||
press(needs, "\r");
|
||||
expect(needs.get().drill.at(-1)).toEqual({ kind: "agent", name: "dev50.guard" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig-stream footer (FR-10): ambient, toggleable, never a view", () => {
|
||||
|
||||
@@ -27,6 +27,19 @@ describe("live visual regressions", () => {
|
||||
expect(screen.lines.every((line) => line.length <= 80)).toBe(true);
|
||||
});
|
||||
|
||||
it("anchors ticker, rule, and status to the bottom of an exact short 140x34 view", () => {
|
||||
const base = demoSnapshot();
|
||||
const snap: FleetSnapshot = { ...base, needs: [], hostsDown: [], humanQueue: [] };
|
||||
const view = createViewState({ instanceId: "t", getSnapshot: () => snap });
|
||||
view.dispatch({ type: "jump", section: "needs" });
|
||||
|
||||
const screen = renderScreen(view.get(), snap, { cols: 140, rows: 34 });
|
||||
expect(screen.lines).toHaveLength(34);
|
||||
expect(screen.lines[31]).toContain("≋");
|
||||
expect(screen.lines[32]).toMatch(/^─+$/);
|
||||
expect(screen.lines[33]).toContain("[t] needs");
|
||||
});
|
||||
|
||||
it("scrolls the Explorer viewport to keep the keyboard selection visible", () => {
|
||||
const base = demoSnapshot();
|
||||
const snap: FleetSnapshot = {
|
||||
@@ -139,6 +152,7 @@ describe("live visual regressions", () => {
|
||||
const selected = view.get().selection;
|
||||
|
||||
const initial = renderScreen(view.get(), snap, { cols: 100, rows: 12 });
|
||||
view.dispatch({ type: "layout", contentMaxOffset: initial.contentMaxOffset, contentTargetCount: initial.contentTargets.length });
|
||||
expect(initial.lines.join("\n")).not.toContain("pod-17 (pod)");
|
||||
const scrollY = initial.lines.findIndex((line) => line.includes("content ↑/↓")) + 1;
|
||||
expect(initial.hitMap).toContainEqual(expect.objectContaining({
|
||||
|
||||
@@ -68,6 +68,15 @@ describe("control-socket adapter (spike-adopted; arch boundary constraint)", ()
|
||||
expect(view.get().drill).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a tab outside its content context and exposes the active view tab", async () => {
|
||||
const view = createViewState({ instanceId: "tui-sock", getSnapshot: () => snap });
|
||||
open = await createControlSocket({ socketPath: shortSockPath(), view });
|
||||
const [bad, good, state] = await ask(open.path, ["tab yaml", "spec openrig-build-rig", "state"]);
|
||||
expect(JSON.parse(bad!).error).toMatch(/not available/);
|
||||
expect(JSON.parse(good!).viewTab).toBe("configuration");
|
||||
expect(JSON.parse(state!).state.viewTab).toBe("configuration");
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createViewState, defaultSections } from "../src/state.js";
|
||||
import { agentsRunningSpec, createViewState, defaultSections } from "../src/state.js";
|
||||
import { parseCommand } from "../src/grammar.js";
|
||||
import { demoSnapshot } from "../src/demo-data.js";
|
||||
import type { NeedsItem, SectionDef } from "../src/types.js";
|
||||
|
||||
@@ -42,6 +43,24 @@ describe("one instance-scoped view-state (PIN 1, FR-12/13)", () => {
|
||||
expect(s.get().lastError).toMatch(/no such agent "nobody\.here"/);
|
||||
});
|
||||
|
||||
it("rejects ambiguous fleet shorthand and accepts an exact scoped agent target", () => {
|
||||
const duplicate = demoSnapshot();
|
||||
duplicate.hosts[0]!.rigs.push({
|
||||
name: "other-rig",
|
||||
pods: [{ name: "dev50", agents: [{ name: "dev50.qa", runtime: "codex", spec: "qa-agent", context: null, tokens: null, status: "idle", live: true }] }],
|
||||
});
|
||||
const s = createViewState({ instanceId: "t", getSnapshot: () => duplicate });
|
||||
expect(s.dispatch({ type: "drill", resource: "agent", name: "dev50.qa" }).lastError).toMatch(/ambiguous agent/);
|
||||
expect(s.dispatch({
|
||||
type: "drill",
|
||||
resource: "agent",
|
||||
name: "dev50.qa",
|
||||
target: { host: "vm-host", rig: "other-rig", pod: "dev50" },
|
||||
}).drill.map((part) => part.name)).toEqual(["vm-host", "other-rig", "dev50", "dev50.qa"]);
|
||||
expect(s.dispatch(parseCommand("agent vm-host/openrig-build/dev50/dev50.qa")).drill.map((part) => part.name))
|
||||
.toEqual(["vm-host", "openrig-build", "dev50", "dev50.qa"]);
|
||||
});
|
||||
|
||||
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" });
|
||||
@@ -56,6 +75,29 @@ describe("one instance-scoped view-state (PIN 1, FR-12/13)", () => {
|
||||
expect(s.get().runningOf).toBe("driver-agent");
|
||||
});
|
||||
|
||||
it("filters spec reverse navigation to genuinely live seats", () => {
|
||||
const copy = demoSnapshot();
|
||||
copy.hosts[0]!.rigs[0]!.pods[0]!.agents[0]!.live = false;
|
||||
expect(agentsRunningSpec(copy, "driver-agent")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a missing spec-of target in place with a named error", () => {
|
||||
const copy = demoSnapshot();
|
||||
copy.hosts[0]!.rigs[0]!.pods[0]!.agents[0]!.spec = "materialized-only";
|
||||
const s = createViewState({ instanceId: "t", getSnapshot: () => copy });
|
||||
const before = s.get().section;
|
||||
const next = s.dispatch({ type: "cross", kind: "spec-of", name: "dev50.driver" });
|
||||
expect(next.section).toBe(before);
|
||||
expect(next.lastError).toMatch(/spec .*not in the library/);
|
||||
});
|
||||
|
||||
it("rejects tabs that do not exist in the current content context", () => {
|
||||
const s = createViewState({ instanceId: "t", ...withSnap });
|
||||
expect(s.dispatch({ type: "tab", tab: "yaml" }).lastError).toMatch(/not available/);
|
||||
s.dispatch({ type: "drill", resource: "spec", name: "openrig-build-rig" });
|
||||
expect(s.dispatch({ type: "tab", tab: "yaml" }).lastError).toBeNull();
|
||||
});
|
||||
|
||||
it("filters the current view and clears with empty text", () => {
|
||||
const s = createViewState({ instanceId: "t", ...withSnap });
|
||||
s.dispatch({ type: "filter", text: "dev50" });
|
||||
@@ -64,6 +106,15 @@ describe("one instance-scoped view-state (PIN 1, FR-12/13)", () => {
|
||||
expect(s.get().filter).toBe("");
|
||||
});
|
||||
|
||||
it("bounds content scrolling in state so PageUp moves immediately from the bottom", () => {
|
||||
const s = createViewState({ instanceId: "t", ...withSnap });
|
||||
s.dispatch({ type: "layout", contentMaxOffset: 15, contentTargetCount: 0 });
|
||||
for (let i = 0; i < 20; i++) s.dispatch({ type: "content-scroll", delta: 10 });
|
||||
expect(s.get().contentOffset).toBe(15);
|
||||
s.dispatch({ type: "content-scroll", delta: -10 });
|
||||
expect(s.get().contentOffset).toBe(5);
|
||||
});
|
||||
|
||||
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" });
|
||||
|
||||
@@ -5,29 +5,34 @@ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
CLI_DIR="$REPO_ROOT/packages/cli"
|
||||
DAEMON_DIR="$REPO_ROOT/packages/daemon"
|
||||
UI_DIR="$REPO_ROOT/packages/ui"
|
||||
TUI_DIR="$REPO_ROOT/packages/tui"
|
||||
|
||||
echo "=== OpenRig Package Build ==="
|
||||
echo "Repo root: $REPO_ROOT"
|
||||
echo ""
|
||||
|
||||
# 1. Clean previous build artifacts
|
||||
echo "[1/5] Cleaning previous artifacts..."
|
||||
rm -rf "$CLI_DIR/daemon" "$CLI_DIR/ui"
|
||||
echo "[1/6] Cleaning previous artifacts..."
|
||||
rm -rf "$CLI_DIR/daemon" "$CLI_DIR/ui" "$CLI_DIR/tui"
|
||||
|
||||
# 2. Build daemon
|
||||
echo "[2/5] Building daemon..."
|
||||
echo "[2/6] Building daemon..."
|
||||
(cd "$DAEMON_DIR" && npm run build)
|
||||
|
||||
# 3. Build UI
|
||||
echo "[3/5] Building UI..."
|
||||
echo "[3/6] Building UI..."
|
||||
(cd "$UI_DIR" && npm run build)
|
||||
|
||||
# 4. Build CLI
|
||||
echo "[4/5] Building CLI..."
|
||||
# 4. Build TUI
|
||||
echo "[4/6] Building TUI..."
|
||||
(cd "$TUI_DIR" && npm run build)
|
||||
|
||||
# 5. Build CLI
|
||||
echo "[5/6] Building CLI..."
|
||||
(cd "$CLI_DIR" && npm run build)
|
||||
|
||||
# 5. Assemble: copy daemon + UI into CLI package
|
||||
echo "[5/5] Assembling package..."
|
||||
# 6. Assemble: copy daemon + UI + TUI into CLI package
|
||||
echo "[6/6] Assembling package..."
|
||||
|
||||
# OPR.0.4.4.11 FR-6 — deploy-identity stamp. Computed ONCE here (the runtime
|
||||
# worktree is at the SHA — available at build), then written as the compiled
|
||||
@@ -80,6 +85,10 @@ fi
|
||||
mkdir -p "$CLI_DIR/ui/dist"
|
||||
cp -r "$UI_DIR/dist/"* "$CLI_DIR/ui/dist/"
|
||||
|
||||
# TUI: dist (public openrig-tui bin declared by the CLI package)
|
||||
mkdir -p "$CLI_DIR/tui/dist"
|
||||
cp -r "$TUI_DIR/dist/"* "$CLI_DIR/tui/dist/"
|
||||
|
||||
# Report
|
||||
echo ""
|
||||
echo "=== Package Assembled ==="
|
||||
@@ -89,5 +98,6 @@ echo "Daemon assets: $(find "$CLI_DIR/daemon/assets" -type f 2>/dev/null | wc
|
||||
echo "Daemon specs: $(find "$CLI_DIR/daemon/specs" -type f 2>/dev/null | wc -l | tr -d ' ') files"
|
||||
echo "Daemon docs: $(find "$CLI_DIR/daemon/docs" -type f 2>/dev/null | wc -l | tr -d ' ') files"
|
||||
echo "UI dist: $(find "$CLI_DIR/ui/dist" -type f | wc -l | tr -d ' ') files"
|
||||
echo "TUI dist: $(find "$CLI_DIR/tui/dist" -type f | wc -l | tr -d ' ') files"
|
||||
echo ""
|
||||
echo "Ready to publish: cd packages/cli && npm publish --access public"
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const cli = path.join(root, "packages", "cli");
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-tui-pack-"));
|
||||
|
||||
try {
|
||||
execFileSync("npm", ["run", "build:package"], { cwd: root, stdio: "inherit" });
|
||||
const packed = JSON.parse(execFileSync("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", temp], { cwd: cli, encoding: "utf8" }));
|
||||
const tarball = path.join(temp, packed[0].filename);
|
||||
execFileSync("tar", ["-xzf", tarball, "-C", temp]);
|
||||
|
||||
const pkgDir = path.join(temp, "package");
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, "package.json"), "utf8"));
|
||||
assert.equal(pkg.bin["openrig-tui"], "tui/dist/main.js");
|
||||
const entry = path.join(pkgDir, pkg.bin["openrig-tui"]);
|
||||
assert.ok(fs.existsSync(entry), "packed TUI entrypoint is missing");
|
||||
assert.notEqual(fs.statSync(entry).mode & 0o111, 0, "packed TUI entrypoint is not executable");
|
||||
|
||||
fs.symlinkSync(path.join(root, "node_modules"), path.join(pkgDir, "node_modules"), "dir");
|
||||
const launched = spawnSync(process.execPath, [entry, "--demo", "--socket", path.join(temp, "tui.sock")], {
|
||||
cwd: pkgDir,
|
||||
input: "q",
|
||||
encoding: "utf8",
|
||||
timeout: 10_000,
|
||||
env: process.env,
|
||||
});
|
||||
assert.equal(launched.status, 0, launched.stderr || `packed TUI exited ${launched.status}`);
|
||||
console.log(`packed TUI launch PASS: ${packed[0].filename}`);
|
||||
} finally {
|
||||
fs.rmSync(temp, { recursive: true, force: true });
|
||||
}
|
||||
Reference in new issue
Block a user