Skip to content

Commit b4d0ad5

Browse files
committed
feat(admin): the graph read-model, skill enumeration, and record joins (issue #54)
The data layer under the trigger/flow graph. Everything the graph will draw becomes readable through read-model.mjs, the admin's one I/O funnel; the view itself is a later slice. Read-model additions, all never-throw, all degrading per folder to a discriminated unreachable, all bounded by the frozen literal-pinned GRAPH_LIMITS: readFolderSkills (object-store enumeration at HEAD via the worker's own selectEntries/keepOnlyDeclaredSkills over one hardened ls-tree, one bounded cat-file per top-level SKILL.md, frontmatter through the gate's own aiTriggerAllows; display-advisory, never a gate decision), readInjectedSkills (working-tree, advisory, the doctor precedent), cronRunStats (raw repeat:<id>:<millis> jobId join with the digits-tail disambiguator), joinRunsToTriggers (persisted triggerIndex with the OQ-008 range guard: a stale index counts unattributed, never lands on whatever entry now occupies the row), observedChainEdges (parentJobId joins folded per flow pair, same-target only per OQ-009, refusals surfaced), and collectGraphInputs (the one dedupe/caps funnel). readTriggers display records now carry the RAW triggers-array index, the identity matched.index counts, so a dropped row leaves a hole rather than renumbering every attribution below it. resolvePaths mirrors the chain caps with defaults imported from the worker. New pure module graph-model.mjs (parseSkillMeta, findSiblingMentions; purity source-regex-tested). findSiblingMentions boundary-matches against the skill-name charset itself rather than \b, because \b calls a hyphen a boundary and fix would fire inside prefix-fix. Worker enablers: ./materialize exports-map subpath, aiTriggerAllows exported, CHAIN_DEPTH_MAX_DEFAULT/CHAIN_MAX_PER_JOB_DEFAULT hoisted as exported consts (loadConfig behaviour unchanged, pinned by test). Specs: DES-ADMIN-VIA-PI-EXTENSION AMENDED, saying out loud what the last three dashboard rows certified as unchanged, because this time it did change: a new read-model surface and new fs/git access. Dashboard fs ban UNCHANGED, checked. .log placement boundary UNCHANGED, checked. DES-AI-TRIGGER-FLOW-GATE UNCHANGED, checked (HEAD-at-display-time is advisory, an unreadable SKILL.md reads as not chainable). Suite in the CI posture: 2100 pass, 0 skipped; admin bundle builds. Signed-off-by: Rob Boerman <robboerman@live.nl>
1 parent f61fd18 commit b4d0ad5

10 files changed

Lines changed: 831 additions & 16 deletions

File tree

admin/src/graph-model.mjs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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.
6+
*/
7+
8+
// One frontmatter value line: `key: value`, an optional surrounding double quote, single-line only.
9+
// The same block-isolation discipline as flow-gate.mjs's aiTriggerAllows, and deliberately NOT a YAML
10+
// parser for the same recorded reason: two display strings do not justify js-yaml, and a scanner that
11+
// accepts only what it understands cannot be driven into surprising shapes by hostile frontmatter.
12+
const FRONTMATTER_BLOCK_RE = /^---\n([\s\S]*?)\n---(?:\n|$)/;
13+
14+
// Display values are CLIPPED, not refused: a viewer degrades. 120 chars covers every honest
15+
// name/description and bounds what a hostile one can push into a panel row or an HTML tooltip.
16+
const META_VALUE_MAX_CHARS = 120;
17+
18+
/**
19+
* Read a skill's display metadata (`name`, `description`) from its SKILL.md frontmatter.
20+
* Total function: any input shape yields `{ name, description }` with nulls, never a throw.
21+
*/
22+
export function parseSkillMeta(text) {
23+
const empty = { name: null, description: null };
24+
if (typeof text !== "string") return empty;
25+
const normalized = text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
26+
const block = FRONTMATTER_BLOCK_RE.exec(normalized);
27+
if (!block) return empty;
28+
return {
29+
name: frontmatterValue(block[1], "name"),
30+
description: frontmatterValue(block[1], "description"),
31+
};
32+
}
33+
34+
function frontmatterValue(block, key) {
35+
const m = new RegExp(`^${key}:[ \\t]*(.*)$`, "m").exec(block);
36+
if (!m) return null;
37+
let value = m[1].trim();
38+
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
39+
if (value === "") return null;
40+
return value.length > META_VALUE_MAX_CHARS ? `${value.slice(0, META_VALUE_MAX_CHARS)}…` : value;
41+
}
42+
43+
// A mention is "strong" when it sits near chaining vocabulary -- the outbox protocol's own words.
44+
// Distance, not co-occurrence-anywhere: a README-style skill that lists every sibling once should
45+
// not promote all of them to likely chain targets.
46+
const CHAIN_VOCAB_RE = /outbox|request-|"flow"|\bchain\b|follow-up/i;
47+
const CHAIN_VOCAB_RADIUS = 200;
48+
49+
/**
50+
* Find which sibling skill names a skill's text mentions -- the heuristic half of the potential
51+
* flow->flow edge (the exact half is the target's own `ai-trigger: allow`). The name space makes
52+
* this workable where free-text search would not be: SKILL_NAME_RE names are a closed, enumerable
53+
* set the caller supplies, so this scans for a handful of known literals and nothing else.
54+
*
55+
* Boundary-matched against the SKILL NAME charset itself, not `\b`: a skill name may contain `-`,
56+
* and `\b` calls a hyphen a boundary, so `\bfix\b` would fire inside `prefix-fix` -- the lookarounds
57+
* refuse any neighbouring name-charset character instead. A name that is also an English word can
58+
* still false-positive in prose, which is exactly why these edges render as "potential" and never as
59+
* observed. Returns `[{ name, strong }]` in the callers' name order; total function, never throws.
60+
*/
61+
export function findSiblingMentions(text, siblingNames) {
62+
if (typeof text !== "string" || !Array.isArray(siblingNames)) return [];
63+
const mentions = [];
64+
for (const name of siblingNames) {
65+
if (typeof name !== "string" || name === "") continue;
66+
const re = new RegExp(`(?<![a-z0-9_-])${escapeRegExp(name)}(?![a-z0-9_-])`);
67+
const at = text.search(re);
68+
if (at === -1) continue;
69+
const windowStart = Math.max(0, at - CHAIN_VOCAB_RADIUS);
70+
const windowEnd = Math.min(text.length, at + name.length + CHAIN_VOCAB_RADIUS);
71+
mentions.push({ name, strong: CHAIN_VOCAB_RE.test(text.slice(windowStart, windowEnd)) });
72+
}
73+
return mentions;
74+
}
75+
76+
function escapeRegExp(s) {
77+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
78+
}

admin/src/read-model.mjs

Lines changed: 321 additions & 5 deletions
Large diffs are not rendered by default.
Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
// Proves jiti resolves the @edgehero/pi-dispatch workspace symlink and its
22
// exports map (the real 3.2 risk), not just bare-relative TypeScript.
33
import { settingsFilePath } from "@edgehero/pi-dispatch/runtime-settings";
4+
// The graph read-model's subpath (issue #54): a missing exports-map entry fails here, in a unit
5+
// test, rather than at the first /dispatch graph in a bundled install.
6+
import { selectEntries, keepOnlyDeclaredSkills } from "@edgehero/pi-dispatch/materialize";
7+
import { aiTriggerAllows } from "@edgehero/pi-dispatch/flow-gate";
8+
import { CHAIN_DEPTH_MAX_DEFAULT } from "@edgehero/pi-dispatch/config";
49

5-
export const ok = typeof settingsFilePath === "function";
10+
export const ok =
11+
typeof settingsFilePath === "function" &&
12+
typeof selectEntries === "function" &&
13+
typeof keepOnlyDeclaredSkills === "function" &&
14+
typeof aiTriggerAllows === "function" &&
15+
Number.isInteger(CHAIN_DEPTH_MAX_DEFAULT);

admin/test/graph-model.test.mjs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import assert from "node:assert/strict";
2+
import { readFileSync } from "node:fs";
3+
import { fileURLToPath } from "node:url";
4+
import { test } from "node:test";
5+
import { parseSkillMeta, findSiblingMentions } from "../src/graph-model.mjs";
6+
7+
test("graph-model.mjs is pure: no fs, no child_process, no redis, no queue, no env", () => {
8+
// The purity guard comes FIRST (the render.mjs/panel.mjs/costs.mjs pattern): this module receives
9+
// bytes and returns meaning, and the day it grows an import is the day the graph stops being
10+
// testable offline and the read-model stops being the admin's one I/O funnel.
11+
const src = readFileSync(fileURLToPath(new URL("../src/graph-model.mjs", import.meta.url)), "utf8");
12+
assert.ok(!/node:fs|node:child_process|ioredis|bullmq|process\.env|readFileSync|execFile/.test(src), "graph-model must stay pure");
13+
});
14+
15+
// ---- parseSkillMeta ----
16+
17+
test("parseSkillMeta reads name and description from the leading frontmatter", () => {
18+
const meta = parseSkillMeta('---\nname: tidy\ndescription: Format and fix lint.\nai-trigger: allow\n---\nBody.\n');
19+
assert.deepEqual(meta, { name: "tidy", description: "Format and fix lint." });
20+
});
21+
22+
test("parseSkillMeta strips a BOM, normalises CRLF, and unwraps double quotes", () => {
23+
const meta = parseSkillMeta('\uFEFF---\r\nname: "quoted name"\r\ndescription: d\r\n---\r\n');
24+
assert.deepEqual(meta, { name: "quoted name", description: "d" });
25+
});
26+
27+
test("parseSkillMeta returns nulls for absent frontmatter, absent keys, and non-strings", () => {
28+
const empty = { name: null, description: null };
29+
assert.deepEqual(parseSkillMeta("no frontmatter here"), empty);
30+
assert.deepEqual(parseSkillMeta("---\nother: x\n---\n"), empty);
31+
assert.deepEqual(parseSkillMeta(""), empty);
32+
assert.deepEqual(parseSkillMeta(null), empty);
33+
assert.deepEqual(parseSkillMeta(42), empty);
34+
});
35+
36+
test("parseSkillMeta clips a hostile oversized value rather than refusing the skill", () => {
37+
const meta = parseSkillMeta(`---\nname: ${"x".repeat(500)}\n---\n`);
38+
assert.equal(meta.name.length, 121, "120 chars plus the ellipsis");
39+
assert.ok(meta.name.endsWith("…"));
40+
});
41+
42+
test("parseSkillMeta reads only the LEADING block, never a --- fence later in the body", () => {
43+
const meta = parseSkillMeta("Body first.\n---\nname: sneaky\n---\n");
44+
assert.deepEqual(meta, { name: null, description: null }, "frontmatter is leading-only, like the gate's scanner");
45+
});
46+
47+
// ---- findSiblingMentions ----
48+
49+
test("findSiblingMentions finds word-boundary sibling names and misses substrings", () => {
50+
const text = "After the build finishes, chain the open-pr flow. Nothing here says prefix-fix.";
51+
const mentions = findSiblingMentions(text, ["open-pr", "fix", "absent-skill"]);
52+
assert.deepEqual(
53+
mentions.map((m) => m.name),
54+
["open-pr"],
55+
"fix must not fire inside prefix-fix, and absent names must not appear",
56+
);
57+
});
58+
59+
test("findSiblingMentions marks a mention strong only near chaining vocabulary", () => {
60+
const strong = findSiblingMentions('Write /outbox/request-1.json with {"flow": "open-pr"}.', ["open-pr"]);
61+
assert.deepEqual(strong, [{ name: "open-pr", strong: true }]);
62+
63+
const weak = findSiblingMentions(`See also the open-pr skill for context.${" filler".repeat(60)} outbox`, ["open-pr"]);
64+
assert.deepEqual(weak, [{ name: "open-pr", strong: false }], "vocabulary beyond the radius must not promote the mention");
65+
});
66+
67+
test("findSiblingMentions is total: bad inputs yield [], and a self-shaped name list is fine", () => {
68+
assert.deepEqual(findSiblingMentions(null, ["a"]), []);
69+
assert.deepEqual(findSiblingMentions("text", null), []);
70+
assert.deepEqual(findSiblingMentions("text", [42, "", null]), []);
71+
});

0 commit comments

Comments
 (0)