Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
331 changes: 325 additions & 6 deletions admin/src/dashboard.ts

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion admin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -816,7 +816,8 @@ async function dispatch(pi: ExtensionAPI, args: string, ctx: any): Promise<void>
const paths = resolvePaths(process.env);
// Glyph posture BEFORE any rendering: every /dispatch surface (the overlay, the costs view's sparkline)
// draws through panel.mjs' active table, and this is the one funnel all subcommands pass through. The
// dashboard's own styler keeps its default for now -- its `ascii` opt-in lands when the view PR settles.
// dashboard's own styler opts in per instance (makeStyler's `ascii`, threaded from these same paths in
// makeDashboard), so PI_DISPATCH_ASCII now flips the overlay frame too, not only panel.mjs (issue #54).
setGlyphs(paths.asciiGlyphs);

// Drain the deployment pointer's retained one-line notice (a broken or newer pointer file) into the
Expand Down
6 changes: 4 additions & 2 deletions admin/src/style.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,10 @@ export const PLAIN_THEME = {
// switch (`setGlyphs`) must not restyle overlays behind a styler's back, so the overlay opts in per
// styler instance via `makeStyler(theme, { ascii })`. (The sparkline ramp is the one exception -- its
// quantization geometry lives only in panel.mjs, so `styler.sparkline` follows panel's active table.)
const OVERLAY_GLYPHS = { tl: "┌", tr: "┐", bl: "└", br: "┘", ml: "├", mr: "┤", h: "─", v: "│", full: "█", empty: "░", ellipsis: "…" };
const OVERLAY_ASCII = { tl: "+", tr: "+", bl: "+", br: "+", ml: "+", mr: "+", h: "-", v: "|", full: "#", empty: ".", ellipsis: "..." };
// The graph rows (issue #54) add four keys; every twin pair below is width-identical on purpose, so a
// renderer's padding math never depends on which table is active.
const OVERLAY_GLYPHS = { tl: "┌", tr: "┐", bl: "└", br: "┘", ml: "├", mr: "┤", h: "─", v: "│", full: "█", empty: "░", ellipsis: "…", arrowRight: "─▶", foldOpen: "▾", foldClosed: "▸", rearm: "↻" };
const OVERLAY_ASCII = { tl: "+", tr: "+", bl: "+", br: "+", ml: "+", mr: "+", h: "-", v: "|", full: "#", empty: ".", ellipsis: "...", arrowRight: "->", foldOpen: "v", foldClosed: ">", rearm: "~" };

/** Per-class colors for `fmtCost`: an estimate must LOOK provisional, and plan coverage must not look free. */
const COST_COLORS = {
Expand Down
212 changes: 210 additions & 2 deletions admin/test/dashboard.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ test("renders every section from the last snapshot, reusing the command renderer
assert.match(out, /5\/25/, "the day spend meter shows reserved/cap");
assert.match(out, /j1/, "last runs");
assert.match(out, /model claude-x/, "settings summary");
assert.match(out, /p pause/, "key hints");
assert.match(out, /p\/r pause/, "key hints (the merged pause\/resume pair, issue #54)");
assert.match(out, /q quit/, "key hints");
});

Expand Down Expand Up @@ -93,6 +93,25 @@ test("with a theme, the framed LIST is colored (ANSI present) but every line sti
assert.match(stripAnsi(lines.join("\n")), /PAUSED/, "plain content survives under the color");
});

test("paths.asciiGlyphs threads into the overlay styler: the frame degrades with the panel, together", async () => {
// PI_DISPATCH_ASCII used to flip panel.mjs' table but not the overlay's own frame glyphs -- the
// half-ASCII gap the old dashboard comment deferred. The opt-in is per styler instance on purpose
// (setGlyphs must not restyle overlays behind a styler's back), so the thread is paths -> makeStyler.
const comp = makeDashboard({ paths: { asciiGlyphs: true }, done() {}, tui: fakeTui(), intervalMs: 100000, deps: cannedDeps() });
await flush();
const ascii = comp.render(80);
await comp.dispose();
assert.ok(ascii[0].startsWith("+"), "ascii corners frame the overlay");
const joined = ascii.join("\n");
assert.ok(!joined.includes("┌") && !joined.includes("│") && !joined.includes("├"), "no frame box-drawing glyph leaks through the ascii overlay");

const comp2 = makeDashboard({ paths: {}, done() {}, tui: fakeTui(), intervalMs: 100000, deps: cannedDeps() });
await flush();
const box = comp2.render(80);
await comp2.dispose();
assert.ok(box[0].startsWith("┌"), "no opt-in keeps the box-drawing default, byte-identically");
});

test("before the first fetch resolves it renders a loading panel, not a crash", () => {
// A fetch that never resolves: the panel must still render (from the null snapshot) synchronously.
const comp = makeDashboard({
Expand Down Expand Up @@ -431,7 +450,7 @@ test("Enter on a run opens its detail dump, and Esc backs out to the list withou
await flush();
const back = comp.render(80).join("\n");
await comp.dispose();
assert.match(back, /p pause/, "Esc returns to the interactive list");
assert.match(back, /p\/r pause/, "Esc returns to the interactive list");
assert.equal(closed, 0, "Esc from a sub-view never closes the overlay");
});

Expand Down Expand Up @@ -1760,3 +1779,192 @@ test("the viewport, markers and badges keep every colored line at exactly the fr
await comp.dispose();
for (const l of lines) assert.equal(visibleLen(l), 80, `every line is exactly 80 visible cols: ${JSON.stringify(l)}`);
});

// ── GRAPH view (issue #54): the COSTS suite's skeleton over the topology ───────────────────────────────

import { buildGraphModel } from "../src/graph-model.mjs";

const GRAPH_SNAPSHOT_TRIGGERS = [
{ type: "cron", index: 0, id: "nightly", pattern: "0 3 * * *", folder: "/srv/site", flow: "build-report", model: null, packages: true, image: null, skillsDir: null, instructions: false, resume: false },
{ type: "label", index: 1, any: ["ai"], all: [], none: [], flow: "triage", packages: true, image: null, skillsDir: null, instructions: false, resume: false, replicas: null, forge: "github" },
];

const CANNED_GRAPH_MODEL = () =>
buildGraphModel({
triggers: { count: 2, triggers: GRAPH_SNAPSHOT_TRIGGERS },
schedulers: [],
folderSkills: {
"/srv/site": {
head: "abc1234def",
truncated: false,
unreachable: null,
skills: [
{ name: "build-report", isSub: false, group: null, aiTrigger: true, meta: null, mentions: [{ name: "notify", strong: true }], unread: false },
{ name: "notify", isSub: false, group: null, aiTrigger: false, meta: null, mentions: [], unread: false },
{ name: "old-import", isSub: false, group: null, aiTrigger: false, meta: null, mentions: [], unread: false },
],
},
},
injectedSkills: {},
cronStats: { byId: { nightly: { runs: 41, lastOutcome: "completed", lastEndedAt: "2026-08-11T00:00:00.000Z" } } },
runJoin: { byIndex: { 1: { runs: 12, lastOutcome: "failed", lastEndedAt: "2026-08-11T01:00:00.000Z" } }, unattributed: 2 },
chainEdges: { edges: [{ parentFlow: "build-report", childFlow: "notify", target: "local:site", count: 3, lastEndedAt: null }], refusals: {}, truncated: false },
caps: { chainDepthMax: 1, chainMaxPerJob: 2, windowDays: 30 },
nowMs: 1770000000000,
});

function graphDeps(over = {}) {
return cannedDeps({
fetchSnapshot: async () => ({ ...SNAPSHOT, triggers: { count: 2, triggers: GRAPH_SNAPSHOT_TRIGGERS } }),
fetchGraph: async () => CANNED_GRAPH_MODEL(),
...over,
});
}

async function openGraph(deps, width = 80) {
const comp = makeDashboard({ paths: {}, done() {}, tui: fakeTui(), intervalMs: 100000, deps });
await flush();
comp.handleInput("g");
await flush();
return comp;
}

test("'g' opens GRAPH, the footer advertises it unclipped at 80, and Esc pops one layer", async () => {
const deps = graphDeps();
const comp = makeDashboard({ paths: {}, done() {}, tui: fakeTui(), intervalMs: 100000, deps });
await flush();

const footer = comp.render(80).map(stripAnsi).find((l) => l.includes("q quit"));
assert.ok(footer, "the LIST footer renders");
assert.match(footer, /g graph/, "the graph key is advertised");
assert.ok(!footer.includes("…"), "the width-80 footer fits with no ellipsis glyph");

comp.handleInput("g");
await flush();
const out = stripAnsi(comp.render(80).join("\n"));
assert.match(out, /GRAPH · triggers and flows/, "the frame titles the view");

comp.handleInput("\x1b");
await flush();
const back = stripAnsi(comp.render(80).join("\n"));
await comp.dispose();
assert.match(back, /RUNS /, "Esc returns to the LIST frame");
});

test("the canned model renders: folder heads, trigger stats, skill badges, evidence-labelled edges, caps always", async () => {
const comp = await openGraph(graphDeps());
const out = stripAnsi(comp.render(80).join("\n"));
await comp.dispose();

assert.match(out, /folder \/srv\/site · HEAD abc1234/, "the folder header carries the short HEAD");
assert.match(out, /cron\s+nightly 0 3 \* \* \*/, "the cron trigger row");
assert.match(out, /runs 41 · last completed/, "cron stats joined by id");
assert.match(out, /runs 12 · last failed/, "forge stats joined by the persisted index");
assert.match(out, /forge github/, "the forge group renders");
assert.match(out, /skills unverifiable/, "and says why its flows are unverified");
assert.match(out, /observed x3/, "an observed edge carries its count");
assert.match(out, /mention \(potential, strong; can never fire\)/, "a potential edge says whether it could ever fire");
assert.match(out, /\[orphan\]/, "the orphan badge renders");
assert.match(out, /2 runs unattributed/, "the honesty counters render");
assert.match(out, /caps: chain depth <= 1 · <= 2 per job · same folder only · window 30d/, "the caps line renders");

for (const line of stripAnsi(comp.render(80).join("\n")).split("\n").filter((l) => l.includes("mention (potential"))) {
assert.ok(!/observed/.test(line), "a potential row never borrows observed's vocabulary");
}
});

test("the caps line renders even on an empty model -- there is no capless graph", async () => {
const comp = await openGraph(graphDeps({ fetchGraph: async () => buildGraphModel({ caps: { chainDepthMax: 1, chainMaxPerJob: 2, windowDays: 30 } }) }));
const out = stripAnsi(comp.render(80).join("\n"));
await comp.dispose();
assert.match(out, /no triggers configured/);
assert.match(out, /caps: chain depth <= 1/, "caps render on the empty model too (DES-GRAPH-EDGE-DERIVATION)");
});

test("the graph fetches on entry and on r, and the poll tick NEVER re-fetches", async () => {
let fetches = 0;
const deps = graphDeps({
fetchGraph: async () => {
fetches++;
return CANNED_GRAPH_MODEL();
},
});
// A REAL 10ms poll interval: if the tick piggybacked a graph fetch (the costs pattern), fetches
// would climb while we wait. The graph is stricter than costs on purpose -- git spawns per folder.
const comp = makeDashboard({ paths: {}, done() {}, tui: fakeTui(), intervalMs: 10, deps });
await flush();
comp.handleInput("g");
await flush();
assert.equal(fetches, 1, "one fetch on entry");
await new Promise((r) => setTimeout(r, 80));
assert.equal(fetches, 1, "the poll tick must not re-enumerate folders");
comp.handleInput("r");
await flush();
await comp.dispose();
assert.equal(fetches, 2, "r is the one manual refresh");
});

test("Enter folds a folder group and unfolds it; Enter on a trigger row opens TRIGGER_DETAIL", async () => {
const comp = await openGraph(graphDeps());
const before = stripAnsi(comp.render(80).join("\n"));
assert.match(before, /skill build-report/, "the folder's rows are visible");

comp.handleInput("\r"); // cursor starts on the first folder header
await flush();
const folded = stripAnsi(comp.render(80).join("\n"));
assert.doesNotMatch(folded, /skill build-report/, "folding hides the group's rows");
assert.match(folded, /folder \/srv\/site/, "the header itself stays");

comp.handleInput("\r");
await flush();
assert.match(stripAnsi(comp.render(80).join("\n")), /skill build-report/, "unfolding restores them");

comp.handleInput("\x1b[B"); // down: onto the cron trigger row
comp.handleInput("\r");
await flush();
const detail = stripAnsi(comp.render(80).join("\n"));
await comp.dispose();
assert.match(detail, /trigger · cron/, "the graph reuses the existing trigger drill");
});

test("a throwing fetchGraph degrades to an in-frame message, never a crash", async () => {
const comp = await openGraph(graphDeps({ fetchGraph: async () => { throw new Error("enumeration blew up"); } }));
const out = stripAnsi(comp.render(80).join("\n"));
await comp.dispose();
assert.match(out, /graph unreachable \(enumeration blew up\)/);
});

test("GRAPH under a real-SGR theme keeps every line at exactly 80 visible columns", async () => {
const theme = { fg: (_c, t) => `\x1b[38;5;42m${t}\x1b[39m`, bold: (t) => `\x1b[1m${t}\x1b[22m`, bg: (_c, t) => t };
const comp = makeDashboard({ paths: {}, done() {}, tui: fakeTui(), intervalMs: 100000, theme, deps: graphDeps() });
await flush();
comp.handleInput("g");
await flush();
const lines = comp.render(80);
await comp.dispose();
assert.ok(lines.some((l) => l.includes("\x1b[")), "the view is colored");
for (const l of lines) {
assert.equal(visibleLen(l), 80, `every framed line is exactly 80 visible cols: ${JSON.stringify(stripAnsi(l))}`);
}
});

test("GRAPH degrades to plain unframed lines at a tiny width, reusing the /dispatch graph renderer whole", async () => {
const comp = await openGraph(graphDeps());
const lines = comp.render(4).map(stripAnsi);
await comp.dispose();
const joined = lines.join("\n");
assert.match(joined, /Graph: triggers and flows/, "the unframed body IS renderGraph's output");
assert.match(joined, /caps: chain depth <= 1/, "caps included, uncollapsed");
assert.ok(!joined.includes("┌"), "no frame at a width the frame cannot hold");
});

test("GRAPH in ascii mode leaks no box-drawing or graph glyphs", async () => {
const comp = makeDashboard({ paths: { asciiGlyphs: true }, done() {}, tui: fakeTui(), intervalMs: 100000, deps: graphDeps() });
await flush();
comp.handleInput("g");
await flush();
const joined = comp.render(80).join("\n");
await comp.dispose();
assert.ok(!/[┌┐└┘├┤│─▾▸↻▶]/.test(joined), "the ascii graph is pure ASCII, frame and rows alike");
assert.match(stripAnsi(joined), /-> build-report/, "the ascii arrow twin renders");
});
14 changes: 14 additions & 0 deletions admin/test/style.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,20 @@ test("makeStyler ascii option swaps frame/meter/divider glyphs with identical ge
assert.equal(s.cell("abcdefgh", 5), "ab...", "the 3-char ellipsis still lands on exactly width");
});

test("the graph glyphs (issue #54) keep key parity and identical widths across both tables", () => {
// Width-identical twins are the contract: a graph row's padding math must not depend on which
// table is active, or the 80-col invariant breaks only for ASCII terminals -- the least debuggable
// place for it to break.
const box = makeStyler(PLAIN_THEME).glyphs;
const ascii = makeStyler(PLAIN_THEME, { ascii: true }).glyphs;
assert.deepEqual(Object.keys(ascii).sort(), Object.keys(box).sort(), "key parity between the twin tables");
for (const key of ["arrowRight", "foldOpen", "foldClosed", "rearm"]) {
assert.ok(typeof box[key] === "string" && box[key].length > 0, key);
assert.equal(ascii[key].length, box[key].length, `${key}: twin widths must match`);
}
assert.doesNotMatch(Object.values(ascii).join(""), /[─│┌┐├┤└┘▾▸↻▶]/, "no non-ASCII glyph leaks into the ascii table");
});

test("styler.sparkline under PLAIN_THEME is byte-identical to the plain sparkline", () => {
const s = makeStyler(PLAIN_THEME);
const values = [0, 1, null, 4, 2];
Expand Down
9 changes: 9 additions & 0 deletions docs/graph.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ serviced repo's `.pi/skills/` tree holds what exists and what opts into being ch
surface assembles that topology in one place (`REQ-TOPOLOGY-GRAPH`, `DES-GRAPH-EDGE-DERIVATION`). It
informs; it changes nothing — no port, no database, no new dependency.

## The GRAPH view

Press `g` on the dashboard (`/dispatch`). The view is a folder-grouped tree: each group header folds
with `↵`, trigger rows carry their joined run stats on the right, skill rows carry their badges, and
a skill's outgoing edges render as indented annotation rows labelled by evidence class. `↵` on a
trigger row opens the same trigger detail the LIST offers. `r` refreshes the model — the only
re-read besides entry, because assembling the graph enumerates each folder's committed skills with
git, and topology changes when you edit things, not per second. `Esc` returns to the LIST.

## `/dispatch graph`

```
Expand Down
Loading
Loading