Skip to content

Commit f224260

Browse files
committed
feat(admin): schedule and spend on the graph, and the HTML honesty carry-through (issue #175)
The third insights slice: the facts the graph computed and never said. - Trigger nodes gain cost (the typed spend foldTriggerCosts maps onto the node id) -- a node FACT beside runs/lastOutcome, no new edge kind, no new flag, the closed vocabularies pinned unchanged. The assemblers wire it with one extra file read and two pure folds over the scan they already paid for; the GRAPH view's entry-plus-r-only refresh policy is untouched and the real-poll pin proves it. - Cron rows finally render next/overdue: next as a countdown against the model's own generatedAt (never a live clock; a stale model shows its stale countdown honestly), overdue in the error color. Both in the TUI and in /dispatch graph text. - Spend renders only through fmtCost/styler.fmtCost, so a plan-covered trigger reads plan:<id> on the graph too, never $0.00. - graph.html: normalizeModel carries meta.chainRefusals, meta.injectedUnreachable and observed-edge lastEndedAt; the legend states the two counters the text and TUI always stated; an observed wire's label says how fresh it is beside its count. Node cost is deliberately NOT allowlisted there: the page cannot use the from clause, and a hand-copied money formatter is a parity liability the insights artifact avoids by taking the real formatter. Specs in the same PR: REQ-TOPOLOGY-GRAPH gains (h); REQ-GRAPH-HTML-EXPORT amended (honesty counters, edge recency); DES-GRAPH-EDGE-DERIVATION and DES-ADMIN-VIA-PI-EXTENSION amended; REQ-COST-ANALYTICS UNCHANGED, checked; DES-COST-FOLD-BY-SCAN UNCHANGED, checked. docs/graph.md updated. Suite green in the CI posture: 2197 tests, 0 skipped, live Valkey. Signed-off-by: Rob Boerman <robboerman@live.nl>
1 parent 4bd530b commit f224260

12 files changed

Lines changed: 189 additions & 17 deletions

admin/src/dashboard.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import { buildGraphModel } from "./graph-model.mjs";
3636
import { matchesKey } from "./keys.mjs";
3737
import { box, meter, clip, fmtUsd, makeLineInput } from "./panel.mjs";
3838
import { makeStyler, frame, RULE } from "./style.mjs";
39-
import { COSTS_WINDOWS, costsSinceMs, foldCosts, whatIfFlow } from "./costs.mjs";
39+
import { COSTS_WINDOWS, costsSinceMs, foldCosts, foldTriggerCosts, whatIfFlow } from "./costs.mjs";
4040

4141
const KEY_HINTS = "[p]ause [r]esume [q]uit";
4242
// Fetch the read-model's full window (listRuns clamps at 50) but render a cursor-following viewport of
@@ -161,6 +161,10 @@ export function createDashboardDeps(paths: any) {
161161
const triggerList: any[] = Array.isArray(triggersView?.triggers) ? triggersView.triggers : [];
162162
const records: any = scanRunRecords({ logsDir: paths.logsDir, sinceMs: nowMs - GRAPH_LIMITS.windowDays * 24 * 60 * 60 * 1000, nowMs });
163163
const recs: any[] = Array.isArray(records) ? records : [];
164+
// Spend badges (issue #175): one more FILE read (subscriptions) and two pure folds over the
165+
// scan this seam already paid for -- no new spawn, so the entry+`r`-only policy is untouched.
166+
const subsView: any = readSubscriptions({ subscriptionsPath: paths.subscriptionsPath });
167+
const triggerJoin = attributeRunsToTriggers({ records: recs, triggers: triggerList });
164168
return buildGraphModel({
165169
triggers: triggersView,
166170
schedulers: Array.isArray(schedulers) ? schedulers : [],
@@ -171,6 +175,7 @@ export function createDashboardDeps(paths: any) {
171175
forgeRepos: forgeRepoTargets({ records: recs }),
172176
caps: { chainDepthMax: paths.chainDepthMax, chainMaxPerJob: paths.chainMaxPerJob, windowDays: GRAPH_LIMITS.windowDays },
173177
nowMs,
178+
triggerCosts: foldTriggerCosts({ records: recs, subscriptions: Array.isArray(subsView?.subscriptions) ? subsView.subscriptions : [], pricing, triggerJoin }),
174179
});
175180
},
176181
/** The priced-model catalog for the what-if `/` filter -- the façade stays behind this seam. */
@@ -1623,7 +1628,9 @@ function graphRows(model: any, folded: any): any[] {
16231628
if (isFolded) continue;
16241629
for (const id of group.triggerIds ?? []) {
16251630
const node: any = nodeById.get(id);
1626-
if (node) rows.push({ kind: "gtrigger", node, flags: flagsByNode.get(id) ?? [] });
1631+
// generatedAt rides on the row so graphRowLine can phrase `next` as a countdown without a
1632+
// clock of its own -- render() stays a pure read of component state.
1633+
if (node) rows.push({ kind: "gtrigger", node, flags: flagsByNode.get(id) ?? [], generatedAt: model.meta?.generatedAt ?? null });
16271634
}
16281635
for (const id of group.skillIds ?? []) {
16291636
const node: any = nodeById.get(id);
@@ -1661,8 +1668,17 @@ function graphRowLine(row: any, cursor: boolean, inner: number, styler: any): st
16611668
const stats = t.runs > 0 ? `runs ${t.runs}${t.lastOutcome ? ` · last ${t.lastOutcome}` : ""}` : "no runs in window";
16621669
const statColor = t.lastOutcome === "completed" ? "success" : t.lastOutcome ? "warning" : "dim";
16631670
const badges = graphBadges(row.flags, styler);
1671+
// The schedule fact the model always computed and no surface rendered (issue #175): overdue in
1672+
// the error color -- the money backstop's own signal -- else the next fire as a countdown against
1673+
// the model's generatedAt (never a live clock; a stale model shows its stale countdown honestly).
1674+
const parts: string[] = [styler.fg(statColor, stats)];
1675+
const schedule = triggerScheduleText(t, row.generatedAt);
1676+
if (schedule) parts.push(styler.fg(schedule.startsWith("overdue") ? "error" : "dim", schedule));
1677+
// Window spend, typed: fmtCost is the only money renderer, so a plan-covered trigger reads
1678+
// plan:<id> here too, never $0.00.
1679+
if (t.cost) parts.push(styler.fmtCost(t.cost));
16641680
const left = pre + kind + styler.stripAnsi(t.label ?? "") + " " + styler.fg("dim", G.arrowRight) + " " + (t.flow ?? "(no flow)") + (t.replicas ? styler.fg("warning", ` x${t.replicas}`) : "") + badges;
1665-
const right = styler.fg(statColor, stats);
1681+
const right = parts.join(styler.fg("dim", " · "));
16661682
return joinEnds(left, right, inner, styler);
16671683
}
16681684
if (row.kind === "gskill") {
@@ -1695,6 +1711,19 @@ function graphRowLine(row: any, cursor: boolean, inner: number, styler: any): st
16951711
return fitLine(pre, inner, styler);
16961712
}
16971713

1714+
/** A cron trigger's schedule phrase: "overdue 2h" from the model's own overdueMs, else "next 4h"
1715+
* counted from the model's generatedAt (the scheduler's `next` is ms in production and ISO in older
1716+
* fixtures -- both parse). Empty for non-cron triggers and for anything unparseable: a schedule badge
1717+
* that guesses is worse than none. */
1718+
function triggerScheduleText(t: any, generatedAt: any): string {
1719+
if (Number.isFinite(t?.overdueMs) && t.overdueMs > 0) return `overdue ${humanizeMs(t.overdueMs) || "<1m"}`;
1720+
const nextMs = typeof t?.next === "number" ? t.next : typeof t?.next === "string" ? Date.parse(t.next) : NaN;
1721+
if (!Number.isFinite(nextMs) || !Number.isFinite(generatedAt)) return "";
1722+
const inMs = nextMs - generatedAt;
1723+
if (inMs <= 0) return ""; // due-but-not-overdue noise; the overdue branch owns the alarm
1724+
return `next ${humanizeMs(inMs) || "<1m"}`;
1725+
}
1726+
16981727
/** The flag badges every graph row shares, colored by severity; the vocabulary is graph-model's. */
16991728
function graphBadges(flags: string[], styler: any): string {
17001729
const parts: string[] = [];

admin/src/graph-html.mjs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,17 @@ function normalizeModel(model) {
181181
skills: m.meta?.truncated?.skills === true,
182182
edges: m.meta?.truncated?.edges === true,
183183
},
184+
// The two honesty counters the text and TUI renderers always carried and this allowlist
185+
// dropped (issue #175): three surfaces of one model must not disagree about what was refused
186+
// or unreadable. Sorted and clipped like every other array on this page.
187+
chainRefusals: (m.meta?.chainRefusals && typeof m.meta.chainRefusals === "object" ? Object.entries(m.meta.chainRefusals) : [])
188+
.flatMap(([scope, count]) => (typeof scope === "string" && intOr(count, 0) > 0 ? [{ scope: clip(scope, 80), count: intOr(count, 0) }] : []))
189+
.sort((a, b) => cmpStr(a.scope, b.scope)),
190+
injectedUnreachable: (Array.isArray(m.meta?.injectedUnreachable) ? m.meta.injectedUnreachable : [])
191+
.filter((d) => typeof d === "string")
192+
.map((d) => clip(d, 120))
193+
.sort()
194+
.slice(0, 8),
184195
};
185196

186197
const folders = [];
@@ -248,6 +259,9 @@ function normalizeModel(model) {
248259
strong: e.strong === true,
249260
eligible: e.eligible === true,
250261
label: typeof e.label === "string" ? e.label : null,
262+
// Observed-edge recency (issue #175), the node-side lastEndedAt sanitizer: "chained 3 times,
263+
// last 2d ago" and "chained 3 times, months back" are different topologies to a reader.
264+
lastEndedAt: typeof e.lastEndedAt === "string" || Number.isFinite(e.lastEndedAt) ? e.lastEndedAt : null,
251265
});
252266
}
253267
edges.sort((a, b) => cmpStr(a.kind, b.kind) || cmpStr(a.from, b.from) || cmpStr(a.to, b.to) || cmpStr(a.label ?? "", b.label ?? "") || (a.count ?? -1) - (b.count ?? -1) || (a.strong ? 1 : 0) - (b.strong ? 1 : 0));
@@ -833,14 +847,17 @@ function wireStyle(w) {
833847
return { stroke: CHIP_FILL.cron, width: 2, dash: "4,3" }; // cron-rearm: dashed in the trigger's own hue
834848
}
835849

836-
function wireSvg(w) {
850+
function wireSvg(w, nowMs) {
837851
const s = wireStyle(w);
838852
const parts = [`<g class="gwire" id="${w.id}">`];
839853
parts.push(`<path d="${w.d}" fill="none" stroke="${s.stroke}" stroke-width="${fmt(s.width)}"${s.dash !== null ? ` stroke-dasharray="${s.dash}"` : ""}/>`);
840854
let label = null;
841855
let fill = PAGE_DIM;
842856
if (w.kind === "observed" && w.edge.count !== null) {
843-
label = `(${w.edge.count}×)`;
857+
// Recency beside the count when the fold recorded it: relTime against the injected instant,
858+
// so the byte-determinism guarantee holds -- same model + same now, same label.
859+
const ago = relTime(nowMs, w.edge.lastEndedAt);
860+
label = ago !== null ? `(${w.edge.count}× · ${ago})` : `(${w.edge.count}×)`;
844861
fill = WIRE_OBSERVED;
845862
} else if (w.kind === "potential") {
846863
label = "mention";
@@ -913,6 +930,10 @@ function legendHtml(norm) {
913930
if (norm.meta.truncated.skills) honesty.push("skill enumeration truncated or partly unread");
914931
if (norm.meta.truncated.edges) honesty.push("observed edges truncated (cap reached)");
915932
if (norm.meta.droppedObservedEdges > 0) honesty.push(`${norm.meta.droppedObservedEdges} observed edges dropped (no unique folder)`);
933+
// The counters the text and TUI surfaces already state (issue #175): the page joins them.
934+
const refused = norm.meta.chainRefusals.reduce((a, r) => a + r.count, 0);
935+
if (refused > 0) honesty.push(`${refused} chain requests refused (caps or gate)`);
936+
if (norm.meta.injectedUnreachable.length > 0) honesty.push(`injected skills dir unreadable: ${norm.meta.injectedUnreachable.join(", ")}`);
916937
for (const line of honesty) rows.push(`<div class="honesty">${escapeHtml(line)}</div>`);
917938
return `<div id="legend">${rows.join("")}</div>`;
918939
}
@@ -1198,7 +1219,7 @@ export function buildGraphHtml(model, { now, fullPaths } = {}) {
11981219
const svgBody = [
11991220
layout.groups.map((g) => groupSvg(g, fullPaths)).join(""),
12001221
layout.skillGroups.map(skillGroupSvg).join(""),
1201-
layout.wires.map(wireSvg).join(""),
1222+
layout.wires.map((w) => wireSvg(w, nowMs)).join(""),
12021223
nodeParts.join(""),
12031224
].join("");
12041225
const vb = layout.viewBox;

admin/src/graph-model.mjs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,14 +160,18 @@ export const GRAPH_FLAGS = Object.freeze([
160160
* Total function: absent or malformed inputs degrade to an empty-but-well-formed model, never a
161161
* throw (the read-model's viewer doctrine, one layer up).
162162
*/
163-
export function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, foldersTruncated, forgeRepos, cronStats, runJoin, chainEdges, caps, nowMs } = {}) {
163+
export function buildGraphModel({ triggers, schedulers, folderSkills, injectedSkills, foldersTruncated, forgeRepos, cronStats, runJoin, chainEdges, caps, nowMs, triggerCosts } = {}) {
164164
const triggerList = Array.isArray(triggers?.triggers) ? triggers.triggers : [];
165165
const schedulerList = Array.isArray(schedulers) ? schedulers : [];
166166
const folders = folderSkills && typeof folderSkills === "object" ? folderSkills : {};
167167
const injected = injectedSkills && typeof injectedSkills === "object" ? injectedSkills : {};
168168
const statsById = cronStats?.byId && typeof cronStats.byId === "object" ? cronStats.byId : {};
169169
const statsByIndex = runJoin?.byIndex && typeof runJoin.byIndex === "object" ? runJoin.byIndex : {};
170170
const observed = Array.isArray(chainEdges?.edges) ? chainEdges.edges : [];
171+
// The per-trigger spend map (costs.mjs foldTriggerCosts), keyed by this module's own node ids.
172+
// Spend is a node FACT like runs/lastOutcome -- no new edge kind, no new flag: the closed
173+
// vocabularies stay closed, and a fold that wires no costs simply grows no badges.
174+
const spendByNode = triggerCosts && typeof triggerCosts === "object" ? triggerCosts : {};
171175

172176
const model = {
173177
folders: [],
@@ -345,6 +349,9 @@ export function buildGraphModel({ triggers, schedulers, folderSkills, injectedSk
345349
lastEndedAt: stats?.lastEndedAt ?? null,
346350
next: sched?.next ?? null,
347351
overdueMs: sched?.overdueMs ?? null,
352+
// The window's typed spend for this trigger, or null when none was wired/attributed. Carried
353+
// whole (usd/class/floor) so every renderer keeps the fmtCost labeling discipline.
354+
cost: spendByNode[id]?.cost ?? null,
348355
};
349356
model.nodes.push(node);
350357
if (group) group.triggerIds.push(id);

admin/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ import { applyDeploymentPointer, pointerPath, readPointer, takePointerNotice } f
9898
// The only fs use in this module: the skew notice reads one package.json through the wizard's own reader.
9999
// Everything else fs-shaped goes through read-model.mjs by design.
100100
import * as nodeFs from "node:fs";
101-
import { COSTS_WINDOWS, costsSinceMs, foldCosts, whatIfFlow } from "./costs.mjs";
101+
import { COSTS_WINDOWS, costsSinceMs, foldCosts, foldTriggerCosts, whatIfFlow } from "./costs.mjs";
102102
// The REAL pricing façade. costs.mjs may not hold a module-scope worker/pricing import by contract (the
103103
// fold is pure; tests inject a canned fake) -- index.ts is where the fs-adjacent assembly lives, so the
104104
// injection happens here.
@@ -1063,6 +1063,9 @@ async function assembleGraph(paths: any): Promise<any> {
10631063
const schedulers: any = await readSchedulers({ url: paths.valkeyUrl });
10641064
const records: any = scanRunRecords({ logsDir: paths.logsDir, sinceMs: nowMs - GRAPH_LIMITS.windowDays * 24 * 60 * 60 * 1000, nowMs });
10651065
const recs: any[] = Array.isArray(records) ? records : [];
1066+
// Spend badges (issue #175): the dashboard seam's twin -- one subscriptions read, two pure folds.
1067+
const subsView: any = readSubscriptions({ subscriptionsPath: paths.subscriptionsPath });
1068+
const triggerJoin = attributeRunsToTriggers({ records: recs, triggers: triggerList });
10661069
return buildGraphModel({
10671070
triggers,
10681071
schedulers: Array.isArray(schedulers) ? schedulers : [],
@@ -1073,6 +1076,7 @@ async function assembleGraph(paths: any): Promise<any> {
10731076
forgeRepos: forgeRepoTargets({ records: recs }),
10741077
caps: { chainDepthMax: paths.chainDepthMax, chainMaxPerJob: paths.chainMaxPerJob, windowDays: GRAPH_LIMITS.windowDays },
10751078
nowMs,
1079+
triggerCosts: foldTriggerCosts({ records: recs, subscriptions: Array.isArray(subsView?.subscriptions) ? subsView.subscriptions : [], pricing: PRICING, triggerJoin }),
10761080
});
10771081
}
10781082

admin/src/render.mjs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,7 @@ export function renderGraph(model) {
427427
out.push("", groupHeading(group));
428428
for (const id of group.triggerIds ?? []) {
429429
const t = nodeById.get(id);
430-
if (t) out.push(` ${graphTriggerLine(t, flagsByNode.get(id) ?? [])}`);
430+
if (t) out.push(` ${graphTriggerLine(t, flagsByNode.get(id) ?? [], m.meta?.generatedAt ?? null)}`);
431431
}
432432
for (const id of group.skillIds ?? []) {
433433
const s = nodeById.get(id);
@@ -477,14 +477,34 @@ function groupHeading(group) {
477477
return `folder ${group.path ?? group.label}${head}${state}`;
478478
}
479479

480-
function graphTriggerLine(t, flags) {
481-
const stats = t.runs > 0 ? `runs ${t.runs}${t.lastOutcome ? `, last ${t.lastOutcome}` : ""}` : "no runs in window";
480+
function graphTriggerLine(t, flags, generatedAt = null) {
481+
// The schedule and spend facts join the stats parens (issue #175): overdue from the model's own
482+
// overdueMs, next as a countdown against generatedAt (never a clock -- this renderer is pure),
483+
// spend through fmtCost so a plan-covered trigger reads plan:<id> in text too.
484+
const bits = [t.runs > 0 ? `runs ${t.runs}${t.lastOutcome ? `, last ${t.lastOutcome}` : ""}` : "no runs in window"];
485+
if (Number.isFinite(t.overdueMs) && t.overdueMs > 0) bits.push(`overdue ${relDuration(t.overdueMs)}`);
486+
else {
487+
const nextMs = typeof t.next === "number" ? t.next : typeof t.next === "string" ? Date.parse(t.next) : NaN;
488+
if (Number.isFinite(nextMs) && Number.isFinite(generatedAt) && nextMs > generatedAt) bits.push(`next ${relDuration(nextMs - generatedAt)}`);
489+
}
490+
if (t.cost) bits.push(`spend ${fmtCost(t.cost)}`);
482491
const badges = [];
483492
if (flags.includes("no-skill")) badges.push("[no-skill: flow absent at HEAD]");
484493
if (flags.includes("charset-invalid")) badges.push("[invalid flow name: can never materialise]");
485494
if (flags.includes("pr-spend-loop-risk")) badges.push("[spend-loop risk: fires on opened/synchronize]");
486495
const replicas = t.replicas ? ` x${t.replicas}` : "";
487-
return `${t.onType} ${t.label} -> ${t.flow ?? "(no flow)"}${replicas} (${stats})${badges.length ? ` ${badges.join(" ")}` : ""}`;
496+
return `${t.onType} ${t.label} -> ${t.flow ?? "(no flow)"}${replicas} (${bits.join(", ")})${badges.length ? ` ${badges.join(" ")}` : ""}`;
497+
}
498+
499+
/** A coarse duration for the graph's schedule facts: minutes under an hour, else hours, else days. */
500+
function relDuration(ms) {
501+
if (!Number.isFinite(ms) || ms <= 0) return "<1m";
502+
const min = Math.floor(ms / 60000);
503+
if (min < 1) return "<1m";
504+
if (min < 60) return `${min}m`;
505+
const h = Math.floor(min / 60);
506+
if (h < 48) return `${h}h`;
507+
return `${Math.round(h / 24)}d`;
488508
}
489509

490510
function graphSkillLine(s, flags) {

admin/test/dashboard.test.mjs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1964,6 +1964,32 @@ async function openGraph(deps, width = 80) {
19641964
return comp;
19651965
}
19661966

1967+
test("GRAPH trigger rows say next/overdue and typed spend when the model carries them (issue #175)", async () => {
1968+
const model = CANNED_GRAPH_MODEL();
1969+
const cron = model.nodes.find((n) => n.id === "trigger:0");
1970+
cron.next = (model.meta.generatedAt ?? 0) + 4 * 3600_000;
1971+
cron.cost = { usd: 0, class: "plan", floor: false, planId: "kimi" };
1972+
const comp = await openGraph(graphDeps({ fetchGraph: async () => model }));
1973+
const out = stripAnsi(comp.render(100).join("\n"));
1974+
await comp.dispose();
1975+
assert.match(out, /next 4h/, "the countdown against the model's own generatedAt, never a live clock");
1976+
assert.match(out, /plan:kimi/, "spend through styler.fmtCost: a plan-covered trigger never reads $0.00");
1977+
assert.doesNotMatch(out, /\$0\.00/);
1978+
1979+
const overdueModel = CANNED_GRAPH_MODEL();
1980+
const c2 = overdueModel.nodes.find((n) => n.id === "trigger:0");
1981+
c2.overdueMs = 2 * 3600_000;
1982+
const comp2 = await openGraph(graphDeps({ fetchGraph: async () => overdueModel }));
1983+
const out2 = stripAnsi(comp2.render(100).join("\n"));
1984+
await comp2.dispose();
1985+
assert.match(out2, /overdue 2h/, "the money backstop's own signal, finally on the row");
1986+
1987+
const bare = await openGraph(graphDeps());
1988+
const out3 = stripAnsi(bare.render(100).join("\n"));
1989+
await bare.dispose();
1990+
assert.doesNotMatch(out3, /next |overdue /, "no scheduler match: no claim, not a guess");
1991+
});
1992+
19671993
test("'g' opens GRAPH, the footer advertises it unclipped at 80, and Esc pops one layer", async () => {
19681994
const deps = graphDeps();
19691995
const comp = makeDashboard({ paths: {}, done() {}, tui: fakeTui(), intervalMs: 100000, deps });

admin/test/graph-html.test.mjs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,14 +327,24 @@ test("orphan dash, potential-vs-observed labels, the caps digits, and honesty co
327327
// mention): those two chips plus the one legend swatch carry the disabled treatment, and the
328328
// exact count is the negative claim -- no non-orphan chip may wear it.
329329
assert.equal((out.match(/stroke-dasharray="8,3"/g) ?? []).length, 3, "two orphan chips and the one legend swatch, nothing else");
330-
assert.equal((out.match(/\(\d+×\)/g) ?? []).length, 1, "one observed edge, one count label; a potential wire NEVER carries a count");
330+
// The observed label carries the count and, when the fold recorded one, the recency (issue #175):
331+
// "chained 2 times, 2d ago" and "chained 2 times, months back" are different topologies to a reader.
332+
assert.equal((out.match(/\(\d+×( · \d+[smhd] ago)?\)/g) ?? []).length, 1, "one observed edge, one count label; a potential wire NEVER carries a count");
333+
assert.match(out, /\(\d+× · \d+[smhd] ago\)/, "the canned observed edge has a lastEndedAt, so its label says how fresh it is");
331334
assert.ok(out.includes(">mention</text>"), "a potential wire is labelled mention instead");
332335
assert.ok(out.includes("chains: depth ≤ 1 · ≤ 2 per job · same folder only · window 30d"), "the caps line renders the model's exact digits");
333336
assert.ok(out.includes("2 runs unattributed"), "honesty counters render when set");
334337

335338
const dropped = buildGraphModel(CANNED());
336339
dropped.meta.droppedObservedEdges = 4;
337340
assert.ok(buildGraphHtml(dropped, { now: NOW }).includes("4 observed edges dropped"), "dropped-edge counter renders when set");
341+
342+
// The two counters the text and TUI surfaces always stated and this page dropped (issue #175):
343+
// three surfaces of one model must not disagree about what was refused or unreadable.
344+
assert.ok(out.includes("1 chain requests refused (caps or gate)"), "the canned refusals reach the legend");
345+
const unreadable = buildGraphModel(CANNED());
346+
unreadable.meta.injectedUnreachable = ["/inj"];
347+
assert.ok(buildGraphHtml(unreadable, { now: NOW }).includes("injected skills dir unreadable: /inj"), "the unreadable-dir counter renders when set");
338348
});
339349

340350
// ---- 10. refresh features ----

0 commit comments

Comments
 (0)