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 +1 -1
- package/src/cli/brand.mjs +2 -1
- package/src/cli.mjs +52 -2
- package/src/engine/scheduler.mjs +21 -0
- package/src/notify/index.mjs +1 -1
- package/src/report/campaign-brief.mjs +4 -2
- package/src/report/message.mjs +13 -1
- 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/web/server.mjs +32 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "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
|
-
"
|
|
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
|
-
|
|
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; }
|
package/src/engine/scheduler.mjs
CHANGED
|
@@ -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
|
package/src/notify/index.mjs
CHANGED
|
@@ -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
|
|
package/src/report/message.mjs
CHANGED
|
@@ -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
|
package/src/report/render.mjs
CHANGED
|
@@ -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", "
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
|
+
}
|
package/src/web/server.mjs
CHANGED
|
@@ -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" });
|