Skip to content

Commit 9402832

Browse files
committed
feat(admin): buildGraphModel, the graph's edge-honesty fold (issue #54)
One pure function assembles the model every graph consumer renders from, and the derivation rules are the design: four evidence classes (config, observed, potential, cron-rearm) from a closed test-pinned vocabulary, the two OQ-009 structural prohibitions (no forge-parent chain edges, no cross-folder chain edges: the harness makes both unrepresentable, so drawing either would draw a lie), precise dangling (no-skill only where enumeration succeeded; unverified is not dangling; charset-invalid is its own flag because deny proves nothing about existence), three-way orphanhood (orphan vs ai-reachable-no-trigger vs injected-ai-trigger), the OQ-020 pr-spend-loop badge, and caps plus honesty counters (unattributed runs, refusals, truncation, dropped edges) on every model. Gate eligibility is a node badge, never an all-pairs edge fabric; a mention is the edge, labelled strong/eligible. An observed edge that cannot hang on exactly one enumerated folder is dropped and counted, never guessed onto a basename that merely matches. An unenumerated cron folder still draws its trigger and config edge on a group that says not-enumerated, so the folder cap cannot silently lose an edge. Specs: NEW DES-GRAPH-EDGE-DERIVATION with the rejected alternatives recorded. DES-JOB-OUTBOX-CHAINING UNCHANGED, checked. DES-COST-FOLD-BY-SCAN UNCHANGED, checked (same scan, second consumer). Suite in the CI posture: 2115 pass, 0 skipped; admin bundle builds. Signed-off-by: Rob Boerman <robboerman@live.nl>
1 parent cb3ec3c commit 9402832

3 files changed

Lines changed: 592 additions & 4 deletions

File tree

admin/src/graph-model.mjs

Lines changed: 354 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
/**
2-
* The PURE side of the trigger/flow graph (issue #54): text scanners over skill content, and (next
3-
* slice) the model assembler every graph consumer renders from. No fs, no child_process, no redis,
4-
* no queue, no env -- read-model.mjs supplies bytes and joins, this module supplies meaning. The
5-
* purity is enforced by a source-regex test, the render.mjs/panel.mjs/costs.mjs pattern.
2+
* The PURE side of the trigger/flow graph (issue #54): text scanners over skill content, and the
3+
* model assembler every graph consumer renders from. No fs, no child_process, no redis, no queue,
4+
* no env -- read-model.mjs supplies bytes and joins, this module supplies meaning. The purity is
5+
* enforced by a source-regex test, the render.mjs/panel.mjs/costs.mjs pattern.
66
*/
77

8+
// SKILL_NAME_RE is a plain frozen RegExp; importing it keeps the charset single-sourced (the
9+
// issue #92 lesson) without breaking this module's purity -- nothing here spawns or reads anything.
10+
import { SKILL_NAME_RE } from "@edgehero/pi-dispatch/flow-gate";
11+
812
// One frontmatter value line: `key: value`, an optional surrounding double quote, single-line only.
913
// The same block-isolation discipline as flow-gate.mjs's aiTriggerAllows, and deliberately NOT a YAML
1014
// parser for the same recorded reason: two display strings do not justify js-yaml, and a scanner that
@@ -76,3 +80,349 @@ export function findSiblingMentions(text, siblingNames) {
7680
function escapeRegExp(s) {
7781
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7882
}
83+
84+
// The closed edge and flag vocabularies. Closed on purpose and pinned in the tests: every consumer
85+
// (the text renderer, the TUI view, the HTML export) switches on these strings, and a producer
86+
// minting a new one without a renderer arm should go red in a unit test, not render as nothing.
87+
export const GRAPH_EDGE_KINDS = Object.freeze(["config", "observed", "potential", "cron-rearm"]);
88+
export const GRAPH_FLAGS = Object.freeze([
89+
"no-skill", // config edge target absent at HEAD in an ENUMERATED folder (readFlowGate's precise token)
90+
"charset-invalid", // run.flow fails SKILL_NAME_RE: can never materialise, a distinct defect from no-skill
91+
"orphan", // no trigger, no ai-trigger, no incoming mention: dead by every path the system has
92+
"ai-reachable-no-trigger", // no trigger but ai-trigger: allow -- deliberately chain/dispatch_run-reachable
93+
"injected-ai-trigger", // an injected skill carrying ai-trigger: allow is a silent no-op (OQ-022)
94+
"unread", // SKILL.md unreadable/oversized at enumeration: facts unknown, gate read as closed
95+
"pr-spend-loop-risk", // pull_request on opened/synchronize: the OQ-020 cross-actor spend-loop signature
96+
]);
97+
98+
/**
99+
* Assemble the graph model every consumer renders from. Pure fold over the read-model's outputs --
100+
* this function performs no I/O, applies the EDGE HONESTY RULES (DES-GRAPH-EDGE-DERIVATION), and is
101+
* the single place they live:
102+
*
103+
* - every trigger with a `run.flow` gets exactly one `config` edge, always;
104+
* - `observed` edges come only from run records (they carry count + lastEndedAt);
105+
* - `potential` edges come only from a text mention, labelled `strong`/`eligible`, and eligibility
106+
* is a NODE badge (`aiTrigger`), never an all-pairs edge;
107+
* - `cron-rearm` is the one self-edge every cron trigger carries by definition;
108+
* - no chain edge is ever drawn out of a forge trigger's flow or across folders -- the harness
109+
* makes both unrepresentable (OQ-009), and drawing either would draw a lie;
110+
* - an UNREACHABLE folder produces zero dangling flags ("unverified" is not "dangling": deny and
111+
* no-skill are different facts, and a read that never happened proves neither);
112+
* - `caps` ride every model, because a consumer that renders edges without their bounds invites
113+
* the reader to extrapolate an unbounded chain fabric out of a depth-1, width-2 reality.
114+
*
115+
* Total function: absent or malformed inputs degrade to an empty-but-well-formed model, never a
116+
* throw (the read-model's viewer doctrine, one layer up).
117+
*/
118+
export function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, cronStats, runJoin, chainEdges, caps, nowMs } = {}) {
119+
const triggerList = Array.isArray(triggers?.triggers) ? triggers.triggers : [];
120+
const schedulerList = Array.isArray(schedulers) ? schedulers : [];
121+
const folders = folderSkills && typeof folderSkills === "object" ? folderSkills : {};
122+
const injected = injectedSkills && typeof injectedSkills === "object" ? injectedSkills : {};
123+
const statsById = cronStats?.byId && typeof cronStats.byId === "object" ? cronStats.byId : {};
124+
const statsByIndex = runJoin?.byIndex && typeof runJoin.byIndex === "object" ? runJoin.byIndex : {};
125+
const observed = Array.isArray(chainEdges?.edges) ? chainEdges.edges : [];
126+
127+
const model = {
128+
folders: [],
129+
nodes: [],
130+
edges: [],
131+
flags: [],
132+
caps: {
133+
chainDepthMax: Number.isInteger(caps?.chainDepthMax) ? caps.chainDepthMax : null,
134+
chainMaxPerJob: Number.isInteger(caps?.chainMaxPerJob) ? caps.chainMaxPerJob : null,
135+
sameFolderOnly: true,
136+
windowDays: Number.isInteger(caps?.windowDays) ? caps.windowDays : null,
137+
},
138+
meta: {
139+
generatedAt: Number.isFinite(nowMs) ? nowMs : null,
140+
triggersMissing: triggers?.missing === true,
141+
triggersInvalid: typeof triggers?.invalid === "string" ? triggers.invalid : null,
142+
unattributedRuns: Number.isInteger(runJoin?.unattributed) ? runJoin.unattributed : 0,
143+
chainRefusals: chainEdges?.refusals && typeof chainEdges.refusals === "object" ? chainEdges.refusals : {},
144+
truncated: {
145+
folders: false,
146+
skills: Object.values(folders).some((f) => f?.truncated === true),
147+
edges: chainEdges?.truncated === true,
148+
},
149+
droppedObservedEdges: 0,
150+
},
151+
};
152+
153+
// ---- folder groups: one per enumerated local folder, one per forge named by a webhook trigger ----
154+
const folderByPath = new Map();
155+
for (const [path, result] of Object.entries(folders)) {
156+
const group = {
157+
key: `folder:${path}`,
158+
path,
159+
label: basenameOf(path),
160+
kind: "local",
161+
head: typeof result?.head === "string" ? result.head : null,
162+
unreachable: typeof result?.unreachable === "string" ? result.unreachable : null,
163+
triggerIds: [],
164+
skillIds: [],
165+
};
166+
folderByPath.set(path, group);
167+
model.folders.push(group);
168+
}
169+
const forgeGroups = new Map();
170+
const forgeGroup = (forge) => {
171+
const name = typeof forge === "string" && forge !== "" ? forge : "forge";
172+
let group = forgeGroups.get(name);
173+
if (!group) {
174+
// A forge trigger's repo is not on this host, so its skills are unverifiable from the admin
175+
// (readFlowGate needs a local git dir); the group says so instead of growing dangling flags.
176+
group = { key: `forge:${name}`, path: null, label: name, kind: "forge", head: null, unreachable: "remote-repo", triggerIds: [], skillIds: [] };
177+
forgeGroups.set(name, group);
178+
model.folders.push(group);
179+
}
180+
return group;
181+
};
182+
183+
// ---- skill nodes from the enumerations ----
184+
const skillNode = new Map(); // "folderKeyname" -> node
185+
const addSkill = (group, skill) => {
186+
const id = `skill:${group.key}:${skill.name}`;
187+
const node = {
188+
id,
189+
kind: "skill",
190+
name: skill.name,
191+
folderKey: group.key,
192+
isSub: skill.isSub === true,
193+
group: skill.isSub === true ? skill.group : null,
194+
aiTrigger: skill.aiTrigger === true,
195+
meta: skill.meta ?? null,
196+
unread: skill.unread === true,
197+
isFlow: false, // set true when a config edge lands on it
198+
mentionedBy: 0,
199+
};
200+
skillNode.set(`${group.key}${skill.name}`, node);
201+
model.nodes.push(node);
202+
group.skillIds.push(id);
203+
if (node.unread) model.flags.push({ nodeId: id, flag: "unread", detail: "SKILL.md unreadable at enumeration; gate read as closed" });
204+
return node;
205+
};
206+
for (const [path, result] of Object.entries(folders)) {
207+
const group = folderByPath.get(path);
208+
for (const skill of Array.isArray(result?.skills) ? result.skills : []) {
209+
if (typeof skill?.name === "string" && skill.name !== "") addSkill(group, skill);
210+
}
211+
}
212+
for (const [dir, result] of Object.entries(injected)) {
213+
for (const skill of Array.isArray(result?.skills) ? result.skills : []) {
214+
if (typeof skill?.name !== "string" || skill.name === "") continue;
215+
const id = `injected:${dir}:${skill.name}`;
216+
model.nodes.push({ id, kind: "injected", name: skill.name, dir, aiTrigger: skill.aiTrigger === true });
217+
if (skill.aiTrigger === true) {
218+
model.flags.push({ nodeId: id, flag: "injected-ai-trigger", detail: "ai-trigger: allow on an injected skill is a silent no-op (OQ-022)" });
219+
}
220+
}
221+
}
222+
223+
// A config edge may point at a flow the enumeration did not find; the target then exists as a
224+
// `skill-missing` node so the edge has a visible end. Created lazily, once per (group, name).
225+
const missingNode = (group, name) => {
226+
const key = `${group.key}${name}`;
227+
let node = skillNode.get(key);
228+
if (node) return node;
229+
node = { id: `skill:${group.key}:${name}`, kind: "skill-missing", name, folderKey: group.key, isSub: false, group: null, aiTrigger: false, meta: null, unread: false, isFlow: false, mentionedBy: 0 };
230+
skillNode.set(key, node);
231+
model.nodes.push(node);
232+
group.skillIds.push(node.id);
233+
return node;
234+
};
235+
236+
// ---- trigger nodes + config edges + cron-rearm self-edges ----
237+
for (const t of triggerList) {
238+
if (!t || typeof t !== "object" || !Number.isInteger(t.index)) continue;
239+
const id = `trigger:${t.index}`;
240+
const isCron = t.type === "cron";
241+
let group = null;
242+
if (isCron) {
243+
if (typeof t.folder === "string" && t.folder !== "") {
244+
group = folderByPath.get(t.folder) ?? null;
245+
if (!group) {
246+
// The folder cap (or a caller that never enumerated) left this folder unscanned; the
247+
// trigger and its config edge still draw -- the config fact is real -- on a group that
248+
// says why its skills are unknown, rather than silently losing the edge.
249+
group = { key: `folder:${t.folder}`, path: t.folder, label: basenameOf(t.folder), kind: "local", head: null, unreachable: "not-enumerated", triggerIds: [], skillIds: [] };
250+
folderByPath.set(t.folder, group);
251+
model.folders.push(group);
252+
}
253+
}
254+
} else {
255+
group = forgeGroup(t.forge);
256+
}
257+
const stats = isCron ? (typeof t.id === "string" ? statsById[t.id] : undefined) : statsByIndex[t.index];
258+
const sched = isCron ? matchScheduler(schedulerList, t) : null;
259+
const node = {
260+
id,
261+
kind: "trigger",
262+
index: t.index,
263+
onType: t.type,
264+
forge: isCron ? null : (t.forge ?? null),
265+
cronId: isCron ? (t.id ?? null) : null,
266+
pattern: isCron ? (t.pattern ?? null) : null,
267+
label: triggerMatchLabel(t),
268+
flow: t.flow ?? null,
269+
replicas: t.replicas ?? null,
270+
folderKey: group?.key ?? null,
271+
runs: Number.isInteger(stats?.runs) ? stats.runs : 0,
272+
lastOutcome: stats?.lastOutcome ?? null,
273+
lastEndedAt: stats?.lastEndedAt ?? null,
274+
next: sched?.next ?? null,
275+
overdueMs: sched?.overdueMs ?? null,
276+
};
277+
model.nodes.push(node);
278+
if (group) group.triggerIds.push(id);
279+
280+
// The OQ-020 cross-actor spend-loop signature is static and cheap; the graph is where an
281+
// operator can actually see it, so it badges here rather than only in SECURITY.md prose.
282+
if (t.type === "pull_request" && Array.isArray(t.action) && t.action.some((a) => a === "opened" || a === "synchronize")) {
283+
model.flags.push({ nodeId: id, flag: "pr-spend-loop-risk", detail: "fires on opened/synchronize; a flow that pushes can loop with another bot (OQ-020)" });
284+
}
285+
286+
// Every cron trigger re-arms by definition: the one self-edge that is config, not history.
287+
if (isCron) model.edges.push({ from: id, to: id, kind: "cron-rearm", label: t.pattern ?? null });
288+
289+
// The config edge -- every trigger that names a flow gets one, ALWAYS.
290+
if (typeof t.flow === "string" && t.flow !== "") {
291+
if (!SKILL_NAME_RE.test(t.flow)) {
292+
// A charset-invalid flow can never materialise (materialize.mjs refuses the name), and the
293+
// gate answers deny, not no-skill -- a distinct, currently-invisible defect class.
294+
const target = group ? missingNode(group, clipName(t.flow)) : null;
295+
if (target) model.edges.push({ from: id, to: target.id, kind: "config" });
296+
model.flags.push({ nodeId: id, flag: "charset-invalid", detail: `run.flow ${JSON.stringify(clipName(t.flow))} fails the skill charset and can never materialise` });
297+
continue;
298+
}
299+
if (isCron && group && group.unreachable === null) {
300+
const existing = skillNode.get(`${group.key}${t.flow}`);
301+
if (existing && existing.kind === "skill" && !existing.isSub) {
302+
existing.isFlow = true;
303+
model.edges.push({ from: id, to: existing.id, kind: "config" });
304+
} else {
305+
// Enumeration SUCCEEDED and the path is absent: this is readFlowGate's precise
306+
// "no-skill", the one token that means dangling (deny would prove nothing).
307+
const target = missingNode(group, t.flow);
308+
model.edges.push({ from: id, to: target.id, kind: "config" });
309+
model.flags.push({ nodeId: id, flag: "no-skill", detail: `.pi/skills/${t.flow}/SKILL.md absent at HEAD` });
310+
}
311+
} else if (group) {
312+
// Forge repo (not local) or unreachable folder: the edge still draws -- the config fact is
313+
// real -- but no dangling flag can honestly attach to a read that never happened, so the
314+
// target is an UNVERIFIED node, a different kind from skill-missing on purpose.
315+
const target = missingNode(group, t.flow);
316+
if (target.kind === "skill-missing") target.kind = "skill-unverified";
317+
target.isFlow = true;
318+
model.edges.push({ from: id, to: target.id, kind: "config" });
319+
}
320+
}
321+
}
322+
323+
// ---- observed chain edges (records only), resolved onto folder groups by target basename ----
324+
const localGroups = model.folders.filter((f) => f.kind === "local");
325+
for (const edge of observed) {
326+
if (typeof edge?.parentFlow !== "string" || typeof edge?.childFlow !== "string") continue;
327+
const base = typeof edge.target === "string" && edge.target.startsWith("local:") ? edge.target.slice("local:".length) : null;
328+
const matches = base === null ? [] : localGroups.filter((f) => f.label === base);
329+
if (matches.length !== 1) {
330+
// No enumerated folder (or an ambiguous basename) to hang the edge on: dropping it and saying
331+
// so beats guessing, which could pin real history onto the wrong folder's skills.
332+
model.meta.droppedObservedEdges++;
333+
continue;
334+
}
335+
const group = matches[0];
336+
const from = missingNode(group, edge.parentFlow);
337+
const to = missingNode(group, edge.childFlow);
338+
model.edges.push({ from: from.id, to: to.id, kind: "observed", count: Number.isInteger(edge.count) ? edge.count : 0, lastEndedAt: edge.lastEndedAt ?? null });
339+
}
340+
341+
// ---- potential edges from text mentions, within each enumerated folder only ----
342+
for (const [path, result] of Object.entries(folders)) {
343+
const group = folderByPath.get(path);
344+
for (const skill of Array.isArray(result?.skills) ? result.skills : []) {
345+
if (skill?.isSub === true) continue;
346+
const from = skillNode.get(`${group.key}${skill?.name}`);
347+
if (!from) continue;
348+
for (const mention of Array.isArray(skill?.mentions) ? skill.mentions : []) {
349+
const to = skillNode.get(`${group.key}${mention?.name}`);
350+
if (!to || to.isSub) continue;
351+
to.mentionedBy++;
352+
model.edges.push({
353+
from: from.id,
354+
to: to.id,
355+
kind: "potential",
356+
strong: mention.strong === true,
357+
// Eligibility is the exact static half: without ai-trigger: allow on the TARGET, the
358+
// outbox gate refuses this edge every time, so a mention alone renders "can never fire".
359+
eligible: to.aiTrigger === true,
360+
});
361+
}
362+
}
363+
}
364+
365+
// ---- orphan / reachability flags, only where the enumeration actually succeeded ----
366+
for (const [path] of Object.entries(folders)) {
367+
const group = folderByPath.get(path);
368+
if (group.unreachable !== null) continue;
369+
for (const id of group.skillIds) {
370+
const node = model.nodes.find((n) => n.id === id);
371+
if (!node || node.kind !== "skill" || node.isSub || node.unread) continue;
372+
if (node.isFlow) continue;
373+
if (node.aiTrigger) {
374+
model.flags.push({ nodeId: id, flag: "ai-reachable-no-trigger", detail: "no trigger names it, but ai-trigger: allow keeps it chain/dispatch_run-reachable" });
375+
} else if (node.mentionedBy === 0) {
376+
model.flags.push({ nodeId: id, flag: "orphan", detail: "no trigger, no ai-trigger, no mention: dead by every path the system has" });
377+
}
378+
}
379+
}
380+
381+
return model;
382+
}
383+
384+
/** The display label for a trigger's match side, mirroring render.mjs's triggerLine vocabulary. */
385+
function triggerMatchLabel(t) {
386+
switch (t?.type) {
387+
case "cron":
388+
return `${t.id ?? "-"} ${t.pattern ?? "-"}`;
389+
case "label":
390+
return selectorLabel(t);
391+
case "comment":
392+
return `"${t.phrase ?? "-"}"`;
393+
case "pull_request":
394+
return `action[${(Array.isArray(t.action) ? t.action : []).join(",")}]`;
395+
default:
396+
return "(unknown)";
397+
}
398+
}
399+
400+
function selectorLabel(t) {
401+
const parts = [];
402+
if (Array.isArray(t.any) && t.any.length) parts.push(`any[${t.any.join(",")}]`);
403+
if (Array.isArray(t.all) && t.all.length) parts.push(`all[${t.all.join(",")}]`);
404+
if (Array.isArray(t.none) && t.none.length) parts.push(`none[${t.none.join(",")}]`);
405+
return parts.join(" ") || "(no selector)";
406+
}
407+
408+
/** Match a cron display trigger to its resident scheduler: by key/name (the id), else by pattern. */
409+
function matchScheduler(schedulers, t) {
410+
if (typeof t?.id === "string") {
411+
const byId = schedulers.find((s) => s?.key === t.id || s?.name === t.id);
412+
if (byId) return byId;
413+
}
414+
return typeof t?.pattern === "string" ? (schedulers.find((s) => s?.pattern === t.pattern) ?? null) : null;
415+
}
416+
417+
/** Basename without importing node:path (this module is pure); both separators, trailing-sep safe. */
418+
function basenameOf(path) {
419+
const trimmed = String(path).replace(/[\\/]+$/, "");
420+
const at = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
421+
return at === -1 ? trimmed : trimmed.slice(at + 1);
422+
}
423+
424+
/** Clip an arbitrary (possibly hostile) flow string for node display; the honest badge needs the name. */
425+
function clipName(name) {
426+
const s = String(name);
427+
return s.length > 64 ? `${s.slice(0, 64)}…` : s;
428+
}

0 commit comments

Comments
 (0)