Skip to content

Commit d348d32

Browse files
committed
feat(admin): /dispatch graph, the topology as text (issue #54)
The graph's first consumer: renderGraph (pure, render.mjs) formats the assembled model as folder groups, triggers with run stats and dangling badges, skills with reachability badges, and edges labelled by their evidence class, then the counters and the caps line, which renders ALWAYS: edges without their bounds invite the reader to extrapolate an unbounded chain fabric out of a depth-1, width-2 reality. assembleGraph (index.ts, the assembleCosts twin) reads triggers FRESH every call (OQ-008: a cached topology is a stale topology), the resident schedulers, one bounded record scan, and the enumerations through collectGraphInputs. Deliberately NOT an LLM-callable tool: the enumeration spawns git per folder, and the topology is for the operator's eyes (DES-CLI-SURFACE ungated tier; tool-count pin stands). readTriggers now also returns the RAW entries count, because the attribution range guard must accept an index pointing at an unusable-but-present row. New wiring pin: USAGE and KNOWN_SUBCOMMANDS agree member-for-member (two copies of one list that nothing pinned together until this amendment widened both). Specs: NEW REQ-TOPOLOGY-GRAPH (honesty rules as requirements, the REQ-COST-ANALYTICS discipline applied to topology). REQ-ADMIN-VIA-PI-EXTENSION and DES-ADMIN-VIA-PI-EXTENSION AMENDED (command list). REQ-COST-ANALYTICS UNCHANGED, checked. Docs: docs/graph.md, README and workflows.md cross-links. Suite in the CI posture: 2120 pass, 0 skipped; admin bundle builds. Signed-off-by: Rob Boerman <robboerman@live.nl>
1 parent ebbbfb2 commit d348d32

11 files changed

Lines changed: 421 additions & 7 deletions

File tree

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,10 @@ Steer the running worker without stopping it, from any terminal:
380380
## The admin panel
381381

382382
The dashboard shown at the top of this README is a **pi extension**: it loads into your own interactive
383-
pi session. No daemon, no web app, no network port.
383+
pi session. No daemon, no web app, no network port. Beside the dashboard, `/dispatch graph` renders the
384+
whole trigger and flow topology as text: what triggers what, what chained to what in the recorded runs,
385+
what a skill's own text says it might chain to, plus orphan skills and dangling triggers
386+
([`docs/graph.md`](docs/graph.md)).
384387

385388
```bash
386389
pi install npm:@edgehero/pi-dispatch-admin # then, in pi: /dispatch

admin/src/index.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,13 @@ import {
7575
scanRunRecords,
7676
readSubscriptions,
7777
KNOWN_KEYS,
78+
GRAPH_LIMITS,
79+
cronRunStats,
80+
joinRunsToTriggers,
81+
observedChainEdges,
82+
collectGraphInputs,
7883
} from "./read-model.mjs";
84+
import { buildGraphModel } from "./graph-model.mjs";
7985
// The deployment pointer (INT-DEPLOYMENT-POINTER-CONTRACT): the wizard-written file that aims this
8086
// extension at a deployment built in another directory. Layered into process.env once at factory load
8187
// (the operator's env always wins), so resolvePaths stays env-only by contract while every one of its
@@ -96,7 +102,7 @@ import { getPricedModel, isZeroRated, listPricedModels, piAiVersion, reprice } f
96102
import { setGlyphs } from "./panel.mjs";
97103
import { buildSandboxRunArgs, launchSandbox as spawnSandbox, resolveSandbox, sandboxContainerName } from "@edgehero/pi-dispatch/sandbox";
98104
import { readManifest } from "@edgehero/pi-dispatch/sandbox-store";
99-
import { renderStatus, renderRuns, renderBudget, renderTriggers, renderSettingsView, renderCosts, renderWhatIf } from "./render.mjs";
105+
import { renderStatus, renderRuns, renderBudget, renderTriggers, renderSettingsView, renderCosts, renderWhatIf, renderGraph } from "./render.mjs";
100106
import { makeDashboard, createDashboardDeps } from "./dashboard.ts";
101107
// Only the nudge is loaded eagerly (it must register its session_start handler at factory time); the
102108
// wizard itself stays behind the dispatch handler's lazy import. The setup-wizard module imports
@@ -152,7 +158,7 @@ const REBUILT_NOTICE = (reason: string) =>
152158
`replaced invalid settings file (${reason}) — other keys were lost`;
153159

154160
const USAGE =
155-
"usage: /dispatch <status|pause|resume|run|runs|logs|budget|costs|triggers|settings|set|unset|setup>";
161+
"usage: /dispatch <status|pause|resume|run|runs|logs|budget|costs|graph|triggers|settings|set|unset|setup>";
156162

157163
const KNOWN_SUBCOMMANDS = [
158164
"status",
@@ -163,6 +169,7 @@ const KNOWN_SUBCOMMANDS = [
163169
"logs",
164170
"budget",
165171
"costs",
172+
"graph",
166173
"triggers",
167174
"settings",
168175
"set",
@@ -913,6 +920,14 @@ async function dispatch(pi: ExtensionAPI, args: string, ctx: any): Promise<void>
913920
costsCommand(pi, paths, tokens, notify);
914921
return;
915922
}
923+
case "graph": {
924+
// Operator-typed read, ungated (DES-CLI-SURFACE: typing it is the approval); renders the same
925+
// model the GRAPH view draws, as plain text into the admin channel. Deliberately NOT an
926+
// LLM-callable tool: the folder enumeration spawns git per folder, and the text is a topology
927+
// the operator reads, not a fold a model consumes.
928+
send(pi, renderGraph(await assembleGraph(paths)));
929+
return;
930+
}
916931
case "run": {
917932
const folder = tokens[1];
918933
const flow = tokens[2];
@@ -1019,6 +1034,34 @@ function assembleCosts(paths: any, window: string, flow?: string): any {
10191034
return { fold };
10201035
}
10211036

1037+
// ---- the graph command surface (issue #54) ----
1038+
1039+
/**
1040+
* Assemble the trigger/flow graph model exactly as the dashboard's seam will: a FRESH triggers read
1041+
* every call (the file live-reloads, OQ-008 -- a cached topology is a stale topology), the resident
1042+
* schedulers, one bounded record scan for the window, the folder/injected enumerations through
1043+
* `collectGraphInputs`, and the pure fold. All I/O lives in the read-model; this function only
1044+
* aggregates its outputs into buildGraphModel's input shape.
1045+
*/
1046+
async function assembleGraph(paths: any): Promise<any> {
1047+
const nowMs = Date.now();
1048+
const triggers: any = readTriggers({ triggersPath: paths.triggersPath });
1049+
const triggerList: any[] = Array.isArray(triggers?.triggers) ? triggers.triggers : [];
1050+
const schedulers: any = await readSchedulers({ url: paths.valkeyUrl });
1051+
const records: any = scanRunRecords({ logsDir: paths.logsDir, sinceMs: nowMs - GRAPH_LIMITS.windowDays * 24 * 60 * 60 * 1000, nowMs });
1052+
const recs: any[] = Array.isArray(records) ? records : [];
1053+
return buildGraphModel({
1054+
triggers,
1055+
schedulers: Array.isArray(schedulers) ? schedulers : [],
1056+
...collectGraphInputs({ triggers: triggerList }),
1057+
cronStats: cronRunStats({ records: recs, schedulerIds: triggerList.filter((t) => t.type === "cron" && typeof t.id === "string").map((t) => t.id) }),
1058+
runJoin: joinRunsToTriggers({ records: recs, triggerCount: triggers?.count }),
1059+
chainEdges: observedChainEdges({ records: recs }),
1060+
caps: { chainDepthMax: paths.chainDepthMax, chainMaxPerJob: paths.chainMaxPerJob, windowDays: GRAPH_LIMITS.windowDays },
1061+
nowMs,
1062+
});
1063+
}
1064+
10221065
/**
10231066
* `/dispatch costs [7d|30d|mtd]` renders the fold; `costs whatif …` estimates one flow at another model's
10241067
* rates. Both send PII-free text into the admin channel -- the same records `runs` already sends,

admin/src/read-model.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -671,7 +671,10 @@ export function readTriggers({ triggersPath, fs = nodeFs }) {
671671
// above row i would otherwise shift every attribution below it onto the wrong trigger.
672672
if (display) triggers.push({ ...display, index });
673673
});
674-
return { triggers };
674+
// `count` is the RAW entries length, for the same reason `index` is raw: the attribution range
675+
// guard must accept an index that points at an unusable-but-present row (triggers.length would
676+
// reject it and miscount the run as stale).
677+
return { triggers, count: entries.length };
675678
}
676679

677680
/**

admin/src/render.mjs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,3 +377,115 @@ export function renderSettingsView(settings) {
377377
}
378378
return out.join("\n");
379379
}
380+
381+
/**
382+
* Render the trigger/flow graph model as plain text -- the `/dispatch graph` command body and the
383+
* dashboard's unframed degrade twin. Pure over the model (built by graph-model.mjs buildGraphModel);
384+
* every honesty rule lives THERE, so this function only formats: folder groups, their triggers with
385+
* run stats and dangling badges, their skills with reachability badges and outgoing edges labelled by
386+
* evidence class, then the counters and the caps line -- which renders ALWAYS, because edges without
387+
* their bounds invite the reader to extrapolate an unbounded chain fabric (DES-GRAPH-EDGE-DERIVATION).
388+
*/
389+
export function renderGraph(model) {
390+
const m = model ?? {};
391+
if (m.meta?.triggersMissing) return "Graph: no triggers file found";
392+
if (typeof m.meta?.triggersInvalid === "string" && m.meta.triggersInvalid !== "") return `Graph: triggers file invalid: ${m.meta.triggersInvalid}`;
393+
const nodes = Array.isArray(m.nodes) ? m.nodes : [];
394+
const nodeById = new Map(nodes.map((n) => [n.id, n]));
395+
const edges = Array.isArray(m.edges) ? m.edges : [];
396+
const flagsByNode = new Map();
397+
for (const f of Array.isArray(m.flags) ? m.flags : []) {
398+
if (!flagsByNode.has(f.nodeId)) flagsByNode.set(f.nodeId, []);
399+
flagsByNode.get(f.nodeId).push(f.flag);
400+
}
401+
402+
const out = ["Graph: triggers and flows"];
403+
const triggerCount = nodes.filter((n) => n.kind === "trigger").length;
404+
if (triggerCount === 0) out.push("", "no triggers configured");
405+
406+
for (const group of Array.isArray(m.folders) ? m.folders : []) {
407+
if ((group.triggerIds?.length ?? 0) === 0 && (group.skillIds?.length ?? 0) === 0) continue;
408+
out.push("", groupHeading(group));
409+
for (const id of group.triggerIds ?? []) {
410+
const t = nodeById.get(id);
411+
if (t) out.push(` ${graphTriggerLine(t, flagsByNode.get(id) ?? [])}`);
412+
}
413+
for (const id of group.skillIds ?? []) {
414+
const s = nodeById.get(id);
415+
if (!s || s.kind === "skill-unverified") continue; // an unverified flow is already on its trigger line
416+
out.push(` ${graphSkillLine(s, flagsByNode.get(id) ?? [])}`);
417+
for (const e of edges) {
418+
if (e.from !== id || e.kind === "cron-rearm") continue;
419+
const target = nodeById.get(e.to);
420+
out.push(` ${graphEdgeLine(e, target)}`);
421+
}
422+
}
423+
}
424+
425+
const injected = nodes.filter((n) => n.kind === "injected");
426+
if (injected.length > 0) {
427+
out.push("", "injected skills (trigger-reachable, never AI-reachable):");
428+
for (const n of injected) {
429+
const marks = (flagsByNode.get(n.id) ?? []).includes("injected-ai-trigger") ? " [ai-trigger: allow is a silent no-op here]" : "";
430+
out.push(` ${n.name} (${n.dir})${marks}`);
431+
}
432+
}
433+
434+
out.push("");
435+
if ((m.meta?.unattributedRuns ?? 0) > 0) out.push(`${m.meta.unattributedRuns} runs unattributed (triggers file changed since they ran, or they predate attribution)`);
436+
const refusals = Object.entries(m.meta?.chainRefusals ?? {});
437+
if (refusals.length > 0) out.push(`chain refusals: ${refusals.map(([flow, n]) => `${flow} ${n}`).join(", ")}`);
438+
const trunc = m.meta?.truncated ?? {};
439+
if (trunc.folders) out.push("folder scan truncated (cap reached): unlisted folders are not empty, they are unscanned");
440+
if (trunc.skills) out.push("skill enumeration truncated or partly unread");
441+
if (trunc.edges) out.push("observed edges truncated (cap reached)");
442+
if ((m.meta?.droppedObservedEdges ?? 0) > 0) out.push(`${m.meta.droppedObservedEdges} observed edges dropped (no unique folder to attach them to)`);
443+
out.push("edges: -> configured, observed xN (from records), mention (potential; a mention is not a promise)");
444+
out.push(capsLine(m.caps));
445+
return out.join("\n");
446+
}
447+
448+
function groupHeading(group) {
449+
if (group.kind === "forge") return `forge ${group.label} (skills unverifiable from the admin host)`;
450+
const head = group.head ? `, HEAD ${String(group.head).slice(0, 7)}` : "";
451+
const state = group.unreachable ? `, ${group.unreachable}` : "";
452+
return `folder ${group.path ?? group.label}${head}${state}`;
453+
}
454+
455+
function graphTriggerLine(t, flags) {
456+
const stats = t.runs > 0 ? `runs ${t.runs}${t.lastOutcome ? `, last ${t.lastOutcome}` : ""}` : "no runs in window";
457+
const badges = [];
458+
if (flags.includes("no-skill")) badges.push("[no-skill: flow absent at HEAD]");
459+
if (flags.includes("charset-invalid")) badges.push("[invalid flow name: can never materialise]");
460+
if (flags.includes("pr-spend-loop-risk")) badges.push("[spend-loop risk: fires on opened/synchronize]");
461+
const replicas = t.replicas ? ` x${t.replicas}` : "";
462+
return `${t.onType} ${t.label} -> ${t.flow ?? "(no flow)"}${replicas} (${stats})${badges.length ? ` ${badges.join(" ")}` : ""}`;
463+
}
464+
465+
function graphSkillLine(s, flags) {
466+
const badges = [];
467+
if (s.kind === "skill-missing") badges.push("[missing at HEAD]");
468+
if (s.isSub) badges.push(`[sub-skill of ${s.group}: loadable, never a flow]`);
469+
if (s.aiTrigger) badges.push("[chainable]");
470+
if (flags.includes("orphan")) badges.push("[orphan: no trigger, no ai-trigger, no mention]");
471+
if (flags.includes("ai-reachable-no-trigger")) badges.push("[AI-reachable, no trigger]");
472+
if (flags.includes("unread")) badges.push("[SKILL.md unread: facts unknown]");
473+
return `skill ${s.name}${badges.length ? ` ${badges.join(" ")}` : ""}`;
474+
}
475+
476+
function graphEdgeLine(e, target) {
477+
const name = target?.name ?? "(unknown)";
478+
if (e.kind === "observed") return `-> ${name} observed x${e.count}`;
479+
if (e.kind === "potential") {
480+
const fire = e.eligible ? "could fire" : "can never fire: target has no ai-trigger allow";
481+
return `-> ${name} mention (potential${e.strong ? ", strong" : ""}; ${fire})`;
482+
}
483+
return `-> ${name} ${e.kind}`;
484+
}
485+
486+
function capsLine(caps) {
487+
const depth = caps?.chainDepthMax ?? "?";
488+
const width = caps?.chainMaxPerJob ?? "?";
489+
const window = caps?.windowDays ?? "?";
490+
return `caps: chain depth <= ${depth}, <= ${width} per job, same folder only, window ${window}d`;
491+
}

admin/test/read-model.test.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1212,6 +1212,7 @@ test("readTriggers attaches the RAW array index, unshifted by dropped entries",
12121212
[["label", 0], ["comment", 2]],
12131213
"the dropped row 1 must leave a hole, not renumber row 2",
12141214
);
1215+
assert.equal(out.count, 3, "count is the RAW length: the range guard must accept an index at an unusable-but-present row");
12151216
});
12161217

12171218
// A minimal run record for the join tests; every field a real record carries that these folds read.

admin/test/render.test.mjs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,3 +413,84 @@ test("renderTriggers marks a replicating trigger, and an unflagged line is byte-
413413
});
414414
assert.equal(all.split("[x").length - 1, 3, "label, comment and pull_request each show their count");
415415
});
416+
417+
// ---- renderGraph (issue #54): the /dispatch graph text body and the dashboard's unframed twin ----
418+
419+
import { renderGraph } from "../src/render.mjs";
420+
import { buildGraphModel } from "../src/graph-model.mjs";
421+
422+
// Hand-built through the REAL assembler so the render tests cannot drift from the model contract;
423+
// every number below is hand-computable from these literals.
424+
const GRAPH_MODEL = () =>
425+
buildGraphModel({
426+
triggers: {
427+
count: 2,
428+
triggers: [
429+
{ 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 },
430+
{ type: "label", index: 1, any: ["ai"], all: [], none: [], flow: "triage", packages: true, image: null, skillsDir: null, instructions: false, resume: false, replicas: null, forge: "github" },
431+
],
432+
},
433+
schedulers: [],
434+
folderSkills: {
435+
"/srv/site": {
436+
head: "abc1234def",
437+
truncated: false,
438+
unreachable: null,
439+
skills: [
440+
{ name: "build-report", isSub: false, group: null, aiTrigger: true, meta: null, mentions: [{ name: "notify", strong: true }], unread: false },
441+
{ name: "notify", isSub: false, group: null, aiTrigger: false, meta: null, mentions: [], unread: false },
442+
{ name: "old-import", isSub: false, group: null, aiTrigger: false, meta: null, mentions: [], unread: false },
443+
],
444+
},
445+
},
446+
injectedSkills: {},
447+
cronStats: { byId: { nightly: { runs: 41, lastOutcome: "completed", lastEndedAt: "2026-08-11T00:00:00.000Z" } } },
448+
runJoin: { byIndex: { 1: { runs: 12, lastOutcome: "failed", lastEndedAt: "2026-08-11T01:00:00.000Z" } }, unattributed: 2 },
449+
chainEdges: { edges: [{ parentFlow: "build-report", childFlow: "notify", target: "local:site", count: 3, lastEndedAt: null }], refusals: {}, truncated: false },
450+
caps: { chainDepthMax: 1, chainMaxPerJob: 2, windowDays: 30 },
451+
nowMs: 1770000000000,
452+
});
453+
454+
test("renderGraph renders folders, triggers with stats, skills with badges, and evidence-labelled edges", () => {
455+
const out = renderGraph(GRAPH_MODEL());
456+
assert.match(out, /^Graph: triggers and flows/, "header anchored at the start");
457+
assert.match(out, /folder \/srv\/site, HEAD abc1234\b/, "the folder heading carries the short HEAD");
458+
assert.match(out, /cron nightly 0 3 \* \* \* -> build-report {2}\(runs 41, last completed\)/);
459+
assert.match(out, /forge github \(skills unverifiable from the admin host\)/);
460+
assert.match(out, /label any\[ai\] -> triage {2}\(runs 12, last failed\)/, "forge runs join via the persisted index");
461+
assert.match(out, /-> notify {2}observed x3/, "an observed edge carries its count");
462+
assert.match(out, /-> notify {2}mention \(potential, strong; can never fire: target has no ai-trigger allow\)/, "a mention of a non-eligible target says it can never fire");
463+
assert.match(out, /skill old-import {2}\[orphan/, "the orphan badge renders");
464+
assert.match(out, /2 runs unattributed/);
465+
assert.match(out, /caps: chain depth <= 1, <= 2 per job, same folder only, window 30d/, "the caps line always renders");
466+
467+
// The negative claims: evidence classes never blur, and record-side text never appears.
468+
assert.ok(!out.includes("label:"), "no matched label text exists anywhere in the model or the render");
469+
const observedLines = out.split("\n").filter((l) => /observed x\d/.test(l));
470+
assert.equal(observedLines.length, 1, "exactly the one observed edge renders with a count (the legend's xN is not a count)");
471+
for (const line of out.split("\n").filter((l) => l.includes("mention (potential") && !l.startsWith("edges:"))) {
472+
assert.ok(!/observed/.test(line), "a potential EDGE line never borrows observed's vocabulary (the legend names both on purpose)");
473+
}
474+
});
475+
476+
test("renderGraph: empty model and degraded inputs render exact, honest strings", () => {
477+
assert.equal(renderGraph({ meta: { triggersMissing: true } }), "Graph: no triggers file found");
478+
assert.equal(renderGraph({ meta: { triggersInvalid: "not valid JSON" } }), "Graph: triggers file invalid: not valid JSON");
479+
const empty = renderGraph(buildGraphModel({ caps: { chainDepthMax: 1, chainMaxPerJob: 2, windowDays: 30 } }));
480+
assert.equal(
481+
empty,
482+
["Graph: triggers and flows", "", "no triggers configured", "", "edges: -> configured, observed xN (from records), mention (potential; a mention is not a promise)", "caps: chain depth <= 1, <= 2 per job, same folder only, window 30d"].join("\n"),
483+
"the empty render is exact-equality pinned, caps included",
484+
);
485+
});
486+
487+
test("renderGraph surfaces truncation and dropped edges rather than pretending coverage", () => {
488+
const model = GRAPH_MODEL();
489+
model.meta.truncated.folders = true;
490+
model.meta.truncated.edges = true;
491+
model.meta.droppedObservedEdges = 2;
492+
const out = renderGraph(model);
493+
assert.match(out, /folder scan truncated .*unscanned/);
494+
assert.match(out, /observed edges truncated/);
495+
assert.match(out, /2 observed edges dropped \(no unique folder/);
496+
});

0 commit comments

Comments
 (0)