|
1 | 1 | /** |
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. |
6 | 6 | */ |
7 | 7 |
|
| 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 | + |
8 | 12 | // One frontmatter value line: `key: value`, an optional surrounding double quote, single-line only. |
9 | 13 | // The same block-isolation discipline as flow-gate.mjs's aiTriggerAllows, and deliberately NOT a YAML |
10 | 14 | // 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) { |
76 | 80 | function escapeRegExp(s) { |
77 | 81 | return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); |
78 | 82 | } |
| 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(); // "folderKey |