Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 52 additions & 3 deletions admin/src/costs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,9 @@ export function foldCosts({ records, subscriptions, pricing, nowMs, piAiPin = nu
// gap-day discipline covers interior quiet days, but a month of leading zeros on a young
// deployment would compress the visible history into the last few cells.
daily: buildDaily(runs, firstRunMs ?? fromMs, toMs),
// The per-flow daily series shares daily's origin and span, so the flow multiples and the
// global columns describe the same days (issue #181).
dailyByFlow: buildDailyByFlow(runs, firstRunMs ?? fromMs, toMs),
byFlow: buildByFlow(runs, subs, pricing),
byModel: buildByModel(runs),
// null, not [], without a join: "not computed" and "nothing attributed" are different sentences,
Expand Down Expand Up @@ -296,12 +299,58 @@ function buildDaily(runs, fromMs, toMs) {
return daily;
}

/** Per-flow rollup, sorted by metered cost descending. A null flow reads "(no flow)" -- a display
* label, not an id, so it can never collide with a real skill-charset flow name. */
/** One label rule for both flow folds: a null/empty flow reads "(no flow)" -- a display label, not
* an id, so it can never collide with a real skill-charset flow name. Extracted so buildByFlow and
* buildDailyByFlow cannot drift on what a flow is called. */
function flowLabelOf(record) {
return typeof record.flow === "string" && record.flow !== "" ? record.flow : "(no flow)";
}

/**
* Per-flow DAILY series (issue #181, the line charts): the same composite facts buildDaily and
* buildByFlow fold separately, folded together -- Map<flow, Map<day, contribs>> over the one sorted
* runs pass. Every flow is gap-padded over the SAME span (the shared cursor loop), because small
* multiples are only comparable when they share an x-domain; a flow's quiet day is a zero-run entry
* exactly as the global series keeps it. Rows sort by window total descending then label, the
* byFlow comparator applied to the sum, so "top N flows" means the same flows in both tables.
*/
function buildDailyByFlow(runs, fromMs, toMs) {
if (runs.length === 0) return [];
const byFlow = new Map();
for (const r of runs) {
const flow = flowLabelOf(r.record);
if (!byFlow.has(flow)) byFlow.set(flow, new Map());
const days = byFlow.get(flow);
const day = utcDay(r.at);
if (!days.has(day)) days.set(day, []);
days.get(day).push(r.contribution);
}
const rows = [];
for (const [flow, buckets] of byFlow) {
const days = [];
for (let cursor = Math.floor(fromMs / DAY_MS) * DAY_MS; cursor <= toMs; cursor += DAY_MS) {
const day = utcDay(cursor);
const contribs = buckets.get(day) ?? [];
days.push({ day, cost: combineContributions(contribs), runs: contribs.length });
}
rows.push({
flow,
// The machine key beside the display label -- the byFlow lesson (issue #175) held here too.
flowKey: flow === "(no flow)" ? null : flow,
days,
});
}
return rows.sort((a, b) => {
const sum = (row) => row.days.reduce((acc, d) => acc + d.cost.usd, 0);
return sum(b) - sum(a) || a.flow.localeCompare(b.flow);
});
}

/** Per-flow rollup, sorted by metered cost descending. The label rule is flowLabelOf's. */
function buildByFlow(runs, subs, pricing) {
const groups = new Map();
for (const r of runs) {
const flow = typeof r.record.flow === "string" && r.record.flow !== "" ? r.record.flow : "(no flow)";
const flow = flowLabelOf(r.record);
if (!groups.has(flow)) groups.set(flow, []);
groups.get(flow).push(r);
}
Expand Down
59 changes: 57 additions & 2 deletions admin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ import {
import { buildGraphModel } from "./graph-model.mjs";
import { buildInsightsHtml } from "./insights-html.mjs";
import { openBrowser } from "@edgehero/pi-dispatch/open-browser";
// The worker's OWN window classifier (the same one reserveBudget enforces), so the budget states the
// insights payload carries are words the page never derives and the panel and enforcement cannot drift.
import { windowState } from "@edgehero/pi-dispatch/budget";
// The deployment pointer (INT-DEPLOYMENT-POINTER-CONTRACT): the wizard-written file that aims this
// extension at a deployment built in another directory. Layered into process.env once at factory load
// (the operator's env always wins), so resolvePaths stays env-only by contract while every one of its
Expand Down Expand Up @@ -1094,11 +1097,63 @@ const INSIGHTS_USAGE =
* window -- the artifact states both windows, and a badge whose window differed from the table
* beside it would be the quiet inconsistency this surface exists to kill.
*/
/** A positive integer or null -- the shape every overlay cap takes on its way into the payload. */
function posIntOrNull(v: any): number | null {
return Number.isInteger(v) && v > 0 ? v : null;
}

/**
* The budget slice of the insights payload (issue #181): reserved-vs-cap FACTS for the three job-slot
* windows and the daily token counter, with the per-window state computed HERE by the worker's own
* `windowState` (and the token rule the old dashboard token line used: `>=` cap, `>` the soft-hold
* floor -- tokens are a running count, not a reservation, so equality is already over). States ride
* the payload as WORDS the page never derives: the artifact builder cannot load the worker
* (its import allowlist is the two pure siblings), and duplicating threshold arithmetic behind a
* parity test would put policy in two places. Caps come from the settings overlay ALONE -- a cap the
* overlay does not set is null, "unknown", because the worker resolves it from env/defaults this
* process cannot read authoritatively, and the page must render that as absence, never a guess.
* `readBudget` is GET-only (CONST-BUDGET-BEFORE-TOKENS: observing the budget spends nothing).
*/
async function assembleBudgetView(paths: any): Promise<any> {
const raw: any = await readBudget({ url: paths.valkeyUrl });
const view: any = readSettingsView({ settingsFile: paths.settingsFile });
const overlay = view && !view.invalid && view.overlay ? view.overlay : {};
const pct = Number.isInteger(overlay.softHoldPct) ? overlay.softHoldPct : null;
const unreachable = raw?.unreachable ? String(raw.unreachable) : null;
const slotWindow = (key: string, capKey: string) => {
const cap = posIntOrNull(overlay[capKey]);
const reserved = unreachable === null ? Number(raw[key] ?? 0) : null;
const state = cap !== null && reserved !== null ? windowState(reserved, cap, pct) : "ok";
return { reserved, cap, state };
};
const tokensSpent = unreachable === null ? Number(raw.tokensToday ?? 0) : null;
const tokenCap = posIntOrNull(overlay.dailyTokenCap);
const tokenState =
tokenCap !== null && tokensSpent !== null
? tokensSpent >= tokenCap
? "over"
: pct !== null && tokensSpent > Math.floor((tokenCap * pct) / 100)
? "soft-hold"
: "ok"
: "ok";
return {
unreachable,
softHoldPct: pct,
day: slotWindow("day", "dailyCap"),
week: slotWindow("week", "weeklyCap"),
month: slotWindow("month", "monthlyCap"),
tokens: { spent: tokensSpent, cap: tokenCap, state: tokenState, maxTokens: posIntOrNull(overlay.maxTokens) },
};
}

async function assembleInsights(paths: any, window: string): Promise<any> {
const graph = await assembleGraph(paths);
const costs = assembleCosts(paths, window);
// The budget slice rides BOTH return shapes: the caps are the operator's one real lever on cost,
// and the lever does not depend on the spend scan being readable.
const budget = await assembleBudgetView(paths);
if (costs?.unreachable) {
return { graph, fold: null, costsUnreachable: String(costs.unreachable), window, costByTrigger: null };
return { graph, fold: null, costsUnreachable: String(costs.unreachable), window, costByTrigger: null, budget };
}
const fold = costs?.fold ?? null;
// Re-fold the spend map at the requested window so badge and table agree. assembleCosts already
Expand All @@ -1113,7 +1168,7 @@ async function assembleInsights(paths: any, window: string): Promise<any> {
const triggerJoin = attributeRunsToTriggers({ records, triggers: Array.isArray(triggersView?.triggers) ? triggersView.triggers : [] });
costByTrigger = foldTriggerCosts({ records, subscriptions: Array.isArray(subsView?.subscriptions) ? subsView.subscriptions : [], pricing: PRICING, triggerJoin });
}
return { graph, fold, costsUnreachable: null, window, costByTrigger };
return { graph, fold, costsUnreachable: null, window, costByTrigger, budget };
}

/**
Expand Down
Loading
Loading