faberun 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- package/src/web/server.mjs +552 -0
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { validateContract } from "../contract/index.mjs";
|
|
4
|
+
import { readJson, writeJsonAtomic } from "../run/store.mjs";
|
|
5
|
+
import { lockStale, pidAlive, readLock } from "../run/lock.mjs";
|
|
6
|
+
import { scopeFindingsNote } from "../contract/scope-findings.mjs";
|
|
7
|
+
import { reviewNote } from "../contract/review-modes.mjs";
|
|
8
|
+
import { validateNodeSnapshot, validateRunMetadata } from "../contract/snapshot.mjs";
|
|
9
|
+
import { compactCost, compactTokens, finite, truncateChars } from "../util.mjs";
|
|
10
|
+
import { listNodeSnapshots, nodeSnapshotPath } from "../run/node-store.mjs";
|
|
11
|
+
import { readHeartbeat } from "../engine/supervise.mjs";
|
|
12
|
+
import { SETTLED, SUCCESS } from "../engine/prompts.mjs";
|
|
13
|
+
|
|
14
|
+
/** Advisory ceiling for status.json (TECH-SPEC lean, rule 5); never enforced destructively. */
|
|
15
|
+
const STATUS_JSON_MAX_BYTES = 200 * 1024;
|
|
16
|
+
const STATUS_POINTER_FILE = "status.json";
|
|
17
|
+
const STATUS_POINTER_MAX_BYTES = 1024;
|
|
18
|
+
const POINTER_STRING_CHARS = 64;
|
|
19
|
+
const POINTER_ATTENTION_CHARS = 80;
|
|
20
|
+
|
|
21
|
+
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
22
|
+
/** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
|
|
23
|
+
/** @typedef {import("../contract/index.mjs").NodeStatus} NodeStatus */
|
|
24
|
+
/** @typedef {Record<string, unknown>} JsonObject */
|
|
25
|
+
/** @typedef {{inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}} StatusPayloadUsage */
|
|
26
|
+
/** @typedef {{id: string, status: NodeStatus, phase: string|null, executionPhase: string|null, runtime: string|null, workerRuntime: string|null, continuation: string, attempt: number, revisions: number, startedAt: string|null, updatedAt: string|null, usage: StatusPayloadUsage|null, costUsd: number|null, verdict: string|null, pendingHandoff: {runtime: string, reason: string}|null, note: string|null, scopeFindings: string[]|null, errorCode: string|null, blockedBy: string[]}} StatusPayloadNode */
|
|
27
|
+
/** @typedef {{schemaVersion: 1, run: string, contractId: string, campaignId: string, goal: string, usage: {inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null}, controller: JsonObject, identityWarnings: string[], summary: string, nodes: StatusPayloadNode[]}} StatusPayload */
|
|
28
|
+
|
|
29
|
+
/** The glyph each terminal state prints in a status table. */
|
|
30
|
+
export const MARK = {
|
|
31
|
+
pending: "[ ]",
|
|
32
|
+
running: "[>]",
|
|
33
|
+
done: "[+]",
|
|
34
|
+
"no-op": "[.]",
|
|
35
|
+
blocked: "[!]",
|
|
36
|
+
failed: "[x]",
|
|
37
|
+
exhausted: "[$]",
|
|
38
|
+
stalled: "[~]",
|
|
39
|
+
canceled: "[/]",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `status <run-dir>`: everything the operator needs, in the same order as
|
|
44
|
+
* the dashboard page (TECH-SPEC lean, section 4's last paragraph) —
|
|
45
|
+
* needs-you, now, nodes, cost. Every cell comes from the same payload
|
|
46
|
+
* `status --json` and the per-run `status.json` file emit
|
|
47
|
+
* (`buildStatusPayload`); `nodes` and `identityWarnings` from `loadRun` are
|
|
48
|
+
* used only for the two facts the payload does not carry: throwing on an
|
|
49
|
+
* invalid persisted snapshot, and `controllerStatus`'s lock read.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} runDir
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
export function renderStatus(runDir) {
|
|
55
|
+
const { contract, nodes, identityWarnings } = loadRun(runDir);
|
|
56
|
+
const usage = readRunUsage(runDir);
|
|
57
|
+
const payload = buildStatusPayload(runDir, contract, nodes, identityWarnings, usage);
|
|
58
|
+
const controller = controllerStatus(runDir, nodes);
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
|
|
61
|
+
const lines = [`# run ${payload.run}`, "", payload.goal, "", `controller: ${controller.line}`, ""];
|
|
62
|
+
|
|
63
|
+
lines.push("## Needs you", "");
|
|
64
|
+
const attention = payload.nodes.filter((node) => !["pending", "running", "done", "no-op"].includes(node.status));
|
|
65
|
+
const orphans = controller.status.state !== "active" ? payload.nodes.filter((node) => node.status === "running").map((node) => node.id) : [];
|
|
66
|
+
if (!attention.length && !orphans.length && !identityWarnings.length) lines.push("Nothing needs you right now.");
|
|
67
|
+
if (orphans.length) lines.push(`- [>] the run process is gone while ${orphans.join(", ")} still claims to be running. Those nodes are orphans, not live work. Resume the run directory to adopt whatever their workers finished.`);
|
|
68
|
+
for (const warning of identityWarnings) lines.push(`- [~] ${warning}`);
|
|
69
|
+
for (const node of attention) lines.push(`- ${MARK[node.status] ?? "[?]"} ${node.id}: ${node.note ?? node.status}`);
|
|
70
|
+
lines.push("", "## Now", "", nowLine(payload, now), "", "## Nodes", "");
|
|
71
|
+
|
|
72
|
+
const widths = [3, 24, 9, 3, 28, 8, 6, 6, 6, 10, 9, MAX_NOTE_LENGTH];
|
|
73
|
+
/** @type {(cells: unknown[]) => string} */
|
|
74
|
+
const row = (cells) => cells.map((cell, i) => fit(String(cell ?? ""), widths[i])).join(" ");
|
|
75
|
+
lines.push("```", row(["", "NODE", "STATE", "TRY", "RUNTIME", "ELAPSED", "IN", "CACHE", "OUT", "USD", "VERDICT", "NOTE"]), row(widths.map((width) => "-".repeat(width))));
|
|
76
|
+
for (const node of payload.nodes) {
|
|
77
|
+
lines.push(row([
|
|
78
|
+
MARK[node.status] ?? "[?]",
|
|
79
|
+
node.id,
|
|
80
|
+
node.status,
|
|
81
|
+
node.attempt ?? 0,
|
|
82
|
+
node.workerRuntime ?? node.runtime ?? "-",
|
|
83
|
+
formatElapsed(node, now),
|
|
84
|
+
compactTokens(node.usage?.inputTokens),
|
|
85
|
+
compactTokens(node.usage?.cacheReadInputTokens),
|
|
86
|
+
compactTokens(node.usage?.outputTokens),
|
|
87
|
+
compactCost(node.costUsd),
|
|
88
|
+
node.verdict ?? "-",
|
|
89
|
+
node.note ?? "-",
|
|
90
|
+
]));
|
|
91
|
+
}
|
|
92
|
+
lines.push("```", "", "## Cost", "", `in ${compactTokens(usage.inputTokens)} · out ${compactTokens(usage.outputTokens)} · cache ${compactTokens(usage.cacheReadInputTokens)} · cost ${compactCost(usage.costUsd)}`);
|
|
93
|
+
return `${lines.join("\n")}\n`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The node the operator should look at right now, formatted the same way
|
|
98
|
+
* the dashboard's now strip is (TECH-SPEC lean, section 4, item 1): the
|
|
99
|
+
* active node's elapsed time and cost so far, or an idle line once every
|
|
100
|
+
* node has settled.
|
|
101
|
+
*
|
|
102
|
+
* @param {StatusPayload} payload
|
|
103
|
+
* @param {number} now epoch ms
|
|
104
|
+
* @returns {string}
|
|
105
|
+
*/
|
|
106
|
+
function nowLine(payload, now) {
|
|
107
|
+
const active = activeStatusNode(payload.nodes);
|
|
108
|
+
if (active) return `now: ${active.id} ${active.status} (${formatElapsed(active, now)}) · ${active.runtime ?? "-"} · ${compactCost(active.costUsd)}`;
|
|
109
|
+
const allTerminal = payload.nodes.every((node) => SUCCESS.has(node.status));
|
|
110
|
+
return allTerminal ? `now: idle · run done · ${compactCost(payload.usage.costUsd)}` : "now: idle";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A node's wall-clock elapsed time: `startedAt` to `updatedAt` once it has
|
|
115
|
+
* settled into a terminal state, `startedAt` to `now` while it is still
|
|
116
|
+
* live (running, blocked or stalled), `-` before it ever started.
|
|
117
|
+
*
|
|
118
|
+
* @param {{status: string, startedAt: string|null, updatedAt: string|null}} node
|
|
119
|
+
* @param {number} now epoch ms
|
|
120
|
+
* @returns {string}
|
|
121
|
+
*/
|
|
122
|
+
function formatElapsed(node, now) {
|
|
123
|
+
if (!node.startedAt) return "-";
|
|
124
|
+
const start = Date.parse(node.startedAt);
|
|
125
|
+
if (!Number.isFinite(start)) return "-";
|
|
126
|
+
const terminal = SETTLED.has(node.status);
|
|
127
|
+
const end = terminal && node.updatedAt ? Date.parse(node.updatedAt) : now;
|
|
128
|
+
return formatDuration(Math.max(0, (Number.isFinite(end) ? end : now) - start));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** @param {number} ms @returns {string} */
|
|
132
|
+
function formatDuration(ms) {
|
|
133
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
134
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
135
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
136
|
+
const seconds = totalSeconds % 60;
|
|
137
|
+
if (hours > 0) return `${hours}h${String(minutes).padStart(2, "0")}m`;
|
|
138
|
+
if (minutes > 0) return `${minutes}m${String(seconds).padStart(2, "0")}s`;
|
|
139
|
+
return `${seconds}s`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* JSON status for `status --json`: stable, machine-readable, no rendering.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} runDir
|
|
146
|
+
* @returns {string}
|
|
147
|
+
*/
|
|
148
|
+
export function renderStatusJson(runDir) {
|
|
149
|
+
const { contract, nodes, identityWarnings } = loadRun(runDir);
|
|
150
|
+
const usage = readRunUsage(runDir);
|
|
151
|
+
const payload = buildStatusPayload(runDir, contract, nodes, identityWarnings, usage);
|
|
152
|
+
return `${JSON.stringify(payload, null, 2)}\n`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The status.json payload shared by the CLI (`status --json`, reading from
|
|
157
|
+
* disk) and the controller's per-tick writer (in-memory node states): every
|
|
158
|
+
* field is a durable fact, never a rendering choice.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} runDir
|
|
161
|
+
* @param {ValidatedContract} contract
|
|
162
|
+
* @param {NodeSnapshot[]} nodes
|
|
163
|
+
* @param {string[]} identityWarnings
|
|
164
|
+
* @param {{inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null}} usage
|
|
165
|
+
* @returns {StatusPayload}
|
|
166
|
+
*/
|
|
167
|
+
function buildStatusPayload(runDir, contract, nodes, identityWarnings, usage) {
|
|
168
|
+
const counts = new Map();
|
|
169
|
+
for (const node of nodes) counts.set(node.status, (counts.get(node.status) ?? 0) + 1);
|
|
170
|
+
return {
|
|
171
|
+
schemaVersion: 1,
|
|
172
|
+
run: basename(runDir),
|
|
173
|
+
contractId: contract.id,
|
|
174
|
+
campaignId: contract.campaignId,
|
|
175
|
+
goal: contract.goal,
|
|
176
|
+
usage: {
|
|
177
|
+
inputTokens: usage.inputTokens,
|
|
178
|
+
outputTokens: usage.outputTokens,
|
|
179
|
+
cacheReadInputTokens: usage.cacheReadInputTokens,
|
|
180
|
+
costUsd: usage.costUsd,
|
|
181
|
+
},
|
|
182
|
+
controller: controllerStatus(runDir, nodes).status,
|
|
183
|
+
identityWarnings,
|
|
184
|
+
summary: [...counts].map(([status, count]) => `${count} ${status}`).join(" · "),
|
|
185
|
+
nodes: nodes.map((node) => ({
|
|
186
|
+
id: node.id,
|
|
187
|
+
status: node.status,
|
|
188
|
+
phase: contract.nodes.find((candidate) => candidate.id === node.id)?.phase ?? null,
|
|
189
|
+
executionPhase: node.phase,
|
|
190
|
+
runtime: node.runtime ? `${node.runtime.harness}/${node.runtime.model}` : null,
|
|
191
|
+
workerRuntime: workerRuntimeLabel(node),
|
|
192
|
+
continuation: continuationMode(node),
|
|
193
|
+
attempt: node.attempt,
|
|
194
|
+
revisions: node.revisions,
|
|
195
|
+
startedAt: node.startedAt ?? null,
|
|
196
|
+
updatedAt: node.updatedAt ?? null,
|
|
197
|
+
usage: node.usage ? { inputTokens: node.usage.inputTokens ?? null, outputTokens: node.usage.outputTokens ?? null, cacheReadInputTokens: node.usage.cacheReadInputTokens ?? null } : null,
|
|
198
|
+
costUsd: typeof node.costUsd === "number" ? node.costUsd : null,
|
|
199
|
+
verdict: node.gate?.verdict ?? null,
|
|
200
|
+
pendingHandoff: pendingHandoff(node),
|
|
201
|
+
note: statusNote(node),
|
|
202
|
+
scopeFindings: node.scopeFindings?.unexpectedPaths ?? null,
|
|
203
|
+
errorCode: node.error?.code ?? null,
|
|
204
|
+
blockedBy: node.blockedBy ?? [],
|
|
205
|
+
})),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The node the operator should look at right now: the first node that is
|
|
211
|
+
* running, else the first in an attention state, else null. Generic over the
|
|
212
|
+
* node shape so both the raw `NodeSnapshot[]` (`derivePointer`) and the
|
|
213
|
+
* `status.json` payload's nodes (`nowLine`) share this one rule.
|
|
214
|
+
*
|
|
215
|
+
* @template {{status: NodeStatus}} T
|
|
216
|
+
* @param {T[]} nodes
|
|
217
|
+
* @returns {T|null}
|
|
218
|
+
*/
|
|
219
|
+
function activeStatusNode(nodes) {
|
|
220
|
+
return nodes.find((node) => node.status === "running")
|
|
221
|
+
?? nodes.find((node) => !["pending", "running", "done", "no-op"].includes(node.status))
|
|
222
|
+
?? null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The bounded pointer record written to `.runs/status.json`: enough for a
|
|
227
|
+
* quick ambient read (statusline, a stale watcher) without opening the
|
|
228
|
+
* per-run status.json. Bounded the same way heartbeat.json used to be, so a
|
|
229
|
+
* cheap bounded read stays valid for any reader still built that way.
|
|
230
|
+
* `generatedAt` is unix seconds, not ISO: the statusline's no-jq fallback has
|
|
231
|
+
* no clock and only jq's builtin `now` can compute an age from a live clock,
|
|
232
|
+
* and neither path needs a `date` process to read an integer. `elapsedSec`
|
|
233
|
+
* is likewise precomputed here (as of `generatedAt`, not live) so the
|
|
234
|
+
* statusline never has to subtract two timestamps to show it — it just
|
|
235
|
+
* prints the integer, whichever reader it is.
|
|
236
|
+
*
|
|
237
|
+
* @param {JsonObject} payload the per-run status.json payload
|
|
238
|
+
* @param {NodeSnapshot[]} nodes
|
|
239
|
+
* @param {number} generatedAt unix seconds
|
|
240
|
+
* @returns {JsonObject}
|
|
241
|
+
*/
|
|
242
|
+
function derivePointer(payload, nodes, generatedAt) {
|
|
243
|
+
const active = activeStatusNode(nodes);
|
|
244
|
+
const done = nodes.filter((node) => node.status === "done" || node.status === "no-op").length;
|
|
245
|
+
const attentionNodes = nodes.filter((node) => !["pending", "running", "done", "no-op"].includes(node.status));
|
|
246
|
+
const attentionNode = attentionNodes[0] ?? null;
|
|
247
|
+
const state = attentionNode ? "attention" : nodes.every((node) => SUCCESS.has(node.status)) ? "done" : "active";
|
|
248
|
+
const activeStartedAt = active?.startedAt ? Math.floor(Date.parse(active.startedAt) / 1000) : null;
|
|
249
|
+
const usage = /** @type {{costUsd: number|null}} */ (payload.usage);
|
|
250
|
+
const pointer = {
|
|
251
|
+
schemaVersion: 1,
|
|
252
|
+
runId: payload.run,
|
|
253
|
+
campaignId: payload.campaignId,
|
|
254
|
+
state,
|
|
255
|
+
checkpoints: { done, total: nodes.length },
|
|
256
|
+
activeNode: active ? truncateChars(active.id, POINTER_STRING_CHARS) : null,
|
|
257
|
+
runtime: active?.runtime ? truncateChars(`${active.runtime.harness}/${active.runtime.model}`, POINTER_STRING_CHARS) : null,
|
|
258
|
+
elapsedSec: activeStartedAt !== null && Number.isFinite(activeStartedAt) ? Math.max(0, generatedAt - activeStartedAt) : null,
|
|
259
|
+
costUsd: typeof usage.costUsd === "number" ? Math.round(usage.costUsd * 100) / 100 : null,
|
|
260
|
+
needsYou: attentionNodes.length,
|
|
261
|
+
attention: attentionNode ? truncateChars(statusNote(attentionNode) ?? attentionNode.status, POINTER_ATTENTION_CHARS) : null,
|
|
262
|
+
generatedAt,
|
|
263
|
+
};
|
|
264
|
+
return pointer;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Write status.json for one run (bounded advisory ceiling) and the
|
|
269
|
+
* `.runs/status.json` pointer to it (bounded to 1 KiB, mirroring the old
|
|
270
|
+
* heartbeat.json contract), atomically. Called each controller tick and at
|
|
271
|
+
* run terminal (TECH-SPEC lean, rule 5).
|
|
272
|
+
*
|
|
273
|
+
* @param {string} runDir
|
|
274
|
+
* @param {string} runsDir
|
|
275
|
+
* @param {ValidatedContract} contract
|
|
276
|
+
* @param {Map<string, NodeSnapshot>} states
|
|
277
|
+
*/
|
|
278
|
+
export function writeStatusArtifacts(runDir, runsDir, contract, states) {
|
|
279
|
+
const nodes = /** @type {NodeSnapshot[]} */ (contract.nodes.map((node) => states.get(node.id)).filter((node) => node !== undefined));
|
|
280
|
+
const runMetadata = /** @type {{identityWarnings?: string[]}} */ (readJson(join(runDir, "run.json")) ?? {});
|
|
281
|
+
const usage = readRunUsage(runDir);
|
|
282
|
+
const payload = buildStatusPayload(runDir, contract, nodes, runMetadata.identityWarnings ?? [], usage);
|
|
283
|
+
const serialized = JSON.stringify(payload);
|
|
284
|
+
if (Buffer.byteLength(serialized, "utf8") > STATUS_JSON_MAX_BYTES) {
|
|
285
|
+
process.stderr.write(`[warn] status.json for ${basename(runDir)} exceeds the ${STATUS_JSON_MAX_BYTES}-byte advisory ceiling\n`);
|
|
286
|
+
}
|
|
287
|
+
writeJsonAtomic(join(runDir, STATUS_POINTER_FILE), payload);
|
|
288
|
+
const pointer = derivePointer(payload, nodes, Math.floor(Date.now() / 1000));
|
|
289
|
+
const pointerSerialized = JSON.stringify(pointer);
|
|
290
|
+
if (Buffer.byteLength(pointerSerialized, "utf8") <= STATUS_POINTER_MAX_BYTES) {
|
|
291
|
+
writeJsonAtomic(join(runsDir, STATUS_POINTER_FILE), pointer);
|
|
292
|
+
} else {
|
|
293
|
+
process.stderr.write(`[warn] .runs/status.json pointer for ${basename(runDir)} exceeds ${STATUS_POINTER_MAX_BYTES} bytes; left unwritten\n`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The controller line: `active pid N since T` for a live lock, or
|
|
299
|
+
* `stale pid N (dead|restarted) last tick T` once its holder is proven dead
|
|
300
|
+
* or the pid was recycled — `T` is then the newest node update, since the
|
|
301
|
+
* dead controller's own lock carries no useful clock. No lock at all (a run
|
|
302
|
+
* that never started, or one that shut down cleanly) reports `none`.
|
|
303
|
+
*
|
|
304
|
+
* @param {string} runDir
|
|
305
|
+
* @param {NodeSnapshot[]} nodes
|
|
306
|
+
* @returns {{line: string, status: {state: "active"|"stale"|"none", pid: number|null, since: string|null, lastTick: string|null}}}
|
|
307
|
+
*/
|
|
308
|
+
export function controllerStatus(runDir, nodes) {
|
|
309
|
+
const lock = readLock(runDir);
|
|
310
|
+
// The heartbeat's `at` is the live tick, so a working controller and a dead
|
|
311
|
+
// one are distinguishable on disk. Before this it was hard-coded null here
|
|
312
|
+
// and computed from node updates only once the lock was already stale, which
|
|
313
|
+
// is why all 47 recorded status.json files reported a null lastTick.
|
|
314
|
+
const heartbeat = readHeartbeat(runDir);
|
|
315
|
+
const lastTick = typeof heartbeat?.at === "string" ? heartbeat.at : null;
|
|
316
|
+
if (!lock || /** @type {{invalid?: true}} */ (lock).invalid) {
|
|
317
|
+
return { line: "none", status: { state: "none", pid: null, since: null, lastTick } };
|
|
318
|
+
}
|
|
319
|
+
const record = /** @type {import("../run/lock.mjs").LockRecord} */ (lock);
|
|
320
|
+
if (!lockStale(record)) {
|
|
321
|
+
return {
|
|
322
|
+
line: `active pid ${record.pid} since ${record.startedAt}`,
|
|
323
|
+
status: { state: "active", pid: record.pid, since: record.startedAt, lastTick },
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
const staleTick = lastTick ?? (nodes.reduce((latest, node) => (node.updatedAt && node.updatedAt > latest ? node.updatedAt : latest), "") || null);
|
|
327
|
+
const reason = pidAlive(record.pid) ? "restarted" : "dead";
|
|
328
|
+
return {
|
|
329
|
+
line: `stale pid ${record.pid} (${reason}) last tick ${staleTick ?? "-"}`,
|
|
330
|
+
status: { state: "stale", pid: record.pid, since: record.startedAt, lastTick: staleTick },
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* @param {string} runDir
|
|
336
|
+
* @returns {string}
|
|
337
|
+
*/
|
|
338
|
+
export function renderReport(runDir) {
|
|
339
|
+
const { contract, nodes } = loadRun(runDir);
|
|
340
|
+
const usage = readRunUsage(runDir);
|
|
341
|
+
const counts = new Map();
|
|
342
|
+
for (const node of nodes) counts.set(node.status, (counts.get(node.status) ?? 0) + 1);
|
|
343
|
+
const summary = [...counts].map(([status, count]) => `${count} ${status}`).join(" · ");
|
|
344
|
+
const widths = [3, 24, 9, 7, 7, 28, 10, 10, 10, 20, 64];
|
|
345
|
+
/** @type {(cells: unknown[]) => string} */
|
|
346
|
+
const row = (cells) => cells.map((cell, i) => fit(String(cell ?? ""), widths[i])).join(" ");
|
|
347
|
+
/** @type {import("../contract/index.mjs").Usage & {costUsd: number|null}} */
|
|
348
|
+
const totals = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, costUsd: null };
|
|
349
|
+
const costs = nodes.map(costProjection);
|
|
350
|
+
const aggregateCost = aggregateCostProjection(costs);
|
|
351
|
+
const lines = [`# run ${basename(runDir)}`, "", `${nodes.length} nodes · ${summary} · in ${compactTokens(usage.inputTokens)} · out ${compactTokens(usage.outputTokens)} · cache ${compactTokens(usage.cacheReadInputTokens)} · cost ${compactCost(usage.costUsd)}`, "", "```", row(["", "NODE", "STATE", "TRY", "REV", "RUNTIME", "IN", "OUT", "CACHE", "COST", "NOTE"]), row(widths.map((width) => "-".repeat(width)))];
|
|
352
|
+
for (const [index, node] of nodes.entries()) {
|
|
353
|
+
const usage = node.usage ?? { inputTokens: null, outputTokens: null, cacheReadInputTokens: null };
|
|
354
|
+
for (const key of /** @type {("inputTokens"|"outputTokens"|"cacheReadInputTokens")[]} */ (Object.keys(totals).filter((key) => key !== "costUsd"))) totals[key] = (totals[key] ?? 0) + (usage[key] ?? 0);
|
|
355
|
+
const cost = costs[index];
|
|
356
|
+
const runtime = workerRuntimeLabel(node) ?? "-";
|
|
357
|
+
const planNode = contract.nodes.find((candidate) => candidate.id === node.id);
|
|
358
|
+
const note = scopeFindingsNote(node.scopeFindings)
|
|
359
|
+
? nodeNote(node)
|
|
360
|
+
: `phase ${planNode?.phase ?? "-"} · ${continuationMode(node)} · ${nodeNote(node)}`;
|
|
361
|
+
lines.push(row([MARK[node.status] ?? "[?]", node.id, node.status, node.attempt ?? 0, node.revisions ?? 0, runtime, compactTokens(usage.inputTokens), compactTokens(usage.outputTokens), compactTokens(usage.cacheReadInputTokens), formatCost(cost), note]));
|
|
362
|
+
}
|
|
363
|
+
totals.costUsd = aggregateCost.costUsd;
|
|
364
|
+
const roles = roleCosts(nodes);
|
|
365
|
+
lines.push("```", "", `totals · in ${compactTokens(totals.inputTokens)} · out ${compactTokens(totals.outputTokens)} · cache ${compactTokens(totals.cacheReadInputTokens)} · worker ${compactCost(roles.worker)} · judge ${compactCost(roles.judge)} · cost ${formatCost(aggregateCost)}`);
|
|
366
|
+
return `${lines.join("\n")}\n`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* JSON report for `report --json`: totals and per-node usage, no rendering.
|
|
371
|
+
*
|
|
372
|
+
* @param {string} runDir
|
|
373
|
+
* @returns {string}
|
|
374
|
+
*/
|
|
375
|
+
export function renderReportJson(runDir) {
|
|
376
|
+
const { contract, nodes } = loadRun(runDir);
|
|
377
|
+
const counts = new Map();
|
|
378
|
+
for (const node of nodes) counts.set(node.status, (counts.get(node.status) ?? 0) + 1);
|
|
379
|
+
/** @type {{inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null, costStatus: string, workerCostUsd: number|null, judgeCostUsd: number|null}} */
|
|
380
|
+
const totals = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, costUsd: null, costStatus: "ambiguous", workerCostUsd: null, judgeCostUsd: null };
|
|
381
|
+
const costs = nodes.map(costProjection);
|
|
382
|
+
const listed = nodes.map((node, index) => {
|
|
383
|
+
const usage = node.usage ?? { inputTokens: null, outputTokens: null, cacheReadInputTokens: null };
|
|
384
|
+
for (const key of /** @type {("inputTokens"|"outputTokens"|"cacheReadInputTokens")[]} */ (["inputTokens", "outputTokens", "cacheReadInputTokens"])) totals[key] = (totals[key] ?? 0) + (usage[key] ?? 0);
|
|
385
|
+
const cost = costs[index];
|
|
386
|
+
return {
|
|
387
|
+
id: node.id,
|
|
388
|
+
status: node.status,
|
|
389
|
+
phase: contract.nodes.find((candidate) => candidate.id === node.id)?.phase ?? null,
|
|
390
|
+
executionPhase: node.phase,
|
|
391
|
+
runtime: node.runtime ? `${node.runtime.harness}/${node.runtime.model}` : null,
|
|
392
|
+
attempt: node.attempt,
|
|
393
|
+
revisions: node.revisions,
|
|
394
|
+
usage,
|
|
395
|
+
costUsd: cost.costUsd,
|
|
396
|
+
costStatus: cost.status,
|
|
397
|
+
continuation: continuationMode(node),
|
|
398
|
+
note: nodeNote(node),
|
|
399
|
+
};
|
|
400
|
+
});
|
|
401
|
+
const aggregateCost = aggregateCostProjection(costs);
|
|
402
|
+
totals.costUsd = aggregateCost.costUsd;
|
|
403
|
+
totals.costStatus = aggregateCost.status;
|
|
404
|
+
const roles = roleCosts(nodes);
|
|
405
|
+
totals.workerCostUsd = roles.worker;
|
|
406
|
+
totals.judgeCostUsd = roles.judge;
|
|
407
|
+
const payload = {
|
|
408
|
+
schemaVersion: 1,
|
|
409
|
+
run: basename(runDir),
|
|
410
|
+
contractId: contract.id,
|
|
411
|
+
campaignId: contract.campaignId,
|
|
412
|
+
summary: [...counts].map(([status, count]) => `${count} ${status}`).join(" · "),
|
|
413
|
+
totals,
|
|
414
|
+
nodes: listed,
|
|
415
|
+
};
|
|
416
|
+
return `${JSON.stringify(payload, null, 2)}\n`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* @param {string} runDir
|
|
421
|
+
* @returns {string}
|
|
422
|
+
*/
|
|
423
|
+
export function renderFindings(runDir) {
|
|
424
|
+
const { nodes } = loadRun(runDir);
|
|
425
|
+
const sections = [];
|
|
426
|
+
for (const node of nodes) {
|
|
427
|
+
const gate = node.gate;
|
|
428
|
+
if (node.status === "exhausted" && gate?.findings?.length) {
|
|
429
|
+
const listed = gate.findings.map((finding) => `- [${finding.severity}] ${finding.description}\n Evidence: ${finding.evidence}`).join("\n");
|
|
430
|
+
sections.push(`## ${node.id}\n\nGate verdict: ${gate.verdict} (${gate.maxSeverity}). ${gate.summary}\n\n${listed}`);
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
const question = blockedContextQuestion(node);
|
|
434
|
+
if (question) sections.push(question);
|
|
435
|
+
}
|
|
436
|
+
return sections.length ? `${sections.join("\n\n")}\n` : "no findings or blocking questions to act on\n";
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* A node the worker itself stopped on, rendered as the repair it asks for.
|
|
441
|
+
*
|
|
442
|
+
* `findings` used to answer only gate exhaustion, and a run whose nodes all
|
|
443
|
+
* stopped on `blocked_context` reported nothing to act on while the workers
|
|
444
|
+
* had each named exactly what they needed — measured on a four-node campaign
|
|
445
|
+
* where three nodes were blocked and the command printed one line saying so.
|
|
446
|
+
* The question is already structured, so the repair is mechanical: put the
|
|
447
|
+
* named paths in the packet's `readFiles` and take a new run id.
|
|
448
|
+
*
|
|
449
|
+
* @param {NodeSnapshot} node
|
|
450
|
+
* @returns {string|null}
|
|
451
|
+
*/
|
|
452
|
+
function blockedContextQuestion(node) {
|
|
453
|
+
if (node.status !== "blocked") return null;
|
|
454
|
+
const result = node.result && typeof node.result === "object" ? /** @type {{status?: unknown, summary?: unknown, missingContext?: unknown}} */ (node.result) : null;
|
|
455
|
+
if (result?.status !== "blocked_context") return null;
|
|
456
|
+
const missing = Array.isArray(result.missingContext) ? result.missingContext.filter((entry) => typeof entry === "string") : [];
|
|
457
|
+
const asked = missing.length ? missing.map((entry) => `- ${entry}`).join("\n") : "- (the worker named nothing specific)";
|
|
458
|
+
const summary = typeof result.summary === "string" ? result.summary : "the worker stopped on missing context";
|
|
459
|
+
return `## ${node.id}\n\nBlocked on context, attempt ${node.attempt ?? 0}. ${summary}\n\nThe worker asked for:\n${asked}`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* @param {string} runDir
|
|
464
|
+
* @returns {{contract: ValidatedContract, nodes: NodeSnapshot[], identityWarnings: string[]}}
|
|
465
|
+
*/
|
|
466
|
+
function loadRun(runDir) {
|
|
467
|
+
const contractPath = join(runDir, "contract.json");
|
|
468
|
+
const contract = validateContract(/** @type {import("../contract/index.mjs").JsonObject} */ (JSON.parse(readFileSync(contractPath, "utf8"))), contractPath, { persisted: true });
|
|
469
|
+
const metadata = validateRunMetadata(readJson(join(runDir, "run.json")));
|
|
470
|
+
return { contract, nodes: readNodes(runDir, contract), identityWarnings: metadata.identityWarnings ?? [] };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* @param {string} runDir
|
|
475
|
+
* @param {ValidatedContract} contract
|
|
476
|
+
* @returns {NodeSnapshot[]}
|
|
477
|
+
*/
|
|
478
|
+
function readNodes(runDir, contract) {
|
|
479
|
+
const names = listNodeSnapshots(runDir);
|
|
480
|
+
const expected = new Map(contract.nodes.map((node) => [`${node.id}.json`, node]));
|
|
481
|
+
for (const name of names) if (!expected.has(name)) throw new TypeError(`unexpected persisted node snapshot ${name}`);
|
|
482
|
+
return contract.nodes.map((node) => {
|
|
483
|
+
const name = `${node.id}.json`;
|
|
484
|
+
if (!names.includes(name)) throw new TypeError(`missing persisted node snapshot ${name}`);
|
|
485
|
+
return validateNodeSnapshot(/** @type {import("../contract/index.mjs").JsonObject} */ (JSON.parse(readFileSync(nodeSnapshotPath(runDir, node.id), "utf8"))), node);
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Tokens by kind and cost across the run's usage.jsonl records. Missing or
|
|
491
|
+
* unparsable lines are skipped; a missing file yields zero totals.
|
|
492
|
+
*
|
|
493
|
+
* @param {string} runDir
|
|
494
|
+
* @returns {{inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null}}
|
|
495
|
+
*/
|
|
496
|
+
function readRunUsage(runDir) {
|
|
497
|
+
const totals = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, costUsd: /** @type {number|null} */ (null) };
|
|
498
|
+
const path = join(runDir, "usage.jsonl");
|
|
499
|
+
if (!existsSync(path)) return totals;
|
|
500
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
501
|
+
if (!line.trim()) continue;
|
|
502
|
+
let record;
|
|
503
|
+
try { record = JSON.parse(line); } catch { continue; }
|
|
504
|
+
if (!record || typeof record !== "object" || Array.isArray(record)) continue;
|
|
505
|
+
const value = /** @type {Record<string, unknown>} */ (record);
|
|
506
|
+
if (typeof value.inputTokens === "number") totals.inputTokens += value.inputTokens;
|
|
507
|
+
if (typeof value.outputTokens === "number") totals.outputTokens += value.outputTokens;
|
|
508
|
+
if (typeof value.cacheReadInputTokens === "number") totals.cacheReadInputTokens += value.cacheReadInputTokens;
|
|
509
|
+
if (typeof value.costUsd === "number") totals.costUsd = (totals.costUsd ?? 0) + value.costUsd;
|
|
510
|
+
}
|
|
511
|
+
return totals;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** @param {NodeSnapshot} node @returns {string} */
|
|
515
|
+
function continuationMode(node) {
|
|
516
|
+
return node.invocations?.at(-1)?.continuationMode ?? "fresh";
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Who produced this node's work, for the RUNTIME column of both tables.
|
|
521
|
+
*
|
|
522
|
+
* `state.runtime` is the last runtime *dispatched*, and the judge dispatch
|
|
523
|
+
* overwrites the worker's: every gated node that reached its gate therefore
|
|
524
|
+
* reported its judge as the runtime, and a six-node campaign whose workers
|
|
525
|
+
* were five different harnesses rendered as though three of them had never
|
|
526
|
+
* run (measured 2026-09-13). The invocation ledger keeps both roles, so the
|
|
527
|
+
* label is derived from it: the worker that ran, falling back to the live
|
|
528
|
+
* runtime for a node that has not dispatched one yet. The judge is not lost —
|
|
529
|
+
* it owns the VERDICT column, and `nowLine` still names whatever is running.
|
|
530
|
+
*
|
|
531
|
+
* @param {NodeSnapshot} node
|
|
532
|
+
* @returns {string|null}
|
|
533
|
+
*/
|
|
534
|
+
function workerRuntimeLabel(node) {
|
|
535
|
+
const worker = [...(node.invocations ?? [])].reverse().find((invocation) => invocation.role === "worker");
|
|
536
|
+
if (worker?.harness && worker.model) return `${worker.harness}/${worker.model}`;
|
|
537
|
+
return node.runtime ? `${node.runtime.harness}/${node.runtime.model}` : null;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* A worker routing override waiting to be consumed by the node's next attempt —
|
|
542
|
+
* set by `handoff` (manual) or provider failover.
|
|
543
|
+
*
|
|
544
|
+
* @param {NodeSnapshot} node
|
|
545
|
+
* @returns {{runtime: string, reason: string}|null}
|
|
546
|
+
*/
|
|
547
|
+
function pendingHandoff(node) {
|
|
548
|
+
const override = node.routing?.currentOverride;
|
|
549
|
+
if (override?.role !== "worker") return null;
|
|
550
|
+
return { runtime: override.runtime, reason: override.reason };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** Segments of a node note are joined by this separator. */
|
|
554
|
+
const NOTE_SEPARATOR = " · ";
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* The longest note a status surface shows. It is the width the status tables
|
|
558
|
+
* render, so a bounded note never has to be cut again on its way into a cell
|
|
559
|
+
* and the JSON carries exactly the string the tables do.
|
|
560
|
+
*/
|
|
561
|
+
export const MAX_NOTE_LENGTH = 64;
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Joins the note segments into one note bounded to `maxLength`, cutting the
|
|
565
|
+
* trailing segment first: the leading scope and review markers are what the
|
|
566
|
+
* operator and the campaign match on, so a long gate summary or error is the
|
|
567
|
+
* part that yields, and every surface shows the same bounded string.
|
|
568
|
+
*
|
|
569
|
+
* @param {(string|null|undefined)[]} segments
|
|
570
|
+
* @param {number} [maxLength]
|
|
571
|
+
* @returns {string|null}
|
|
572
|
+
*/
|
|
573
|
+
function boundedNote(segments, maxLength = MAX_NOTE_LENGTH) {
|
|
574
|
+
const parts = segments.filter(Boolean);
|
|
575
|
+
if (!parts.length) return null;
|
|
576
|
+
const note = parts.join(NOTE_SEPARATOR);
|
|
577
|
+
if (note.length <= maxLength) return note;
|
|
578
|
+
const head = parts.slice(0, -1).join(NOTE_SEPARATOR);
|
|
579
|
+
const tail = /** @type {string} */ (parts.at(-1));
|
|
580
|
+
const room = maxLength - (head ? head.length + NOTE_SEPARATOR.length : 0);
|
|
581
|
+
const cut = `${tail.slice(0, Math.max(0, room - 1))}…`;
|
|
582
|
+
if (head && cut.length > 1) return `${head}${NOTE_SEPARATOR}${cut}`;
|
|
583
|
+
return `${note.slice(0, maxLength - 1)}…`;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* The note a status surface shows for a node: gate summary or error, led by
|
|
588
|
+
* any advisory scope finding and by the review outcome, so a gated done node
|
|
589
|
+
* cannot hide an advisory finding or an invalid verdict behind its gate
|
|
590
|
+
* summary (TECH-SPEC lean, rules 1 and 2). The order is the stable format
|
|
591
|
+
* every surface shares: `scope: N unexpected paths · <review note> · <gate
|
|
592
|
+
* summary or error>`, bounded so the tables and the JSON cannot disagree.
|
|
593
|
+
*
|
|
594
|
+
* @param {NodeSnapshot} node
|
|
595
|
+
* @returns {string|null}
|
|
596
|
+
*/
|
|
597
|
+
export function statusNote(node) {
|
|
598
|
+
const scope = scopeFindingsNote(node.scopeFindings);
|
|
599
|
+
const review = reviewNote(node);
|
|
600
|
+
const detail = node.gate?.summary ?? node.error?.message ?? node.blockedBy?.join(", ") ?? node.phase;
|
|
601
|
+
const note = boundedNote([review, detail]);
|
|
602
|
+
if (!scope) return note;
|
|
603
|
+
return boundedNote([scope, note]);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* A scope finding is advisory, so the node keeps its own note; the finding
|
|
608
|
+
* still has to stay visible on a gated node, where the gate summary would
|
|
609
|
+
* otherwise be the whole note (TECH-SPEC lean, rule 1).
|
|
610
|
+
*
|
|
611
|
+
* @param {NodeSnapshot} node
|
|
612
|
+
* @returns {string}
|
|
613
|
+
*/
|
|
614
|
+
function nodeNote(node) {
|
|
615
|
+
const note = statusNote(node);
|
|
616
|
+
if (note) return note;
|
|
617
|
+
if (typeof node.result === "string" && node.result.trim()) return node.result.trim();
|
|
618
|
+
return node.phase ?? "-";
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** @typedef {{costUsd: number|null, status: "known"|"estimated"|"ambiguous"}} CostProjection */
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Per-role cost, summed from the invocation ledger alone. A role that appears
|
|
625
|
+
* only on invocations that all carry a provider cost is `known`; a role with no
|
|
626
|
+
* invocation at all, or with any invocation whose cost is missing, is `null`.
|
|
627
|
+
* That is deliberately not zero: an unavailable or partial role cost must not
|
|
628
|
+
* fabricate `$0`, and summing only the known invocations would understate a
|
|
629
|
+
* partial one. The invocation ledger is the single source, so the node's own
|
|
630
|
+
* `costUsd` (itself the sum of these invocations) is never added on top and
|
|
631
|
+
* cannot double-count.
|
|
632
|
+
*
|
|
633
|
+
* @param {NodeSnapshot[]} nodes
|
|
634
|
+
* @returns {{worker: number|null, judge: number|null}}
|
|
635
|
+
*/
|
|
636
|
+
export function roleCosts(nodes) {
|
|
637
|
+
/** @type {Record<"worker"|"judge", {total: number, present: number, unknown: number}>} */
|
|
638
|
+
const roles = {
|
|
639
|
+
worker: { total: 0, present: 0, unknown: 0 },
|
|
640
|
+
judge: { total: 0, present: 0, unknown: 0 },
|
|
641
|
+
};
|
|
642
|
+
for (const node of nodes) {
|
|
643
|
+
for (const invocation of node.invocations ?? []) {
|
|
644
|
+
if (invocation.role !== "worker" && invocation.role !== "judge") continue;
|
|
645
|
+
const bucket = roles[invocation.role];
|
|
646
|
+
const cost = finite(invocation.costUsd);
|
|
647
|
+
if (cost === null) bucket.unknown += 1;
|
|
648
|
+
else {
|
|
649
|
+
bucket.present += 1;
|
|
650
|
+
bucket.total += cost;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
const complete = (/** @type {{present: number, unknown: number, total: number}} */ bucket) =>
|
|
655
|
+
bucket.present > 0 && bucket.unknown === 0 ? bucket.total : null;
|
|
656
|
+
return { worker: complete(roles.worker), judge: complete(roles.judge) };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Project cost only from durable snapshot evidence. Invocation costs are
|
|
661
|
+
* provider-reported; a standalone node total has no provider attribution and
|
|
662
|
+
* remains an estimate. Missing or mismatched evidence is ambiguous.
|
|
663
|
+
*
|
|
664
|
+
* @param {NodeSnapshot} node
|
|
665
|
+
* @returns {CostProjection}
|
|
666
|
+
*/
|
|
667
|
+
function costProjection(node) {
|
|
668
|
+
const nodeCost = finite(node.costUsd);
|
|
669
|
+
const invocations = Array.isArray(node.invocations) ? node.invocations : [];
|
|
670
|
+
const invocationCosts = invocations.map((invocation) => finite(invocation.costUsd));
|
|
671
|
+
|
|
672
|
+
if (invocations.length > 0) {
|
|
673
|
+
if (!invocationCosts.every((cost) => cost !== null)) return { costUsd: null, status: "ambiguous" };
|
|
674
|
+
const reportedCost = invocationCosts.reduce((total, cost) => total + /** @type {number} */ (cost), 0);
|
|
675
|
+
if (nodeCost !== null && !sameCost(nodeCost, reportedCost)) return { costUsd: null, status: "ambiguous" };
|
|
676
|
+
return { costUsd: nodeCost ?? reportedCost, status: "known" };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (nodeCost !== null) return { costUsd: nodeCost, status: "estimated" };
|
|
680
|
+
return { costUsd: null, status: "ambiguous" };
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** @param {CostProjection[]} costs @returns {CostProjection} */
|
|
684
|
+
function aggregateCostProjection(costs) {
|
|
685
|
+
if (costs.length === 0 || costs.some((cost) => cost.status === "ambiguous")) {
|
|
686
|
+
return { costUsd: null, status: "ambiguous" };
|
|
687
|
+
}
|
|
688
|
+
const costUsd = costs.every((cost) => typeof cost.costUsd === "number")
|
|
689
|
+
? costs.reduce((total, cost) => total + /** @type {number} */ (cost.costUsd), 0)
|
|
690
|
+
: null;
|
|
691
|
+
return {
|
|
692
|
+
costUsd,
|
|
693
|
+
status: costs.some((cost) => cost.status === "estimated") ? "estimated" : "known",
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** @param {number} left @param {number} right @returns {boolean} */
|
|
698
|
+
function sameCost(left, right) {
|
|
699
|
+
return Math.abs(left - right) <= 1e-9;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** @param {CostProjection} projection @returns {string} */
|
|
703
|
+
function formatCost(projection) {
|
|
704
|
+
return `${compactCost(projection.costUsd)} (${projection.status})`;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* @param {string} value
|
|
709
|
+
* @param {number} width
|
|
710
|
+
* @returns {string}
|
|
711
|
+
*/
|
|
712
|
+
export function fit(value, width) {
|
|
713
|
+
const clean = value.replace(/[\u0000-\u001f\u007f]+/gu, " ").replace(/\s+/gu, " ").trim();
|
|
714
|
+
if (clean.length <= width) return clean + " ".repeat(width - clean.length);
|
|
715
|
+
return `${clean.slice(0, Math.max(0, width - 2))}..`.padEnd(width, " ");
|
|
716
|
+
}
|