faberun 0.30.0 → 0.32.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/package.json +1 -1
- package/src/campaign/campaign-brief.mjs +17 -6
- package/src/campaign/chain.mjs +124 -8
- package/src/campaign/ledger.mjs +19 -7
- package/src/campaign/metrics-command.mjs +68 -9
- package/src/campaign/metrics.mjs +75 -45
- package/src/campaign/record.mjs +8 -1
- package/src/campaign/reserve.mjs +340 -0
- package/src/campaign/spend.mjs +229 -0
- package/src/campaign/unpark.mjs +54 -1
- package/src/campaign/watch.mjs +314 -37
- package/src/cli/brand.mjs +2 -1
- package/src/cli.mjs +52 -2
- package/src/contract/judge-envelope.mjs +17 -2
- package/src/contract/scope-findings.mjs +3 -1
- package/src/contract/task-packet.mjs +40 -5
- package/src/contract/verification.mjs +67 -1
- package/src/engine/aux-admission.mjs +124 -0
- package/src/engine/capacity.mjs +78 -0
- package/src/engine/dispatch.mjs +116 -23
- package/src/engine/failure-cause.mjs +59 -0
- package/src/engine/lifecycle.mjs +3 -2
- package/src/engine/phase-session.mjs +56 -10
- package/src/engine/prompts.mjs +57 -2
- package/src/engine/reserve-admission.mjs +99 -0
- package/src/engine/resume.mjs +3 -1
- package/src/engine/review.mjs +21 -0
- package/src/engine/run-command.mjs +166 -11
- package/src/engine/scheduler.mjs +92 -88
- package/src/engine/settle.mjs +15 -8
- package/src/engine/settlement-overlap.mjs +143 -0
- package/src/engine/verify.mjs +131 -7
- package/src/harnesses/session-metrics.mjs +112 -20
- package/src/notify/index.mjs +86 -10
- package/src/plan/freeze.mjs +7 -2
- package/src/plan/human-step.mjs +36 -0
- package/src/plan/pipeline.mjs +53 -22
- package/src/plan/preflight.mjs +81 -5
- package/src/plan/progress.mjs +101 -1
- package/src/plan/repo-facts.mjs +403 -49
- package/src/plan/resolve.mjs +7 -0
- package/src/plan/revise-context.mjs +108 -0
- package/src/plan/rounds.mjs +180 -45
- package/src/plan/sizing.mjs +33 -1
- package/src/plan/template.mjs +134 -22
- package/src/repo/integrate.mjs +47 -5
- package/src/report/campaign-brief-estimate.mjs +47 -1
- package/src/report/campaign-brief.mjs +31 -2
- package/src/report/locale.mjs +20 -0
- package/src/report/message.mjs +13 -1
- package/src/report/metrics-report.mjs +38 -0
- package/src/report/progress.mjs +194 -10
- package/src/report/render.mjs +1 -1
- package/src/report/report-html.mjs +235 -0
- package/src/report/report-video.mjs +323 -0
- package/src/run/usage-windows.mjs +99 -4
- package/src/run/usage.mjs +69 -7
- package/src/web/api.mjs +179 -24
- package/src/web/app.mjs +27 -2
- package/src/web/server.mjs +32 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The run report's video boundary: the reading ladder's fourth rung, an
|
|
3
|
+
* animated explainer. It is programmatic, in the 3Blue1Brown sense — the scene
|
|
4
|
+
* is a pure function of the report's facts and a time, rendered as SVG frames,
|
|
5
|
+
* rasterised by librsvg and assembled with ffmpeg around an ElevenLabs
|
|
6
|
+
* narration — rather than manim, which no machine in the fleet has. The
|
|
7
|
+
* narration script (text) is the durable source; the mp3 and mp4 are derived
|
|
8
|
+
* copies, so a missing key, voice, rasteriser or ffmpeg is a named failure that
|
|
9
|
+
* leaves the script behind and changes nothing else.
|
|
10
|
+
*
|
|
11
|
+
* Every seam (the ElevenLabs HTTP call, the rasteriser, ffprobe and ffmpeg) is
|
|
12
|
+
* injectable, so a test proves the whole pipeline with fakes and never spends
|
|
13
|
+
* a real synthesis credit.
|
|
14
|
+
*/
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { NOTIFY_LANG_ENV } from "../notify/index.mjs";
|
|
20
|
+
import { renderReportJson } from "./render.mjs";
|
|
21
|
+
import { escapeSvgText, layoutGraphNodes, readContractDependsOn, STATUS_COLOURS } from "./report-html.mjs";
|
|
22
|
+
import { compactCost } from "../util.mjs";
|
|
23
|
+
import { writeTextAtomic } from "../run/store.mjs";
|
|
24
|
+
|
|
25
|
+
/** The durable source and its derived copies, beside `STATUS.md` in the run directory. */
|
|
26
|
+
const NARRATION_FILE = "report.narration.txt";
|
|
27
|
+
const VIDEO_FILE = "report.mp4";
|
|
28
|
+
|
|
29
|
+
/** Video frame rate; the animation is gradual fades, so this is plenty smooth. */
|
|
30
|
+
const FPS = 15;
|
|
31
|
+
const WIDTH = 1280;
|
|
32
|
+
const HEIGHT = 720;
|
|
33
|
+
|
|
34
|
+
/** @typedef {import("./report-html.mjs").GraphNode} GraphNode */
|
|
35
|
+
/** @typedef {{run: string, summary: string, totals: {costUsd: number|null}, nodes: {id: string, status: string}[]}} VideoPayload */
|
|
36
|
+
/** @typedef {{title: string, nodes: GraphNode[], done: number, total: number, cost: string|null, attention: string[]}} Scene */
|
|
37
|
+
/** @typedef {{apiKey: string, voice: string, model: string}} ElevenLabsOptions */
|
|
38
|
+
/** @typedef {{t: number}} Frame */
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A stable error surface for callers and CLI reporting.
|
|
42
|
+
*/
|
|
43
|
+
export class ReportVideoError extends Error {
|
|
44
|
+
/** @param {string} code @param {string} message @param {{cause?: unknown}} [options] */
|
|
45
|
+
constructor(code, message, options = {}) {
|
|
46
|
+
super(message, options);
|
|
47
|
+
this.name = "ReportVideoError";
|
|
48
|
+
this.code = code;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The spoken script, deterministic from the report's own facts, in the
|
|
54
|
+
* operator's language. The title is the run id; the body names the counts, the
|
|
55
|
+
* attention nodes and the spend. No model is asked to phrase it — the same
|
|
56
|
+
* discipline as the watcher's progress line, so the same run yields the same
|
|
57
|
+
* narration.
|
|
58
|
+
*
|
|
59
|
+
* @param {VideoPayload} payload
|
|
60
|
+
* @param {NodeJS.ProcessEnv} env
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
export function buildNarration(payload, env = process.env) {
|
|
64
|
+
const lang = (env[NOTIFY_LANG_ENV] ?? "").trim() === "en" ? "en" : "pt";
|
|
65
|
+
const attention = payload.nodes.filter((node) => !QUIET.has(node.status)).map((node) => node.id);
|
|
66
|
+
const done = doneCount(payload);
|
|
67
|
+
const cost = payload.totals.costUsd === null ? null : String(payload.totals.costUsd);
|
|
68
|
+
if (lang === "en") {
|
|
69
|
+
const attentionLine = attention.length ? `${attention.length} need${attention.length === 1 ? "s" : ""} you: ${attention.join(", ")}.` : "Nothing needs you.";
|
|
70
|
+
return `Report for run ${payload.run}. ${done} of ${payload.nodes.length} nodes done. ${attentionLine} Total cost: ${cost ?? "unknown"} dollars.`;
|
|
71
|
+
}
|
|
72
|
+
const attentionLine = attention.length ? `${attention.length} ${attention.length === 1 ? "nó precisa" : "nós precisam"} de você: ${attention.join(", ")}.` : "Nada precisa de você.";
|
|
73
|
+
return `Relatório da run ${payload.run}. ${done} de ${payload.nodes.length} nós concluídos. ${attentionLine} Custo total: ${cost ?? "desconhecido"} dólares.`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Node states that wait on a person; everything else is quiet. */
|
|
77
|
+
const QUIET = new Set(["pending", "running", "done", "no-op"]);
|
|
78
|
+
|
|
79
|
+
/** @param {VideoPayload} payload @returns {number} */
|
|
80
|
+
function doneCount(payload) {
|
|
81
|
+
return payload.nodes.filter((node) => node.status === "done" || node.status === "no-op").length;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One SVG frame of the scene at a normalised time `t` in [0, 1]. The work
|
|
86
|
+
* graph builds up node by node in topological order, each fading in with its
|
|
87
|
+
* status colour, while the title holds and the progress strip and spend
|
|
88
|
+
* surface near the end. Self-contained: inline colours, no external reference.
|
|
89
|
+
*
|
|
90
|
+
* @param {Scene} scene
|
|
91
|
+
* @param {Frame} frame
|
|
92
|
+
* @returns {string}
|
|
93
|
+
*/
|
|
94
|
+
export function renderReportSceneSvg(scene, frame) {
|
|
95
|
+
const t = clamp(frame.t, 0, 1);
|
|
96
|
+
const titleOpacity = ramp(t, 0.02, 0.08);
|
|
97
|
+
const footerOpacity = ramp(t, 0.55, 0.08);
|
|
98
|
+
|
|
99
|
+
const laidOut = layoutGraphNodes(scene.nodes);
|
|
100
|
+
const revealOf = revealSchedule(laidOut);
|
|
101
|
+
const maxDepth = laidOut.reduce((max, node) => Math.max(max, node.depth), 0);
|
|
102
|
+
const maxRow = laidOut.reduce((max, node) => Math.max(max, node.row), 0);
|
|
103
|
+
const naturalW = GRAPH_MARGIN * 2 + (maxDepth + 1) * GRAPH_COL_WIDTH;
|
|
104
|
+
const naturalH = GRAPH_MARGIN * 2 + (maxRow + 1) * GRAPH_ROW_HEIGHT;
|
|
105
|
+
const regionW = WIDTH - 160;
|
|
106
|
+
const regionH = 400;
|
|
107
|
+
const scale = Math.min(regionW / naturalW, regionH / naturalH, 1);
|
|
108
|
+
const tx = (WIDTH - naturalW * scale) / 2;
|
|
109
|
+
const ty = 120 + (regionH - naturalH * scale) / 2;
|
|
110
|
+
|
|
111
|
+
const byId = new Map(laidOut.map((node) => [node.id, node]));
|
|
112
|
+
const edges = laidOut.flatMap((node) => (node.dependsOn ?? [])
|
|
113
|
+
.filter((/** @type {string} */ dep) => byId.has(dep))
|
|
114
|
+
.map((/** @type {string} */ dep) => {
|
|
115
|
+
const from = posOf(/** @type {{depth: number, row: number}} */ (byId.get(dep)));
|
|
116
|
+
const to = posOf(node);
|
|
117
|
+
const x1 = from.x + GRAPH_BOX_W;
|
|
118
|
+
const y1 = from.y + GRAPH_BOX_H / 2;
|
|
119
|
+
const x2 = to.x;
|
|
120
|
+
const y2 = to.y + GRAPH_BOX_H / 2;
|
|
121
|
+
const midX = (x1 + x2) / 2;
|
|
122
|
+
const opacity = ramp(t, revealOf.get(node.id) ?? 0, FADE);
|
|
123
|
+
return `<path d="M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}" fill="none" stroke="#D97B4F" stroke-width="2" opacity="${opacity}"/>`;
|
|
124
|
+
})).join("");
|
|
125
|
+
const boxes = laidOut.map((node, index) => {
|
|
126
|
+
const { x, y } = posOf(node);
|
|
127
|
+
const colour = STATUS_COLOURS[node.status] ?? "#1F1F1F";
|
|
128
|
+
const opacity = ramp(t, revealOf.get(node.id) ?? 0, FADE);
|
|
129
|
+
const clipId = `clip${index}`;
|
|
130
|
+
return `<g opacity="${opacity}"><rect x="${x}" y="${y}" width="${GRAPH_BOX_W}" height="${GRAPH_BOX_H}" rx="8" fill="#F4E9D8" stroke="${colour}" stroke-width="2"/><clipPath id="${clipId}"><rect x="${x}" y="${y}" width="${GRAPH_BOX_W}" height="${GRAPH_BOX_H}"/></clipPath><g clip-path="url(#${clipId})"><text x="${x + 10}" y="${y + 24}" font-family="ui-monospace, monospace" font-size="14" fill="#1F1F1F">${escapeSvgText(node.id)}</text><text x="${x + 10}" y="${y + 44}" font-family="ui-sans-serif, sans-serif" font-size="13" fill="${colour}">${escapeSvgText(node.status)}</text></g></g>`;
|
|
131
|
+
}).join("");
|
|
132
|
+
|
|
133
|
+
const barW = WIDTH - 160;
|
|
134
|
+
const fillW = Math.round((scene.done / Math.max(1, scene.total)) * barW);
|
|
135
|
+
const statusText = `${scene.done}/${scene.total} · ${scene.cost ?? "-"}`;
|
|
136
|
+
const attentionText = scene.attention.length ? `⚠ ${scene.attention.join(", ")}` : "";
|
|
137
|
+
|
|
138
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${WIDTH} ${HEIGHT}" width="${WIDTH}" height="${HEIGHT}">`
|
|
139
|
+
+ `<rect width="${WIDTH}" height="${HEIGHT}" fill="#F4E9D8"/>`
|
|
140
|
+
+ `<text x="80" y="86" font-family="ui-sans-serif, sans-serif" font-size="40" fill="#1F1F1F" opacity="${titleOpacity}">${escapeSvgText(scene.title)}</text>`
|
|
141
|
+
+ `<g transform="translate(${tx} ${ty}) scale(${scale})">${edges}${boxes}</g>`
|
|
142
|
+
+ `<g opacity="${footerOpacity}">`
|
|
143
|
+
+ `<rect x="80" y="${HEIGHT - 120}" width="${barW}" height="20" rx="10" fill="#1F1F1F" opacity="0.12"/>`
|
|
144
|
+
+ `<rect x="80" y="${HEIGHT - 120}" width="${fillW}" height="20" rx="10" fill="#B5522A"/>`
|
|
145
|
+
+ `<text x="80" y="${HEIGHT - 140}" font-family="ui-sans-serif, sans-serif" font-size="26" fill="#1F1F1F">${escapeSvgText(statusText)}</text>`
|
|
146
|
+
+ (attentionText ? `<text x="80" y="${HEIGHT - 70}" font-family="ui-sans-serif, sans-serif" font-size="22" fill="#B5522A">${escapeSvgText(attentionText)}</text>` : "")
|
|
147
|
+
+ `</g>`
|
|
148
|
+
+ `</svg>`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const GRAPH_COL_WIDTH = 260;
|
|
152
|
+
const GRAPH_ROW_HEIGHT = 84;
|
|
153
|
+
const GRAPH_BOX_W = 220;
|
|
154
|
+
const GRAPH_BOX_H = 56;
|
|
155
|
+
const GRAPH_MARGIN = 16;
|
|
156
|
+
/** How long one node takes to fade in, in normalised time. */
|
|
157
|
+
const FADE = 0.06;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The reveal instant of each node, in topological order (depth first, then
|
|
161
|
+
* row): the first fades in at 0.12 and the last at 0.80, leaving a tail for
|
|
162
|
+
* the counters.
|
|
163
|
+
*
|
|
164
|
+
* @param {(GraphNode & {depth: number, row: number})[]} laidOut
|
|
165
|
+
* @returns {Map<string, number>}
|
|
166
|
+
*/
|
|
167
|
+
function revealSchedule(laidOut) {
|
|
168
|
+
const order = [...laidOut].sort((a, b) => a.depth - b.depth || a.row - b.row);
|
|
169
|
+
return new Map(order.map((node, index) => [node.id, 0.12 + 0.68 * (index / Math.max(1, laidOut.length - 1))]));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** @param {{depth: number, row: number}} node @returns {{x: number, y: number}} */
|
|
173
|
+
function posOf(node) {
|
|
174
|
+
return { x: GRAPH_MARGIN + node.depth * GRAPH_COL_WIDTH, y: GRAPH_MARGIN + node.row * GRAPH_ROW_HEIGHT };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** @param {number} t @param {number} start @param {number} dur @returns {number} */
|
|
178
|
+
function ramp(t, start, dur) {
|
|
179
|
+
return clamp((t - start) / dur, 0, 1);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** @param {number} v @param {number} lo @param {number} hi @returns {number} */
|
|
183
|
+
function clamp(v, lo, hi) {
|
|
184
|
+
return Math.max(lo, Math.min(hi, v));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Synthesize the narration as MP3 bytes through ElevenLabs. The key is read
|
|
189
|
+
* from the environment, never stored; the request carries it only as a header.
|
|
190
|
+
*
|
|
191
|
+
* @param {string} text
|
|
192
|
+
* @param {ElevenLabsOptions} options
|
|
193
|
+
* @param {{fetch?: typeof fetch}} [io]
|
|
194
|
+
* @returns {Promise<Buffer>}
|
|
195
|
+
*/
|
|
196
|
+
export async function synthesizeSpeech(text, options, io = {}) {
|
|
197
|
+
const fetchImpl = io.fetch ?? fetch;
|
|
198
|
+
const url = `https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(options.voice)}`;
|
|
199
|
+
let response;
|
|
200
|
+
try {
|
|
201
|
+
response = await fetchImpl(url, {
|
|
202
|
+
method: "POST",
|
|
203
|
+
headers: { "xi-api-key": options.apiKey, "Content-Type": "application/json" },
|
|
204
|
+
body: JSON.stringify({
|
|
205
|
+
text,
|
|
206
|
+
model_id: options.model,
|
|
207
|
+
voice_settings: { stability: 0.5, similarity_boost: 0.75 },
|
|
208
|
+
}),
|
|
209
|
+
});
|
|
210
|
+
} catch (error) {
|
|
211
|
+
throw new ReportVideoError("VIDEO_TTS_FAILED", `ElevenLabs request failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
212
|
+
}
|
|
213
|
+
if (!response.ok) {
|
|
214
|
+
throw new ReportVideoError("VIDEO_TTS_FAILED", `ElevenLabs returned HTTP ${response.status}`);
|
|
215
|
+
}
|
|
216
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
217
|
+
if (bytes.length === 0) throw new ReportVideoError("VIDEO_TTS_FAILED", "ElevenLabs returned no audio");
|
|
218
|
+
return bytes;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** @param {string} command @param {string[]} args @returns {import("node:child_process").SpawnSyncReturns<string>} */
|
|
222
|
+
function spawnRun(command, args) {
|
|
223
|
+
return spawnSync(command, args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The duration of the narration in milliseconds, read by ffprobe so the frames
|
|
228
|
+
* fill exactly the audio.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} audioPath
|
|
231
|
+
* @param {(command: string, args: string[]) => import("node:child_process").SpawnSyncReturns<string>} run
|
|
232
|
+
* @returns {number}
|
|
233
|
+
*/
|
|
234
|
+
function probeDurationMs(audioPath, run) {
|
|
235
|
+
const result = run("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", audioPath]);
|
|
236
|
+
const seconds = Number((result.stdout ?? "").trim());
|
|
237
|
+
if (result.status !== 0 || !Number.isFinite(seconds) || seconds <= 0) {
|
|
238
|
+
throw new ReportVideoError("VIDEO_PROBE_FAILED", "could not measure the narration duration");
|
|
239
|
+
}
|
|
240
|
+
return Math.round(seconds * 1000);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The full pipeline: narration script (durable) → ElevenLabs MP3 → SVG frames
|
|
245
|
+
* → PNG frames via rsvg-convert → an MP4 via ffmpeg. The script is written
|
|
246
|
+
* first and survives every later failure; a missing key, voice or tool is a
|
|
247
|
+
* named code on the result, with any partial video removed, rather than a
|
|
248
|
+
* throw — the same contract as `generateReportHtml`.
|
|
249
|
+
*
|
|
250
|
+
* @param {string} runDir
|
|
251
|
+
* @param {{apiKey?: string, voice?: string, model?: string, env?: NodeJS.ProcessEnv, synthesize?: typeof synthesizeSpeech, run?: (command: string, args: string[]) => import("node:child_process").SpawnSyncReturns<string>}} options
|
|
252
|
+
* @returns {Promise<{narrationPath: string, videoPath: string|null, code: string|null}>}
|
|
253
|
+
*/
|
|
254
|
+
export async function generateReportVideo(runDir, options) {
|
|
255
|
+
const env = options.env ?? process.env;
|
|
256
|
+
const run = options.run ?? spawnRun;
|
|
257
|
+
const synthesize = options.synthesize ?? synthesizeSpeech;
|
|
258
|
+
|
|
259
|
+
const payload = /** @type {VideoPayload} */ (JSON.parse(renderReportJson(runDir)));
|
|
260
|
+
const scene = buildScene(runDir, payload);
|
|
261
|
+
const narration = buildNarration(payload, env);
|
|
262
|
+
const narrationPath = join(runDir, NARRATION_FILE);
|
|
263
|
+
const videoPath = join(runDir, VIDEO_FILE);
|
|
264
|
+
writeTextAtomic(narrationPath, narration);
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
const apiKey = options.apiKey || env.ELEVENLABS_API_KEY || "";
|
|
268
|
+
const voice = options.voice || env.ELEVENLABS_VOICE || "";
|
|
269
|
+
if (!apiKey) throw new ReportVideoError("VIDEO_KEY_MISSING", "ELEVENLABS_API_KEY is not set");
|
|
270
|
+
if (!voice) throw new ReportVideoError("VIDEO_VOICE_MISSING", "ELEVENLABS_VOICE is not set");
|
|
271
|
+
|
|
272
|
+
const audio = await synthesize(narration, { apiKey, voice, model: options.model ?? "eleven_multilingual_v2" });
|
|
273
|
+
const stage = mkdtempSync(join(tmpdir(), "report-video-"));
|
|
274
|
+
try {
|
|
275
|
+
const audioPath = join(stage, "narration.mp3");
|
|
276
|
+
writeFileSync(audioPath, audio);
|
|
277
|
+
const durationMs = probeDurationMs(audioPath, run);
|
|
278
|
+
const frameCount = Math.max(2, Math.round((durationMs / 1000) * FPS));
|
|
279
|
+
const framesDir = join(stage, "frames");
|
|
280
|
+
mkdirSync(framesDir, { recursive: true });
|
|
281
|
+
for (let i = 0; i < frameCount; i += 1) {
|
|
282
|
+
const svg = renderReportSceneSvg(scene, { t: frameCount === 1 ? 1 : i / (frameCount - 1) });
|
|
283
|
+
const base = join(framesDir, `frame-${String(i).padStart(4, "0")}`);
|
|
284
|
+
writeFileSync(`${base}.svg`, svg, "utf8");
|
|
285
|
+
const raster = run("rsvg-convert", ["-w", String(WIDTH), "-h", String(HEIGHT), `${base}.svg`, "-o", `${base}.png`]);
|
|
286
|
+
if (raster.status !== 0) throw new ReportVideoError("VIDEO_RASTER_FAILED", `rsvg-convert failed: ${raster.stderr || raster.stdout}`);
|
|
287
|
+
}
|
|
288
|
+
const assemble = run("ffmpeg", [
|
|
289
|
+
"-y", "-framerate", String(FPS), "-i", join(framesDir, "frame-%04d.png"),
|
|
290
|
+
"-i", audioPath, "-shortest", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", videoPath,
|
|
291
|
+
]);
|
|
292
|
+
if (assemble.status !== 0) throw new ReportVideoError("VIDEO_ASSEMBLE_FAILED", `ffmpeg failed: ${assemble.stderr || assemble.stdout}`);
|
|
293
|
+
return { narrationPath, videoPath, code: null };
|
|
294
|
+
} finally {
|
|
295
|
+
rmSync(stage, { recursive: true, force: true });
|
|
296
|
+
}
|
|
297
|
+
} catch (error) {
|
|
298
|
+
rmSync(videoPath, { force: true });
|
|
299
|
+
const code = error instanceof ReportVideoError ? error.code : "REPORT_VIDEO_FAILED";
|
|
300
|
+
return { narrationPath, videoPath: null, code };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The scene the frames render from: the report's graph, its counts, its spend
|
|
306
|
+
* and the nodes that wait on a person.
|
|
307
|
+
*
|
|
308
|
+
* @param {string} runDir
|
|
309
|
+
* @param {VideoPayload} payload
|
|
310
|
+
* @returns {Scene}
|
|
311
|
+
*/
|
|
312
|
+
function buildScene(runDir, payload) {
|
|
313
|
+
const dependsOn = readContractDependsOn(runDir);
|
|
314
|
+
const nodes = payload.nodes.map((node) => ({ id: node.id, status: node.status, dependsOn: dependsOn.get(node.id) ?? [] }));
|
|
315
|
+
return {
|
|
316
|
+
title: payload.run,
|
|
317
|
+
nodes,
|
|
318
|
+
done: doneCount(payload),
|
|
319
|
+
total: payload.nodes.length,
|
|
320
|
+
cost: typeof payload.totals.costUsd === "number" ? compactCost(payload.totals.costUsd) : null,
|
|
321
|
+
attention: payload.nodes.filter((node) => !QUIET.has(node.status)).map((node) => node.id),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The machine-wide record of how much of each account's provider-reported
|
|
3
|
-
* usage window is spent
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* usage window is spent, and how much usage the account has been asked to
|
|
4
|
+
* spend between window reads. It is separate from the availability store
|
|
5
|
+
* because it answers a different question: availability says whether a
|
|
6
|
+
* provider answered, this says how close the account is to refusing. Measured
|
|
6
7
|
* 2026-09-24: the Codex week went from 86% to 94% in one afternoon of judge
|
|
7
8
|
* readings, and nothing read it until the owner looked.
|
|
8
9
|
*/
|
|
@@ -11,9 +12,11 @@ import { dirname } from "node:path";
|
|
|
11
12
|
import { usageWindowsPath } from "./paths.mjs";
|
|
12
13
|
import { writeJsonAtomic } from "./store.mjs";
|
|
13
14
|
import { codexHome, codexUsageWindows } from "../harnesses/codex/usage-window.mjs";
|
|
15
|
+
import { addMeasured } from "../harnesses/session-metrics.mjs";
|
|
14
16
|
|
|
15
17
|
/** @typedef {import("../harnesses/codex/usage-window.mjs").UsageWindow} UsageWindow */
|
|
16
|
-
/** @typedef {
|
|
18
|
+
/** @typedef {import("../harnesses/session-metrics.mjs").UsageCounters} UsageCounters */
|
|
19
|
+
/** @typedef {{windows: UsageWindow[], observedAt: string, totals?: UsageCounters}} AccountWindows */
|
|
17
20
|
|
|
18
21
|
/**
|
|
19
22
|
* @param {string} account the account key, e.g. `codex:<CODEX_HOME>`
|
|
@@ -80,3 +83,95 @@ export function recordInvocationWindows(runtime, continuationId, options = {}) {
|
|
|
80
83
|
if (!account || !continuationId) return;
|
|
81
84
|
recordUsageWindows(account, codexUsageWindows(continuationId, options), options.now);
|
|
82
85
|
}
|
|
86
|
+
|
|
87
|
+
/** @returns {UsageCounters} */
|
|
88
|
+
function emptyCounters() {
|
|
89
|
+
return { bytes: null, inputTokens: null, cacheReadInputTokens: null, cacheWriteInputTokens: null, outputTokens: null };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Two counter sets into one: a measured value wins over an unmeasured one,
|
|
94
|
+
* and a counter neither side measured stays null. Every field is named -- the
|
|
95
|
+
* totals are never indexed by a dynamic counter name.
|
|
96
|
+
*
|
|
97
|
+
* @param {UsageCounters|undefined} left
|
|
98
|
+
* @param {UsageCounters} right
|
|
99
|
+
* @returns {UsageCounters}
|
|
100
|
+
*/
|
|
101
|
+
function addCounters(left, right) {
|
|
102
|
+
return {
|
|
103
|
+
bytes: addMeasured(left?.bytes ?? null, right.bytes),
|
|
104
|
+
inputTokens: addMeasured(left?.inputTokens ?? null, right.inputTokens),
|
|
105
|
+
cacheReadInputTokens: addMeasured(left?.cacheReadInputTokens ?? null, right.cacheReadInputTokens),
|
|
106
|
+
cacheWriteInputTokens: addMeasured(left?.cacheWriteInputTokens ?? null, right.cacheWriteInputTokens),
|
|
107
|
+
outputTokens: addMeasured(left?.outputTokens ?? null, right.outputTokens),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The strict rollup of per-node counter sets: a counter is summed only when
|
|
113
|
+
* every entry measured it, so a rollup over partly-measured nodes is null
|
|
114
|
+
* rather than a partial sum -- the same refusal the cost evidence makes for
|
|
115
|
+
* an unpriced invocation.
|
|
116
|
+
*
|
|
117
|
+
* @param {(UsageCounters|null|undefined)[]} counters
|
|
118
|
+
* @returns {UsageCounters}
|
|
119
|
+
*/
|
|
120
|
+
export function sumCounters(counters) {
|
|
121
|
+
/** @param {number|null} left @param {number|null} right @returns {number|null} */
|
|
122
|
+
const strict = (left, right) => left === null || right === null ? null : left + right;
|
|
123
|
+
const [first] = counters;
|
|
124
|
+
/** @type {UsageCounters} */
|
|
125
|
+
let totals = {
|
|
126
|
+
bytes: first?.bytes ?? null,
|
|
127
|
+
inputTokens: first?.inputTokens ?? null,
|
|
128
|
+
cacheReadInputTokens: first?.cacheReadInputTokens ?? null,
|
|
129
|
+
cacheWriteInputTokens: first?.cacheWriteInputTokens ?? null,
|
|
130
|
+
outputTokens: first?.outputTokens ?? null,
|
|
131
|
+
};
|
|
132
|
+
for (const entry of counters.slice(1)) {
|
|
133
|
+
totals = {
|
|
134
|
+
bytes: strict(totals.bytes, entry?.bytes ?? null),
|
|
135
|
+
inputTokens: strict(totals.inputTokens, entry?.inputTokens ?? null),
|
|
136
|
+
cacheReadInputTokens: strict(totals.cacheReadInputTokens, entry?.cacheReadInputTokens ?? null),
|
|
137
|
+
cacheWriteInputTokens: strict(totals.cacheWriteInputTokens, entry?.cacheWriteInputTokens ?? null),
|
|
138
|
+
outputTokens: strict(totals.outputTokens, entry?.outputTokens ?? null),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return totals;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Roll one finished invocation's measured counters onto its account's running
|
|
146
|
+
* totals, the machine-wide record of what each account has been asked to
|
|
147
|
+
* spend between window reads. A counter the invocation did not measure leaves
|
|
148
|
+
* the running total alone, and a harness that reports no account adds nothing.
|
|
149
|
+
*
|
|
150
|
+
* @param {{harness: string}} runtime
|
|
151
|
+
* @param {UsageCounters} counters
|
|
152
|
+
* @param {{env?: Record<string, string|undefined>, now?: number}} [options]
|
|
153
|
+
* @returns {void}
|
|
154
|
+
*/
|
|
155
|
+
export function addAccountUsage(runtime, counters, options = {}) {
|
|
156
|
+
const account = usageAccountOf(runtime, options.env);
|
|
157
|
+
if (!account) return;
|
|
158
|
+
const store = loadStore();
|
|
159
|
+
const existing = store.accounts[account];
|
|
160
|
+
store.accounts[account] = existing && Array.isArray(existing.windows)
|
|
161
|
+
? { windows: existing.windows, observedAt: existing.observedAt, totals: addCounters(existing.totals, counters) }
|
|
162
|
+
: { windows: [], observedAt: new Date(options.now ?? Date.now()).toISOString(), totals: addCounters(emptyCounters(), counters) };
|
|
163
|
+
const path = usageWindowsPath();
|
|
164
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
165
|
+
writeJsonAtomic(path, store);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The account's rolled-up invocation counters, or null before anything was
|
|
170
|
+
* rolled onto it.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} account
|
|
173
|
+
* @returns {UsageCounters|null}
|
|
174
|
+
*/
|
|
175
|
+
export function readAccountUsage(account) {
|
|
176
|
+
return loadStore().accounts[account]?.totals ?? null;
|
|
177
|
+
}
|
package/src/run/usage.mjs
CHANGED
|
@@ -9,14 +9,14 @@
|
|
|
9
9
|
import { appendJsonl, writeJsonAtomic } from "./store.mjs";
|
|
10
10
|
import { basename, join } from "node:path";
|
|
11
11
|
import { errorMessage, finite, stableJson } from "../util.mjs";
|
|
12
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
13
|
-
import { liveUsage } from "../harnesses/session-metrics.mjs";
|
|
12
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
13
|
+
import { liveUsage, liveUsageCounters } from "../harnesses/session-metrics.mjs";
|
|
14
14
|
|
|
15
15
|
import { priceUsage } from "../engine/process.mjs";
|
|
16
16
|
import { readBoundedTail, sessionLedger } from "../engine/transcript.mjs";
|
|
17
17
|
import { writeNode } from "../engine/state.mjs";
|
|
18
18
|
import { harnessCapabilities, normalizeProviderResult } from "../harnesses/index.mjs";
|
|
19
|
-
import { recordInvocationWindows } from "./usage-windows.mjs";
|
|
19
|
+
import { addAccountUsage, recordInvocationWindows, sumCounters } from "./usage-windows.mjs";
|
|
20
20
|
|
|
21
21
|
// `priceUsage` is defined beside `invocationResult`, the second source point,
|
|
22
22
|
// and re-exported here so the ledger's public surface is unchanged. This module
|
|
@@ -32,6 +32,7 @@ export { priceUsage };
|
|
|
32
32
|
/** @typedef {ProviderEnvelope & {costProvenance?: "priced"}} PricedEnvelope */
|
|
33
33
|
/** @typedef {{kind: "adopted"|"rejudge"|"restart"|"reconciled"|"exhausted"|"stalled", phase?: "worker"|"judge", result?: unknown, usage?: Usage, costUsd?: number|null, costProvenance?: "priced", exhaustedUntil?: string|null, error?: {code: string, message: string}|null, invocationId?: string, reason?: string}} RecoveryOutcome */
|
|
34
34
|
/** @typedef {import("../contract/index.mjs").Usage} Usage */
|
|
35
|
+
/** @typedef {import("../harnesses/session-metrics.mjs").UsageCounters} UsageCounters */
|
|
35
36
|
/** @typedef {"no-usage-stream"|"model-not-priced"|"provider-reported-nothing"|"invocation-killed"} UnknownCostReason */
|
|
36
37
|
|
|
37
38
|
/** The only reasons a newly-written unknown cost may carry. `legacy` is read-only compatibility for older ledgers. */
|
|
@@ -91,9 +92,11 @@ export function recordInvocationUsage(job, options = {}) {
|
|
|
91
92
|
// the single priced object every later copy spreads from.
|
|
92
93
|
try {
|
|
93
94
|
recordInvocationWindows(job.runtime, envelope.continuationId);
|
|
95
|
+
addAccountUsage(job.runtime, invocationCounters(job.invocation, boundedStdout));
|
|
94
96
|
} catch {
|
|
95
|
-
// The window record
|
|
96
|
-
// the invocation it rode on: a missing or
|
|
97
|
+
// The window record and the account rollup are telemetry about the
|
|
98
|
+
// account, never a reason to fail the invocation it rode on: a missing or
|
|
99
|
+
// unreadable session file means no reading.
|
|
97
100
|
}
|
|
98
101
|
const priced = priceUsage(job.runtime, envelope.usage, envelope.costUsd);
|
|
99
102
|
envelope = { ...envelope, costUsd: priced.costUsd, costProvenance: priced.costProvenance };
|
|
@@ -108,6 +111,36 @@ export function recordInvocationUsage(job, options = {}) {
|
|
|
108
111
|
return envelope;
|
|
109
112
|
}
|
|
110
113
|
const USAGE_LOG_NAME = "usage.jsonl";
|
|
114
|
+
/**
|
|
115
|
+
* The five measured counters one invocation proves: the recorded usage, with
|
|
116
|
+
* cache writes and any unrecorded counter mapped from the harness-reported
|
|
117
|
+
* session metrics on the transcript tail, plus the prompt's byte size. A
|
|
118
|
+
* counter nothing reports stays null, never a plausible zero.
|
|
119
|
+
* @param {Invocation|undefined|null} invocation
|
|
120
|
+
* @param {string} stdout bounded transcript tail, empty when none survives
|
|
121
|
+
* @returns {UsageCounters}
|
|
122
|
+
*/
|
|
123
|
+
function invocationCounters(invocation, stdout) {
|
|
124
|
+
const usage = /** @type {Record<string, unknown>} */ (invocation?.usage ?? {});
|
|
125
|
+
const observed = stdout ? liveUsageCounters(String(invocation?.harness ?? ""), stdout) : null;
|
|
126
|
+
return {
|
|
127
|
+
bytes: promptBytes(invocation?.promptPath),
|
|
128
|
+
inputTokens: finite(usage.inputTokens) ?? observed?.inputTokens ?? null,
|
|
129
|
+
cacheReadInputTokens: finite(usage.cacheReadInputTokens) ?? observed?.cacheReadInputTokens ?? null,
|
|
130
|
+
cacheWriteInputTokens: observed?.cacheWriteInputTokens ?? null,
|
|
131
|
+
outputTokens: finite(usage.outputTokens) ?? observed?.outputTokens ?? null,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** @param {unknown} promptPath @returns {number|null} the dispatched prompt's byte size, when its file survives */
|
|
135
|
+
function promptBytes(promptPath) {
|
|
136
|
+
if (typeof promptPath !== "string" || !promptPath) return null;
|
|
137
|
+
try {
|
|
138
|
+
return statSync(promptPath).size;
|
|
139
|
+
} catch {
|
|
140
|
+
// A prompt file the run archived or cleaned away has no measured size.
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
111
144
|
/** @param {Usage|undefined} usage @returns {boolean} */
|
|
112
145
|
function hasMeasuredUsage(usage) {
|
|
113
146
|
return Boolean(usage && [usage.inputTokens, usage.outputTokens, usage.cacheReadInputTokens]
|
|
@@ -230,6 +263,15 @@ export function usageRecordIds(runDir) {
|
|
|
230
263
|
export function appendUsageRecord(runDir, invocation, options = {}) {
|
|
231
264
|
if (!invocation?.id || usageRecordIds(runDir).has(invocation.id)) return;
|
|
232
265
|
const usage = /** @type {Usage} */ (invocation.usage ?? { inputTokens: null, outputTokens: null, cacheReadInputTokens: null });
|
|
266
|
+
/** @type {string} */
|
|
267
|
+
let transcript = "";
|
|
268
|
+
try {
|
|
269
|
+
transcript = invocation.stdoutPath ? readBoundedTail(invocation.stdoutPath) : "";
|
|
270
|
+
} catch {
|
|
271
|
+
// A transcript the run already capped or cleaned away carries no session
|
|
272
|
+
// metrics; the counters it would map stay unrecorded.
|
|
273
|
+
}
|
|
274
|
+
const counters = invocationCounters(invocation, transcript);
|
|
233
275
|
const costProvenance = invocation.costProvenance ?? (typeof invocation.costUsd === "number" ? "provider" : "unknown");
|
|
234
276
|
const unknownReason = costProvenance === "unknown"
|
|
235
277
|
? unknownCostReason(invocation, usage, options)
|
|
@@ -245,6 +287,12 @@ export function appendUsageRecord(runDir, invocation, options = {}) {
|
|
|
245
287
|
inputTokens: typeof usage.inputTokens === "number" ? usage.inputTokens : null,
|
|
246
288
|
cacheReadInputTokens: typeof usage.cacheReadInputTokens === "number" ? usage.cacheReadInputTokens : null,
|
|
247
289
|
outputTokens: typeof usage.outputTokens === "number" ? usage.outputTokens : null,
|
|
290
|
+
// The R1 counters ride the same record as the priced cost, per role and
|
|
291
|
+
// attempt: bytes from the dispatched prompt file, cache writes from the
|
|
292
|
+
// transcript's session metrics. An unmeasured counter is omitted, so a
|
|
293
|
+
// pre-counter record keeps its exact old shape.
|
|
294
|
+
...(counters.bytes === null ? {} : { bytes: counters.bytes }),
|
|
295
|
+
...(counters.cacheWriteInputTokens === null ? {} : { cacheWriteInputTokens: counters.cacheWriteInputTokens }),
|
|
248
296
|
costUsd: typeof invocation.costUsd === "number" ? invocation.costUsd : null,
|
|
249
297
|
// A persisted `priced` marker wins; otherwise the pre-Phase-4 rule applies
|
|
250
298
|
// unchanged: a reported number is `provider`, absence is `unknown`.
|
|
@@ -482,7 +530,7 @@ function listNodeSnapshotFiles(runDir) {
|
|
|
482
530
|
// without recorded evidence stays null so the estimate can say
|
|
483
531
|
// `insufficient data` instead of inventing a zero.
|
|
484
532
|
|
|
485
|
-
/** @typedef {{runtimeId: string|null, model: string|null, costUsd: number|null, costProvenance: string|null}} CompletedExecutionRole */
|
|
533
|
+
/** @typedef {{runtimeId: string|null, model: string|null, costUsd: number|null, costProvenance: string|null, usage: UsageCounters|null}} CompletedExecutionRole */
|
|
486
534
|
/**
|
|
487
535
|
* One completed execution node, with the worker (and optional judge) role
|
|
488
536
|
* evidence the estimate compares against a planned assignment. `costUsd` is
|
|
@@ -692,7 +740,21 @@ function roleEvidence(role, usageRecords, invocations, contract, contractNode) {
|
|
|
692
740
|
const model = typeof last.model === "string" && last.model ? last.model : declaredModel;
|
|
693
741
|
const priced = source.every((record) => typeof record.costUsd === "number" && Number.isFinite(record.costUsd) && record.costProvenance === "priced");
|
|
694
742
|
const costUsd = priced ? source.reduce((total, record) => total + /** @type {number} */ (record.costUsd), 0) : null;
|
|
695
|
-
return {
|
|
743
|
+
return {
|
|
744
|
+
runtimeId,
|
|
745
|
+
model,
|
|
746
|
+
costUsd,
|
|
747
|
+
costProvenance: costUsd === null ? null : "priced",
|
|
748
|
+
// The role's R1 counters, strict like the cost: summed only when every
|
|
749
|
+
// invocation in the role measured each counter, else null.
|
|
750
|
+
usage: sumCounters(source.map((record) => ({
|
|
751
|
+
bytes: finite(record.bytes),
|
|
752
|
+
inputTokens: finite(record.inputTokens),
|
|
753
|
+
cacheReadInputTokens: finite(record.cacheReadInputTokens),
|
|
754
|
+
cacheWriteInputTokens: finite(record.cacheWriteInputTokens),
|
|
755
|
+
outputTokens: finite(record.outputTokens),
|
|
756
|
+
}))),
|
|
757
|
+
};
|
|
696
758
|
}
|
|
697
759
|
|
|
698
760
|
/**
|