Skip to content

Commit e54deac

Browse files
committed
feat(admin): tier-aware flow resolution in the topology (#188)
A cron trigger whose run.flow legally lives in an injected run.skillsDir, the overlay skills/, or a staged package drew its config edge to a red skill-missing node flagged no-skill (absent at HEAD), a false alarm for a flow that runs fine. The sharpest case minted a second red node while the true injected:<dir>:<name> node floated edge-less in its own group. Config-edge resolution now walks the loader's own precedence order per trigger: repo > injected > overlay > staged. A lower-tier hit lands the edge on that tier's node, unflagged, the never-AI-reachable half riding the tip; the overlay and staged tiers enumerate as their own groups where the session can see PI_GLOBAL_PI_DIR (new readOverlaySkills, and the worker's own readStagedSkills behind readStagedSkillsList, manifest order preserved because it is the loader's shadowing order). A tier node is claimed only when every higher applicable tier is a KNOWN miss: doctor's existential tick probes past an unknown tier, an identity edge must not, so an unknown tier (no PI_GLOBAL_PI_DIR in the session, which the deployment pointer deliberately cannot carry; an unreadable or truncated listing; a pattern-manifest package) softens the claim to the new amber skill-not-at-head kind naming the unchecked tiers in the tip. Red no-skill now requires every applicable tier checked and missed, its detail names them, and run.packages: false reads as the known withheld miss it is. Forge triggers keep skill-unverified: the remote repo outranks every host-readable tier. The graph tip also gains the command: /name line for command triggers (the issue #189 handoff; normalizeModel's allowlist gains the field the canary test proved structurally unreachable). The closed edge and flag vocabularies are byte-unchanged. Node kinds grew (overlay, staged, skill-not-at-head) and became the third closed pinned set, GRAPH_NODE_KINDS, with a glyph-parity pin so a kind without a renderer arm goes red in a unit test. REQ-TOPOLOGY-GRAPH AMENDED (statement, new clause (a2), acceptance). REQ-PER-TRIGGER-SKILLS AMENDED (injected-only acceptance clause). REQ-GLOBAL-PI-OVERLAY AMENDED (the topology as doctor's display half). DES-GRAPH-EDGE-DERIVATION AMENDED (ladder; stop-at-unknown with the doctor divergence recorded; two new Rejected rows). DES-ADMIN-VIA-PI-EXTENSION AMENDED (two new readers, same never-throw, GRAPH_LIMITS-bounded, display-advisory posture). DES-FLOW-RESOLUTION-TWO-ADVISORY-LAYERS AMENDED (third advisory surface). OQ-022 AMENDED (prose correction), OQ-025 NEW (six accepted residuals). DES-AI-TRIGGER-FLOW-GATE UNCHANGED, checked: AI-reachability stays committed-repo-only; tier nodes carry no aiTrigger and no chainable badge; potential-edge eligibility still reads only the repo enumeration. REQ-DEPLOYMENT-BOOTSTRAP UNCHANGED, checked: display only, no doctor change, no fix tier. Signed-off-by: Rob Boerman <robboerman@live.nl>
1 parent 2dec8d5 commit e54deac

14 files changed

Lines changed: 879 additions & 49 deletions

admin/src/graph-html.mjs

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,15 +116,20 @@ const WIRE_POTENTIAL = "#8b949e";
116116

117117
// One glyph per node kind for the 30px icon column; characters, not images, because images would
118118
// need data: URIs and a font would need @font-face, both of which the file:// posture forbids.
119-
const GLYPH = Object.freeze({
119+
// Exported for the kind-parity test: this module cannot use the `from` clause, so a test is the
120+
// anti-drift wire that keeps every GRAPH_NODE_KINDS entry drawable (the GRAPH_HTML_KINDS pattern).
121+
export const GLYPH = Object.freeze({
120122
cron: "◷",
121123
label: "◈",
122124
comment: "❝",
123125
pull_request: "⇄",
124126
skill: "ƒ",
125127
"skill-missing": "!",
126128
"skill-unverified": "?",
129+
"skill-not-at-head": "⋯",
127130
injected: "+",
131+
overlay: "◎",
132+
staged: "▣",
128133
});
129134

130135
// The 5-entity escape, byte-for-byte the worker's buildFormPage helper: every string interpolated
@@ -208,6 +213,14 @@ function normalizeModel(model) {
208213
.map((d) => clip(d, 120))
209214
.sort()
210215
.slice(0, 8),
216+
// The two tier-honesty counters (issue #188), the injectedUnreachable rule applied to the
217+
// deployment-wide tiers: a tier the ladder silently skipped would read as "checked".
218+
overlayUnreachable: m.meta?.overlayUnreachable === true,
219+
stagedUnenumerable: (Array.isArray(m.meta?.stagedUnenumerable) ? m.meta.stagedUnenumerable : [])
220+
.filter((d) => typeof d === "string")
221+
.map((d) => clip(d, 80))
222+
.sort()
223+
.slice(0, 8),
211224
};
212225

213226
const folders = [];
@@ -243,6 +256,15 @@ function normalizeModel(model) {
243256
onType: typeof n.onType === "string" ? n.onType : null,
244257
pattern: typeof n.pattern === "string" ? n.pattern : null,
245258
flow: typeof n.flow === "string" ? n.flow : null,
259+
// A command trigger's slash command (issue #189's node fact, #188's rendering): the tip is the
260+
// detail surface, so the full command rides, clipped like every other string on this page.
261+
command: typeof n.command === "string" ? clip(n.command, 80) : null,
262+
// The staged tier node's owning package, and the softened state's evidence: which tiers this
263+
// session could not check. Both clipped/sorted/capped so a hostile model cannot inflate a tip.
264+
package: typeof n.package === "string" ? clip(n.package, 80) : null,
265+
tiersUnknown: Array.isArray(n.tiersUnknown)
266+
? [...new Set(n.tiersUnknown.filter((t) => typeof t === "string").map((t) => clip(t, 40)))].sort().slice(0, 4)
267+
: [],
246268
replicas: intOr(n.replicas, null),
247269
folderKey: typeof n.folderKey === "string" ? n.folderKey : null,
248270
runs: intOr(n.runs, 0),
@@ -350,6 +372,8 @@ function layoutNormalized(norm) {
350372
placedByOrig.set(n.id, p);
351373
let g = n.folderKey === null ? null : (groupIndexByKey.get(n.folderKey) ?? null);
352374
if (!g && n.kind === "injected") g = groupIndexByKey.get("~injected") ?? addGroup("~injected", "injected skills (run.skillsDir)", "local", null, null);
375+
if (!g && n.kind === "overlay") g = groupIndexByKey.get("~overlay") ?? addGroup("~overlay", "overlay skills (global pi dir)", "local", null, null);
376+
if (!g && n.kind === "staged") g = groupIndexByKey.get("~staged") ?? addGroup("~staged", "staged package skills", "local", null, null);
353377
if (!g) g = groupIndexByKey.get(n.folderKey ?? "~ungrouped") ?? addGroup(n.folderKey ?? "~ungrouped", "ungrouped", "local", null, null);
354378
g.members.push(p);
355379
p.groupId = g.id;
@@ -758,13 +782,27 @@ function buildTip(n, flags, groupLabel, nowMs) {
758782
if (n.kind === "trigger") {
759783
lines.push(`trigger · ${n.onType ?? "?"} · ${n.label ?? ""}`.trim());
760784
if (n.flow !== null) lines.push(`flow: ${n.flow}${n.replicas !== null ? ` ×${n.replicas}` : ""}`);
785+
// flow's mutually-exclusive sibling (issue #189): a command trigger dispatches a registered
786+
// extension command, so the tip shows the whole /name-and-args line the reviewed file staged.
787+
else if (n.command !== null) lines.push(`command: /${n.command}${n.replicas !== null ? ` ×${n.replicas}` : ""}`);
761788
} else if (n.kind === "injected") {
762789
lines.push(`injected skill · ${n.name ?? "?"}`);
763790
lines.push("trigger-reachable via run.skillsDir, never AI-reachable");
791+
} else if (n.kind === "overlay") {
792+
lines.push(`overlay skill · ${n.name ?? "?"}`);
793+
lines.push("deployment overlay skills/, trigger-reachable, never AI-reachable");
794+
} else if (n.kind === "staged") {
795+
lines.push(`staged skill · ${n.name ?? "?"}${n.package !== null ? ` · package ${n.package}` : ""}`);
796+
lines.push("staged pi package, trigger-reachable, never AI-reachable");
764797
} else if (n.kind === "skill-missing") {
765798
lines.push(`skill · ${n.name ?? "?"} · missing at HEAD`);
766799
} else if (n.kind === "skill-unverified") {
767800
lines.push(`skill · ${n.name ?? "?"} · unverified (repo not readable from this host)`);
801+
} else if (n.kind === "skill-not-at-head") {
802+
// The softened state (issue #188): absent at HEAD is known TRUE, but a tier this session cannot
803+
// check may still hold the name, so neither missing styling nor a dangling flag would be honest.
804+
lines.push(`skill · ${n.name ?? "?"} · not committed at HEAD`);
805+
if (n.tiersUnknown.length > 0) lines.push(`not checkable from this session: ${n.tiersUnknown.join(", ")}`);
768806
} else {
769807
lines.push(`skill · ${n.name ?? "?"}${n.isSub ? " · sub-skill (never a flow)" : ""}`);
770808
}
@@ -823,6 +861,10 @@ function nodeState(n, flagNames) {
823861
return { stroke: DANGER, dash: "10,4", faded: false };
824862
}
825863
if (n.kind === "skill-unverified") return { stroke: PAGE_DIM, dash: "10,4", faded: false };
864+
// Amber, deliberately between red and dim (issue #188): the repo read HAPPENED and came back
865+
// absent, but an unchecked tier may hold the name -- a third epistemic state, styled as one.
866+
// Safe below the red arm: the dangling flags ride TRIGGER nodes, never this kind.
867+
if (n.kind === "skill-not-at-head") return { stroke: PAGE_AMBER, dash: "10,4", faded: false };
826868
if (flagNames.has("orphan")) return { stroke: CHIP_STROKE, dash: "8,3", faded: true };
827869
return { stroke: CHIP_STROKE, dash: null, faded: false };
828870
}
@@ -959,7 +1001,8 @@ export function legendHtml(norm) {
9591001
row(legendSwatch(`<circle cx="8" cy="6" r="5" fill="${BADGE_ORANGE}"/>`), "unread / injected ai-trigger / PR spend-loop risk");
9601002
row(legendSwatch(`<circle cx="8" cy="6" r="4" fill="none" stroke="${BADGE_GREEN}" stroke-width="2"/>`), "chainable: ai-trigger allow");
9611003
row(legendSwatch(`<rect x="1" y="1" width="14" height="10" rx="2" fill="none" stroke="${CHIP_STROKE}" stroke-dasharray="8,3"/>`), "orphan: no trigger, no ai-trigger, no mention");
962-
row(legendSwatch(`<rect x="1" y="1" width="14" height="10" rx="2" fill="none" stroke="${DANGER}" stroke-dasharray="10,4"/>`), "dangling: flow absent or name invalid");
1004+
row(legendSwatch(`<rect x="1" y="1" width="14" height="10" rx="2" fill="none" stroke="${DANGER}" stroke-dasharray="10,4"/>`), "dangling: absent in every checkable tier or name invalid");
1005+
row(legendSwatch(`<rect x="1" y="1" width="14" height="10" rx="2" fill="none" stroke="${PAGE_AMBER}" stroke-dasharray="10,4"/>`), "not at HEAD: some skill tiers not checkable from this session");
9631006
row(legendSwatch(`<rect x="1" y="1" width="14" height="10" rx="2" fill="none" stroke="${PAGE_DIM}" stroke-dasharray="10,4"/>`), "unverified: repo not readable from this host");
9641007
rows.push(`<div class="caps">${escapeHtml(capsLineText(norm.caps))}</div>`);
9651008
const honesty = [];
@@ -972,6 +1015,8 @@ export function legendHtml(norm) {
9721015
const refused = norm.meta.chainRefusals.reduce((a, r) => a + r.count, 0);
9731016
if (refused > 0) honesty.push(`${refused} chain requests refused (caps or gate)`);
9741017
if (norm.meta.injectedUnreachable.length > 0) honesty.push(`injected skills dir unreadable: ${norm.meta.injectedUnreachable.join(", ")}`);
1018+
if (norm.meta.overlayUnreachable) honesty.push("overlay skills dir unreadable (global pi dir)");
1019+
if (norm.meta.stagedUnenumerable.length > 0) honesty.push(`staged packages not enumerable (manifest patterns): ${norm.meta.stagedUnenumerable.join(", ")}`);
9751020
for (const line of honesty) rows.push(`<div class="honesty">${escapeHtml(line)}</div>`);
9761021
return `<div id="legend">${rows.join("")}</div>`;
9771022
}

0 commit comments

Comments
 (0)