faberun 0.31.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faberun",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/brand.mjs CHANGED
@@ -184,7 +184,8 @@ export function renderUsage() {
184
184
  "<resume|cancel> <run-dir> [--detach]",
185
185
  "supervise <run-dir> [--detach] [--interval <sec>]",
186
186
  "supervise campaign <campaign-id> [--cwd <dir>] [--allow-main]",
187
- "<status|report> <run-dir> [--json]",
187
+ "status <run-dir> [--json]",
188
+ "report <run-dir> [--json] [--html] [--video]",
188
189
  "findings <run-dir>",
189
190
  "doctor [<contract.json>] [--cwd <dir>] [--discover] [--json]",
190
191
  "setup [--yes] [--no-skill] [--harnesses <a,b>] [--worker <id>] [--judge <id>] [--json]",
package/src/cli.mjs CHANGED
@@ -16,8 +16,11 @@ import { faberunHome } from "./host/home.mjs";
16
16
  import { reassociateProject } from "./cli/project.mjs";
17
17
  import { colorLevel, renderBanner, renderUsage, statusToken } from "./cli/brand.mjs";
18
18
  import { noTransportWarning } from "./notify/index.mjs";
19
+ import { deliverToSessions, resolveSessionTargets } from "./notify/session.mjs";
19
20
  import { renderFindings, renderReport, renderReportJson, renderStatus, renderStatusJson } from "./report/render.mjs";
20
21
  import { renderNext, renderNextJson } from "./report/next.mjs";
22
+ import { generateReportHtml } from "./report/report-html.mjs";
23
+ import { generateReportVideo } from "./report/report-video.mjs";
21
24
 
22
25
  import {
23
26
  writeTextAtomic,
@@ -105,7 +108,7 @@ export const COMMAND_OPTIONS = {
105
108
  preflight: { static: { type: "boolean" }, json: { type: "boolean" }, "time-verification": { type: "boolean" } },
106
109
  validate: {},
107
110
  status: { json: { type: "boolean" } },
108
- report: { json: { type: "boolean" } },
111
+ report: { json: { type: "boolean" }, html: { type: "boolean" }, video: { type: "boolean" } },
109
112
  findings: {},
110
113
  doctor: { cwd: { type: "string" }, json: { type: "boolean" }, discover: { type: "boolean" }, env: { type: "boolean" } },
111
114
  models: { probe: { type: "boolean" }, json: { type: "boolean" } },
@@ -278,6 +281,23 @@ function superviseIntervalOf(value) {
278
281
  return seconds;
279
282
  }
280
283
 
284
+ /**
285
+ * Wake the operator's session with the report artifacts the command just
286
+ * wrote. Opt-in and lossy, exactly like every other notification: with no
287
+ * `FABERUN_NOTIFY_SESSION` target nothing is delivered and nothing blocks the
288
+ * command that already printed the paths.
289
+ *
290
+ * @param {{kind: "html"|"video", path: string}[]} artifacts
291
+ * @param {NodeJS.ProcessEnv} env
292
+ * @returns {Promise<void>}
293
+ */
294
+ async function notifyReportArtifacts(artifacts, env) {
295
+ const targets = resolveSessionTargets(env);
296
+ if (!targets.length || !artifacts.length) return;
297
+ const summary = ["🐦 faberun", ...artifacts.map((artifact) => `${artifact.kind === "video" ? "📹" : "📄"} ${artifact.path}`)].join(" · ");
298
+ await deliverToSessions({ type: "report", summary }, targets, { env });
299
+ }
300
+
281
301
  /**
282
302
  * @param {string[]} argv
283
303
  * @returns {Promise<void>}
@@ -548,7 +568,37 @@ async function main(argv) {
548
568
  return;
549
569
  }
550
570
  if (command === "report") {
551
- process.stdout.write(values.json === true ? renderReportJson(resolve(target)) : renderReport(resolve(target)));
571
+ const runDir = resolve(target);
572
+ if (values.json === true) {
573
+ process.stdout.write(renderReportJson(runDir));
574
+ return;
575
+ }
576
+ if (values.html === true) {
577
+ const result = generateReportHtml(runDir);
578
+ if (result.htmlPath === null) {
579
+ process.stderr.write(`[fail] ${result.code}\n`);
580
+ process.stdout.write(`[report] markdown · ${result.markdownPath}\n`);
581
+ process.exitCode = 1;
582
+ return;
583
+ }
584
+ process.stdout.write(`[report] markdown · ${result.markdownPath}\n`);
585
+ process.stdout.write(`[report] html · ${result.htmlPath}\n`);
586
+ await notifyReportArtifacts([{ kind: "html", path: result.htmlPath }], process.env);
587
+ return;
588
+ }
589
+ if (values.video === true) {
590
+ const result = await generateReportVideo(runDir, { env: process.env });
591
+ process.stdout.write(`[report] narration · ${result.narrationPath}\n`);
592
+ if (result.videoPath === null) {
593
+ process.stderr.write(`[fail] ${result.code}\n`);
594
+ process.exitCode = 1;
595
+ return;
596
+ }
597
+ process.stdout.write(`[report] video · ${result.videoPath}\n`);
598
+ await notifyReportArtifacts([{ kind: "video", path: result.videoPath }], process.env);
599
+ return;
600
+ }
601
+ process.stdout.write(renderReport(runDir));
552
602
  return;
553
603
  }
554
604
  if (command === "metrics") { process.stdout.write(renderCampaignMetrics(target, values)); return; }
@@ -38,6 +38,7 @@ import { alreadyNotified, emitNodeAdvisories, notifyQueueFor, notifyQueuesByRun,
38
38
  import { detectStalls, invocationAlive, terminateProcess } from "./process.mjs";
39
39
  import { transition, writeNode } from "./state.mjs";
40
40
  import { render, renderFinalReport, writeFindingsArtifact } from "../report/final.mjs";
41
+ import { generateReportHtml } from "../report/report-html.mjs";
41
42
  import { operationNextState, providerReceipts, settleInvocation } from "../run/operations.mjs";
42
43
  import { appendUsageRecord, invocationCost, invocationUsage, recordInvocationUsage } from "../run/usage.mjs";
43
44
  import { captureNodeScopeBoundaries, checkWorkerScope, emptyScope } from "./scope.mjs";
@@ -203,6 +204,24 @@ function archiveCanceledRunDir(runDir) {
203
204
  renameSync(runDir, archivedPath);
204
205
  }
205
206
 
207
+ /**
208
+ * The portable report copy, generated at run terminal so the operator always
209
+ * has a browser-readable report beside `STATUS.md`. Lossy on purpose: a
210
+ * missing mdhtml or an unreadable run yields no artifact and no error, because
211
+ * the run's own terminal work is already done and the report must not block it.
212
+ *
213
+ * @param {string} runDir
214
+ * @returns {{kind: "html", path: string}[]}
215
+ */
216
+ function renderReportHtmlArtifacts(runDir) {
217
+ try {
218
+ const result = generateReportHtml(runDir);
219
+ return result.htmlPath ? [{ kind: "html", path: result.htmlPath }] : [];
220
+ } catch {
221
+ return [];
222
+ }
223
+ }
224
+
206
225
  /**
207
226
  * @param {string} contractPath
208
227
  * @param {{detachedBootstrap?: boolean, baseRef?: string}} [options]
@@ -752,6 +771,7 @@ export async function driveRun(contract, runDir, states, campaign, lock, sourceI
752
771
  renderStatusIfChanged(true, null);
753
772
  renderCampaignHandoffSafely(campaign, runsDir, runDir);
754
773
  writeFindingsArtifact(runDir, contract, states);
774
+ const artifacts = renderReportHtmlArtifacts(runDir);
755
775
  const failed = [...states.values()].filter((state) => state.status !== "done");
756
776
  const runId = basename(runDir);
757
777
  const runDedupeKey = `run.terminal:${runId}:${failed.length ? "attention" : "done"}`;
@@ -763,6 +783,7 @@ export async function driveRun(contract, runDir, states, campaign, lock, sourceI
763
783
  done: states.size - failed.length,
764
784
  total: states.size,
765
785
  dedupeKey: runDedupeKey,
786
+ artifacts,
766
787
  });
767
788
  }
768
789
  // Delivery is lossy: there is no retry budget to wait out, so the controller
@@ -245,7 +245,7 @@ export const NOTIFY_NO_TRANSPORT_WARNING = "no human notification transport is c
245
245
  /** @typedef {Record<string, unknown>} JsonObject */
246
246
  /** @typedef {{schemaVersion: number, eventId: string, at: string, type: string, campaignId: string|null, runId: string|null, nodeId: string|null, status: string|null, errorCode: string|null, dedupeKey: string, summary: string}} InboxEntry */
247
247
  /** @typedef {{type: string, dedupeKey: string, summary: string, at?: string, campaignId?: string|null, runId?: string|null, nodeId?: string|null, status?: string|null, errorCode?: string|null}} InboxEvent */
248
- /** @typedef {{type: "node.terminal"|"run.terminal"|"attention"|"advisory"|"progress", runId: string|null, campaignId?: string|null, nodeId?: string|null, status?: string|null, attempt?: number|null, errorCode?: string|null, done?: number|null, total?: number|null, dedupeKey?: string|null, runDir?: string|null, costUsd?: number|null, summary?: string|null, eventId?: string, editOfMessageId?: string|null}} NotifyEvent */
248
+ /** @typedef {{type: "node.terminal"|"run.terminal"|"attention"|"advisory"|"progress", runId: string|null, campaignId?: string|null, nodeId?: string|null, status?: string|null, attempt?: number|null, errorCode?: string|null, done?: number|null, total?: number|null, dedupeKey?: string|null, runDir?: string|null, costUsd?: number|null, summary?: string|null, eventId?: string, editOfMessageId?: string|null, artifacts?: {kind: "html"|"video", path: string}[]}} NotifyEvent */
249
249
  /** @typedef {{id: string, ok: boolean, error?: string, messageId?: string}} TransportOutcome one transport's own outcome, named so the receipt says which took the message; `messageId` is the id a transport receipt named for the message it created or edited */
250
250
  /** @typedef {{ok: boolean, error?: string, noTransport?: boolean, messageId?: string, transports?: TransportOutcome[]}} DeliveryResult */
251
251
 
@@ -425,12 +425,14 @@ function link(path) {
425
425
  }
426
426
 
427
427
  /**
428
- * Escape a table cell and collapse it to one line.
428
+ * Escape a table cell and collapse it to one line. Exported so the report's
429
+ * HTML surface (`report-html.mjs`) shares this one home instead of carrying a
430
+ * byte-identical copy.
429
431
  *
430
432
  * @param {string} value
431
433
  * @returns {string}
432
434
  */
433
- function cell(value) {
435
+ export function cell(value) {
434
436
  return value.replace(/\|/gu, "\\|").replace(/\s+/gu, " ").trim();
435
437
  }
436
438
 
@@ -124,7 +124,7 @@ export function renderRunProgress(runDir, event) {
124
124
  const view = { runDir, runId: event.runId ?? basename(runDir), payload, objectives, snapshots, campaign, label, subject };
125
125
 
126
126
  const body = event.type === "run.terminal" ? runTerminal(view) : event.type === "attention" ? attention(view) : nodeTerminal(view);
127
- const lines = [...body, RULE, footer(view, event.type), ...updateLine(label)];
127
+ const lines = [...body, ...artifactLines(event.artifacts), RULE, footer(view, event.type), ...updateLine(label)];
128
128
  return boundToCeiling(lines);
129
129
  }
130
130
 
@@ -614,6 +614,18 @@ function formatUsdCompact(value) {
614
614
  return formatUsd(value);
615
615
  }
616
616
 
617
+ /**
618
+ * The run's readable artifacts, one line each with the glyph the report
619
+ * command prints beside it. The path is data, not wording, so the line needs
620
+ * no label and stays out of the operator's language.
621
+ *
622
+ * @param {{kind: "html"|"video", path: string}[]|undefined} artifacts
623
+ * @returns {string[]}
624
+ */
625
+ function artifactLines(artifacts) {
626
+ return (artifacts ?? []).map((artifact) => `${artifact.kind === "video" ? "📹" : "📄"} ${artifact.path}`);
627
+ }
628
+
617
629
  /**
618
630
  * The delivered bullets are the only block that grows with the run, so they
619
631
  * are dropped from the end first when the whole message would exceed the
@@ -448,7 +448,7 @@ export function renderReport(runDir) {
448
448
  const totals = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, costUsd: null };
449
449
  const costs = nodes.map(costProjection);
450
450
  const aggregateCost = aggregateCostProjection(costs);
451
- 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)))];
451
+ 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", "USD", "NOTE"]), row(widths.map((width) => "-".repeat(width)))];
452
452
  for (const [index, node] of nodes.entries()) {
453
453
  const usage = node.usage ?? { inputTokens: null, outputTokens: null, cacheReadInputTokens: null };
454
454
  for (const key of /** @type {("inputTokens"|"outputTokens"|"cacheReadInputTokens")[]} */ (Object.keys(totals).filter((key) => key !== "costUsd"))) totals[key] = (totals[key] ?? 0) + (usage[key] ?? 0);
@@ -0,0 +1,235 @@
1
+ /**
2
+ * The run report's HTML boundary: the same contract `campaign-brief-html.mjs`
3
+ * owns for the campaign brief — Markdown is the durable source, the portable
4
+ * `.md.html` is a copy that only survives a build, check and round-trip — but
5
+ * for `report`, the surface the operator reads a run's attempts, tokens and
6
+ * cost from. It renders the ASCII table as a Markdown table and, when the
7
+ * phase has a dependency topology, an inline SVG of the work graph instead of
8
+ * a list, then hands the Markdown to that one renderer rather than opening a
9
+ * second markdown→HTML path.
10
+ *
11
+ * It is a module apart from `render.mjs` because that file owns the terminal
12
+ * and JSON surfaces and is already near the 800-line ceiling; the HTML copy is
13
+ * a different audience (a browser, not a terminal) and a different lifetime
14
+ * (a file beside the Markdown, not stdout).
15
+ */
16
+ import { readFileSync, rmSync } from "node:fs";
17
+ import { basename, join } from "node:path";
18
+ import { renderReportJson } from "./render.mjs";
19
+ import { cell } from "./campaign-brief.mjs";
20
+ import { CampaignBriefRenderError, renderCampaignBriefHtml } from "./campaign-brief-html.mjs";
21
+ import { formatCost, formatRole } from "./role-usage.mjs";
22
+ import { compactTokens } from "../util.mjs";
23
+ import { writeTextAtomic } from "../run/store.mjs";
24
+
25
+ /** The durable source and its portable copy, beside `STATUS.md` in the run directory. */
26
+ const REPORT_MD_FILE = "report.md";
27
+ const REPORT_HTML_FILE = "report.md.html";
28
+
29
+ /** @typedef {{id: string, status: string, dependsOn: string[]}} GraphNode */
30
+ /** @typedef {import("./role-usage.mjs").RoleUsage} RoleUsage */
31
+ /** @typedef {import("./role-usage.mjs").CostProjection} CostProjection */
32
+ /** @typedef {{id: string, status: string, attempt: number, revisions: number, runtime: string|null, usage: {inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}|null, costUsd: number|null, costStatus: CostProjection["status"], note: string|null}} ReportNode */
33
+ /** @typedef {{run: string, summary: string, totals: {inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null, costStatus: CostProjection["status"]}, roles: {worker: RoleUsage, judge: RoleUsage}, nodes: ReportNode[]}} ReportPayload */
34
+
35
+ /**
36
+ * The work graph as inline SVG, or null when every node is
37
+ * dependency-independent and a list reads better than a diagram. The layout is
38
+ * the same depth/row scheme the dashboard uses (`src/web/app.mjs`): a node's
39
+ * column is its dependency depth, its row is its position among the siblings
40
+ * at that depth. Self-contained: inline colours, no stylesheet, no external
41
+ * reference, so the rendered copy stays offline.
42
+ *
43
+ * @param {GraphNode[]} nodes
44
+ * @returns {string|null}
45
+ */
46
+ export function renderReportGraphSvg(nodes) {
47
+ const hasEdges = nodes.some((node) => (node.dependsOn ?? []).length > 0);
48
+ if (!hasEdges) return null;
49
+ const laidOut = layoutGraphNodes(nodes);
50
+ const byId = new Map(laidOut.map((node) => [node.id, node]));
51
+ const maxDepth = laidOut.reduce((max, node) => Math.max(max, node.depth), 0);
52
+ const maxRow = laidOut.reduce((max, node) => Math.max(max, node.row), 0);
53
+ const width = NODE_MARGIN * 2 + (maxDepth + 1) * NODE_COL_WIDTH;
54
+ const height = NODE_MARGIN * 2 + (maxRow + 1) * NODE_ROW_HEIGHT;
55
+ const posOf = (/** @type {{depth: number, row: number}} */ node) => ({ x: NODE_MARGIN + node.depth * NODE_COL_WIDTH, y: NODE_MARGIN + node.row * NODE_ROW_HEIGHT });
56
+ const edges = laidOut.flatMap((node) => (node.dependsOn ?? [])
57
+ .filter((/** @type {string} */ dep) => byId.has(dep))
58
+ .map((/** @type {string} */ dep) => {
59
+ const from = posOf(/** @type {{depth: number, row: number}} */ (byId.get(dep)));
60
+ const to = posOf(node);
61
+ const x1 = from.x + NODE_BOX_W;
62
+ const y1 = from.y + NODE_BOX_H / 2;
63
+ const x2 = to.x;
64
+ const y2 = to.y + NODE_BOX_H / 2;
65
+ const midX = (x1 + x2) / 2;
66
+ return `<path d="M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}" fill="none" stroke="#D97B4F" stroke-width="2"/>`;
67
+ })).join("");
68
+ const boxes = laidOut.map((node, index) => {
69
+ const { x, y } = posOf(node);
70
+ const clipId = `nodeclip${index}`;
71
+ const colour = STATUS_COLOURS[node.status] ?? "#1F1F1F";
72
+ return `<g><rect x="${x}" y="${y}" width="${NODE_BOX_W}" height="${NODE_BOX_H}" rx="8" fill="#F4E9D8" stroke="${colour}" stroke-width="2"/><clipPath id="${clipId}"><rect x="${x}" y="${y}" width="${NODE_BOX_W}" height="${NODE_BOX_H}"/></clipPath><g clip-path="url(#${clipId})"><text x="${x + 10}" y="${y + 22}" font-family="ui-monospace, monospace" font-size="12" fill="#1F1F1F">${escapeSvgText(truncateOneLine(node.id, NODE_ID_MAX_CHARS))}</text><text x="${x + 10}" y="${y + 40}" font-family="ui-sans-serif, sans-serif" font-size="12" fill="${colour}">${escapeSvgText(node.status)}</text></g></g>`;
73
+ }).join("");
74
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-label="work graph">${edges}${boxes}</svg>`;
75
+ }
76
+
77
+ /**
78
+ * `report`'s Markdown: the same facts `report --json` carries, laid out for a
79
+ * browser. The node table mirrors the ASCII table's columns (with `USD`, the
80
+ * one term both surfaces now share) and the work graph renders as a diagram
81
+ * when there are dependencies, else as a list.
82
+ *
83
+ * @param {string} runDir
84
+ * @returns {string}
85
+ */
86
+ export function renderReportMarkdown(runDir) {
87
+ const payload = /** @type {ReportPayload} */ (JSON.parse(renderReportJson(runDir)));
88
+ const dependsOn = readContractDependsOn(runDir);
89
+ const graph = renderReportGraphSvg(payload.nodes.map((node) => ({ id: node.id, status: node.status, dependsOn: dependsOn.get(node.id) ?? [] })));
90
+ const totals = payload.totals;
91
+ const summary = `${payload.summary} · in ${compactTokens(totals.inputTokens)} · out ${compactTokens(totals.outputTokens)} · cache ${compactTokens(totals.cacheReadInputTokens)} · cost ${formatCost({ costUsd: totals.costUsd, status: totals.costStatus })}`;
92
+
93
+ const header = "| NODE | STATE | TRY | REV | RUNTIME | IN | OUT | CACHE | USD | NOTE |";
94
+ const separator = "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |";
95
+ const rows = payload.nodes.map((node) => {
96
+ const usage = node.usage ?? { inputTokens: null, outputTokens: null, cacheReadInputTokens: null };
97
+ return `| \`${cell(node.id)}\` | ${cell(node.status)} | ${node.attempt} | ${node.revisions} | ${cell(node.runtime ?? "-")} | ${compactTokens(usage.inputTokens)} | ${compactTokens(usage.outputTokens)} | ${compactTokens(usage.cacheReadInputTokens)} | ${cell(formatCost({ costUsd: node.costUsd, status: node.costStatus }))} | ${cell(node.note ?? "-")} |`;
98
+ });
99
+
100
+ const graphSection = graph
101
+ ? `## Work graph\n\n${graph}`
102
+ : `## Work graph\n\nDependency-independent nodes: ${payload.nodes.map((node) => `\`${node.id}\``).join(", ")}.`;
103
+
104
+ const totalsLine = `totals · in ${compactTokens(totals.inputTokens)} · out ${compactTokens(totals.outputTokens)} · cache ${compactTokens(totals.cacheReadInputTokens)} · worker ${formatRole(payload.roles.worker)} · judge ${formatRole(payload.roles.judge)} · cost ${formatCost({ costUsd: totals.costUsd, status: totals.costStatus })}`;
105
+
106
+ return [
107
+ `# Report — ${payload.run}`,
108
+ "",
109
+ summary,
110
+ "",
111
+ graphSection,
112
+ "",
113
+ "## Nodes",
114
+ "",
115
+ header,
116
+ separator,
117
+ ...rows,
118
+ "",
119
+ "## Totals",
120
+ "",
121
+ totalsLine,
122
+ "",
123
+ ].join("\n");
124
+ }
125
+
126
+ /**
127
+ * Write `report.md` (the durable source) and render its portable copy, exactly
128
+ * as the campaign brief does: the Markdown is always written first, the HTML
129
+ * only survives a successful build, check and round-trip, and a renderer
130
+ * failure removes any prior HTML and reports the renderer's named code. The
131
+ * text path — `renderReport` — is untouched and works without mdhtml.
132
+ *
133
+ * @param {string} runDir
134
+ * @param {{mdhtmlBin?: string, cwd?: string, renderHtml?: (markdown: string, options: import("./campaign-brief-html.mjs").RenderCampaignBriefHtmlOptions) => import("./campaign-brief-html.mjs").RenderCampaignBriefHtmlResult}} [options]
135
+ * @returns {{markdownPath: string, htmlPath: string|null, code: string|null}}
136
+ */
137
+ export function generateReportHtml(runDir, options = {}) {
138
+ const markdown = renderReportMarkdown(runDir);
139
+ const markdownPath = join(runDir, REPORT_MD_FILE);
140
+ const htmlPath = join(runDir, REPORT_HTML_FILE);
141
+ writeTextAtomic(markdownPath, markdown);
142
+ const renderHtml = options.renderHtml ?? renderCampaignBriefHtml;
143
+ try {
144
+ renderHtml(markdown, { outputPath: htmlPath, title: `Report — ${basename(runDir)}`, mdhtmlBin: options.mdhtmlBin, cwd: options.cwd });
145
+ } catch (error) {
146
+ rmSync(htmlPath, { force: true });
147
+ const code = error instanceof CampaignBriefRenderError ? error.code : "REPORT_HTML_FAILED";
148
+ return { markdownPath, htmlPath: null, code };
149
+ }
150
+ return { markdownPath, htmlPath, code: null };
151
+ }
152
+
153
+ /**
154
+ * The contract's per-node `dependsOn`, read tolerantly: `renderReportJson` has
155
+ * already validated the contract, so this fallback only guards a race where
156
+ * the file changes between the two reads.
157
+ *
158
+ * @param {string} runDir
159
+ * @returns {Map<string, string[]>}
160
+ */
161
+ export function readContractDependsOn(runDir) {
162
+ try {
163
+ const contract = /** @type {{nodes?: {id?: unknown, dependsOn?: unknown}[]}} */ (JSON.parse(readFileSync(join(runDir, "contract.json"), "utf8")));
164
+ return new Map((contract.nodes ?? []).map((node) => [
165
+ String(node.id),
166
+ Array.isArray(node.dependsOn) ? node.dependsOn.filter((dep) => typeof dep === "string") : [],
167
+ ]));
168
+ } catch {
169
+ return new Map();
170
+ }
171
+ }
172
+
173
+ const NODE_COL_WIDTH = 260;
174
+ const NODE_ROW_HEIGHT = 84;
175
+ const NODE_BOX_W = 220;
176
+ const NODE_BOX_H = 56;
177
+ const NODE_MARGIN = 16;
178
+ const NODE_ID_MAX_CHARS = 28;
179
+
180
+ /** One fill per terminal state, the same earthy palette the brief theme declares. */
181
+ /** @type {Record<string, string | undefined>} */
182
+ export const STATUS_COLOURS = {
183
+ done: "#556B3F",
184
+ "no-op": "#556B3F",
185
+ running: "#D97B4F",
186
+ pending: "#1F1F1F",
187
+ blocked: "#B5522A",
188
+ failed: "#B5522A",
189
+ exhausted: "#B5522A",
190
+ stalled: "#B5522A",
191
+ canceled: "#B5522A",
192
+ };
193
+
194
+ /**
195
+ * A node's column is its dependency depth; its row is its position among the
196
+ * siblings at that depth (the dashboard's own scheme). Exported so the video
197
+ * scene (`report-video.mjs`) reuses this one layout instead of carrying a
198
+ * copy.
199
+ *
200
+ * @param {GraphNode[]} nodes
201
+ * @returns {(GraphNode & {depth: number, row: number})[]}
202
+ */
203
+ export function layoutGraphNodes(nodes) {
204
+ const byId = new Map(nodes.map((node) => [node.id, node]));
205
+ const depthCache = new Map();
206
+ /** @param {string} id @param {Set<string>} seen @returns {number} */
207
+ const depthOf = (id, seen) => {
208
+ if (depthCache.has(id)) return /** @type {number} */ (depthCache.get(id));
209
+ if (seen.has(id)) return 0;
210
+ seen.add(id);
211
+ const node = byId.get(id);
212
+ const deps = (node?.dependsOn ?? []).filter((dep) => byId.has(dep));
213
+ const depth = deps.length === 0 ? 0 : 1 + Math.max(...deps.map((dep) => depthOf(dep, seen)));
214
+ depthCache.set(id, depth);
215
+ return depth;
216
+ };
217
+ const rowCounters = new Map();
218
+ return nodes.map((node) => {
219
+ const depth = depthOf(node.id, new Set());
220
+ const row = rowCounters.get(depth) ?? 0;
221
+ rowCounters.set(depth, row + 1);
222
+ return { ...node, depth, row };
223
+ });
224
+ }
225
+
226
+ /** @param {string} value @returns {string} */
227
+ export function escapeSvgText(value) {
228
+ return value.replace(/[&<>"']/gu, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch] ?? ch));
229
+ }
230
+
231
+ /** @param {string} value @param {number} max @returns {string} */
232
+ function truncateOneLine(value, max) {
233
+ const flat = value.replace(/\s+/gu, " ").trim();
234
+ return flat.length <= max ? flat : `${flat.slice(0, Math.max(0, max - 1))}…`;
235
+ }
@@ -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
+ }
@@ -440,6 +440,7 @@ export async function startServer({ runsDir, tokenFile, port = 4173, host = "127
440
440
  if (asset) return sendFile(response, join(HERE, asset.file), asset.type);
441
441
  if (url.pathname === "/api/snapshot") return sendJson(response, buildSnapshot(runsDir, selectionOf(url)));
442
442
  if (url.pathname === "/api/stream") return streamSnapshots(request, response, runsDir, selectionOf(url), pollMs);
443
+ if (url.pathname === "/api/artifact") return serveRunArtifact(response, url, runsDir);
443
444
  if (url.pathname.startsWith("/api/")) {
444
445
  // A phone client that hangs up mid-request must not take the daemon
445
446
  // down as an unhandled rejection; the router catches its own errors,
@@ -536,6 +537,37 @@ function sendFile(response, path, type) {
536
537
  response.end(readFileSync(path));
537
538
  }
538
539
 
540
+ /** The exact run artifacts the dashboard serves; the name is the allowlist. */
541
+ const ARTIFACT_TYPES = {
542
+ "report.md.html": "text/html; charset=utf-8",
543
+ "report.mp4": "video/mp4",
544
+ };
545
+
546
+ /**
547
+ * Serve one run's report artifact behind the same bearer check as every other
548
+ * route. `run` is a bare run directory name and `file` one of the two names the
549
+ * report commands write, so nothing else under the runs root is reachable.
550
+ *
551
+ * @param {import("node:http").ServerResponse} response
552
+ * @param {URL} url
553
+ * @param {string} runsDir
554
+ */
555
+ function serveRunArtifact(response, url, runsDir) {
556
+ const run = url.searchParams.get("run") ?? "";
557
+ const file = url.searchParams.get("file") ?? "";
558
+ const type = /** @type {Record<string, string>} */ (ARTIFACT_TYPES)[file];
559
+ if (!type || !/^[A-Za-z0-9_.-]+$/.test(run) || run === "." || run === "..") return notFound(response);
560
+ const artifactPath = join(runsDir, run, file);
561
+ if (!existsSync(artifactPath)) return notFound(response);
562
+ sendFile(response, artifactPath, type);
563
+ }
564
+
565
+ /** @param {import("node:http").ServerResponse} response */
566
+ function notFound(response) {
567
+ response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
568
+ response.end("not found");
569
+ }
570
+
539
571
  /** @param {import("node:http").ServerResponse} response @param {unknown} payload */
540
572
  function sendJson(response, payload) {
541
573
  response.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });