faberun 0.10.0 → 0.12.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/README.md +6 -0
- package/integrations/claude-code/statusline.sh +31 -2
- package/package.json +2 -2
- package/skills/faberun/references/spec-format.md +9 -0
- package/src/campaign/chain.mjs +2 -1
- package/src/campaign/index.mjs +102 -1
- package/src/campaign/metrics.mjs +2 -2
- package/src/cli/brand.mjs +2 -0
- package/src/cli/campaign-contract.mjs +55 -0
- package/src/cli/campaign.mjs +48 -13
- package/src/cli/init.mjs +8 -2
- package/src/cli/launch.mjs +2 -1
- package/src/cli/plan.mjs +3 -2
- package/src/cli/project.mjs +150 -0
- package/src/cli/skills.mjs +2 -3
- package/src/cli/update.mjs +7 -10
- package/src/cli.mjs +30 -4
- package/src/contract/index.mjs +44 -0
- package/src/contract/task-packet.mjs +12 -3
- package/src/engine/cancel.mjs +13 -0
- package/src/engine/dispatch.mjs +20 -2
- package/src/engine/lifecycle.mjs +38 -61
- package/src/engine/live-preflight.mjs +2 -1
- package/src/engine/notify-queue.mjs +9 -1
- package/src/engine/pricing-seed.json +543 -0
- package/src/engine/pricing-seed.mjs +54 -0
- package/src/engine/process.mjs +8 -1
- package/src/engine/result-file.mjs +2 -1
- package/src/engine/resume.mjs +6 -2
- package/src/engine/run-identity.mjs +9 -2
- package/src/engine/scheduler.mjs +40 -6
- package/src/engine/settle-judge.mjs +141 -0
- package/src/engine/settle.mjs +1 -1
- package/src/harnesses/protocol.mjs +46 -10
- package/src/harnesses/replay/bin.mjs +10 -1
- package/src/host/home.mjs +10 -3
- package/src/host/platform.mjs +111 -0
- package/src/host/preflight.mjs +43 -21
- package/src/host/projects.mjs +155 -0
- package/src/notify/index.mjs +88 -10
- package/src/plan/pipeline.mjs +32 -9
- package/src/plan/repo-facts.mjs +80 -2
- package/src/plan/spec.mjs +2 -1
- package/src/repo/declared-paths.mjs +7 -1
- package/src/repo/integrate.mjs +1 -1
- package/src/repo/scope-closure.mjs +2 -1
- package/src/repo/signal.mjs +24 -7
- package/src/repo/source-identity.mjs +7 -0
- package/src/repo/workspace.mjs +12 -2
- package/src/repo/worktree.mjs +89 -22
- package/src/report/next.mjs +66 -6
- package/src/report/progress.mjs +724 -0
- package/src/report/render.mjs +3 -3
- package/src/run/migrate.mjs +278 -0
- package/src/run/paths.mjs +189 -0
- package/src/seat/index.mjs +2 -1
- package/src/web/app.css +170 -0
- package/src/web/app.mjs +690 -0
- package/src/web/index.html +38 -283
- package/src/web/server.mjs +197 -182
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two renderers sharing one module, so a notification and a campaign page can
|
|
3
|
+
* never disagree about progress:
|
|
4
|
+
*
|
|
5
|
+
* `renderRunProgress` -- one message for a human, from a run's persisted
|
|
6
|
+
* state plus one terminal event: campaign, phase and node with a glyph for
|
|
7
|
+
* the node's outcome; percent and count done and left; this node's own span;
|
|
8
|
+
* the phase's estimated remaining time, from settled spans alone; the next
|
|
9
|
+
* node in the phase, or that the phase is complete; what the node delivered,
|
|
10
|
+
* in the worker's own words; and cost by role plus the campaign's cumulative
|
|
11
|
+
* cost.
|
|
12
|
+
*
|
|
13
|
+
* `renderCampaignProgress` -- the campaign-wide roll-up, answering the
|
|
14
|
+
* owner's four questions from persisted state alone: what the campaign
|
|
15
|
+
* delivered (per phase, its newest settled node's own words), what progress
|
|
16
|
+
* it has made (done, settled and total across every phase, kept two
|
|
17
|
+
* different numbers), what it cost (the campaign total plus per-role cost
|
|
18
|
+
* with the three token counts carried beside it -- a role only partly
|
|
19
|
+
* priced reports the dollars on record and its provenance still says
|
|
20
|
+
* `partial`), and how long it ran -- to the record's own `closedAt` once it
|
|
21
|
+
* has one -- with a labelled estimate of what is left. The phase list is the
|
|
22
|
+
* campaign record's linked runs -- the history -- followed by every manifest
|
|
23
|
+
* contract with no run yet -- the queue: the manifest alone is not the
|
|
24
|
+
* history, because the orchestrator replaces its entry each phase and
|
|
25
|
+
* forgets (measured 2026-09-18: manifest 1 contract, linkedRunIds 22, 20 run
|
|
26
|
+
* directories on disk), while the record does not. A manifest contract whose
|
|
27
|
+
* authored file is gone is left out rather than allowed to abort the render:
|
|
28
|
+
* one missing file must not cost the page all four answers.
|
|
29
|
+
*
|
|
30
|
+
* Every number here comes from a reader that already owns it -- the status
|
|
31
|
+
* payload (`renderStatusJson`, which already carries per-node phase, spans,
|
|
32
|
+
* cost and per-role usage with provenance), the raw node snapshot (for the
|
|
33
|
+
* worker's own `result.summary`, which the status payload does not carry),
|
|
34
|
+
* and, for the campaign's money and tokens, the campaign record's own
|
|
35
|
+
* `linkedRunIds`: each linked run's `usage.jsonl`, summed through the same
|
|
36
|
+
* per-role arithmetic the status payload uses for this run alone. Nothing
|
|
37
|
+
* here recomputes what one of those already answers.
|
|
38
|
+
*/
|
|
39
|
+
import { MARK, formatDuration, formatRole, readRunUsage, renderStatusJson, roleUsage } from "./render.mjs";
|
|
40
|
+
import { nodeSnapshotPath, readNodeSnapshot } from "../run/node-store.mjs";
|
|
41
|
+
import { boundedUtf8, compactCost, finite } from "../util.mjs";
|
|
42
|
+
import { SETTLED, SUCCESS } from "../engine/prompts.mjs";
|
|
43
|
+
import { campaignDir } from "../campaign/layout.mjs";
|
|
44
|
+
import { readCampaign } from "../campaign/record.mjs";
|
|
45
|
+
import { computeNextItems } from "./next.mjs";
|
|
46
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
47
|
+
import { dirname, join, resolve } from "node:path";
|
|
48
|
+
import { runDirectory } from "../run/paths.mjs";
|
|
49
|
+
|
|
50
|
+
/** @typedef {import("./render.mjs").StatusPayloadNode} StatusPayloadNode */
|
|
51
|
+
/** @typedef {import("../notify/index.mjs").NotifyEvent} NotifyEvent */
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The message's byte ceiling. A three-node run, both roles priced and a
|
|
55
|
+
* two-sentence worker summary, measures ~430 bytes; this leaves headroom for
|
|
56
|
+
* a long campaign/phase/node id triple while still bounding an adversarial
|
|
57
|
+
* worker summary (the protocol's own 4 KiB ceiling), which is the only
|
|
58
|
+
* unbounded block and the one truncated first.
|
|
59
|
+
*/
|
|
60
|
+
export const PROGRESS_MESSAGE_MAX_BYTES = 2048;
|
|
61
|
+
|
|
62
|
+
const NO_SUMMARY = "(no summary recorded)";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {string} runDir
|
|
66
|
+
* @param {NotifyEvent} event
|
|
67
|
+
* @returns {string}
|
|
68
|
+
*/
|
|
69
|
+
export function renderRunProgress(runDir, event) {
|
|
70
|
+
const payload = /** @type {{campaignId: string, usage: {costUsd: number|null}, roles: {worker: import("./render.mjs").RoleUsage, judge: import("./render.mjs").RoleUsage}, nodes: StatusPayloadNode[]}} */
|
|
71
|
+
(JSON.parse(renderStatusJson(runDir)));
|
|
72
|
+
const subject = subjectNode(payload.nodes, event.nodeId ?? null);
|
|
73
|
+
const raw = readNodeSnapshot(runDir, subject.id);
|
|
74
|
+
const phaseNodes = payload.nodes.filter((node) => node.phase === subject.phase);
|
|
75
|
+
const total = payload.nodes.length;
|
|
76
|
+
const done = payload.nodes.filter((node) => SETTLED.has(node.status)).length;
|
|
77
|
+
const donePct = total ? Math.round((done / total) * 100) : 0;
|
|
78
|
+
|
|
79
|
+
const lines = [
|
|
80
|
+
`${MARK[subject.status] ?? "[?]"} campaign ${payload.campaignId} · phase ${subject.phase ?? "-"} · node ${subject.id}`,
|
|
81
|
+
`${done}/${total} nodes done · ${donePct}% done · ${100 - donePct}% left`,
|
|
82
|
+
`this node: ${spanOf(subject) ?? "-"}`,
|
|
83
|
+
phaseEstimateLine(phaseNodes),
|
|
84
|
+
nextNodeLine(phaseNodes),
|
|
85
|
+
];
|
|
86
|
+
if (event.type === "attention") lines.push(attentionLine(runDir, subject, raw));
|
|
87
|
+
const valueLineIndex = lines.push(valueLine(raw)) - 1;
|
|
88
|
+
lines.push(costLine(runDir, payload));
|
|
89
|
+
|
|
90
|
+
return boundToCeiling(lines, valueLineIndex);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The event's own node when it names one and the run still has it, else the
|
|
95
|
+
* most recently settled node (a `run.terminal` event names the run, not a
|
|
96
|
+
* node), else the run's first node.
|
|
97
|
+
*
|
|
98
|
+
* @param {StatusPayloadNode[]} nodes
|
|
99
|
+
* @param {string|null} nodeId
|
|
100
|
+
* @returns {StatusPayloadNode}
|
|
101
|
+
*/
|
|
102
|
+
function subjectNode(nodes, nodeId) {
|
|
103
|
+
const named = nodeId ? nodes.find((node) => node.id === nodeId) : undefined;
|
|
104
|
+
if (named) return named;
|
|
105
|
+
const settled = nodes.filter((node) => SETTLED.has(node.status) && node.updatedAt);
|
|
106
|
+
const latest = settled.sort((left, right) => String(left.updatedAt).localeCompare(String(right.updatedAt))).at(-1);
|
|
107
|
+
return latest ?? nodes[0];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** @param {{startedAt: string|null, updatedAt: string|null}} node @returns {number|null} */
|
|
111
|
+
function spanMs(node) {
|
|
112
|
+
if (!node.startedAt || !node.updatedAt) return null;
|
|
113
|
+
const start = Date.parse(node.startedAt);
|
|
114
|
+
const end = Date.parse(node.updatedAt);
|
|
115
|
+
return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** @param {{startedAt: string|null, updatedAt: string|null}} node @returns {string|null} */
|
|
119
|
+
function spanOf(node) {
|
|
120
|
+
const ms = spanMs(node);
|
|
121
|
+
return ms === null ? null : formatDuration(ms);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The remaining-time estimate, shared by the single-run message and the
|
|
126
|
+
* campaign roll-up so neither invents a second one: the mean span of the
|
|
127
|
+
* settled nodes alone -- a running node's partial elapsed is never in
|
|
128
|
+
* `SETTLED`, so it never drags the mean down and the estimate forward --
|
|
129
|
+
* times how many nodes have not settled. `"complete"` when nothing is left;
|
|
130
|
+
* `null` when nothing has settled to base the estimate on -- absent, never
|
|
131
|
+
* zero, the same refusal this page applies to requirement coverage.
|
|
132
|
+
*
|
|
133
|
+
* @param {{status: string}[]} nodes every node the estimate covers
|
|
134
|
+
* @param {{status: string, startedAt: string|null, updatedAt: string|null}[]} spans the nodes that carry the timestamps to average
|
|
135
|
+
* @returns {string|null}
|
|
136
|
+
*/
|
|
137
|
+
function remainingEstimate(nodes, spans) {
|
|
138
|
+
const unsettled = nodes.filter((node) => !SETTLED.has(node.status)).length;
|
|
139
|
+
if (!unsettled) return "complete";
|
|
140
|
+
const settledSpans = spans.filter((node) => SETTLED.has(node.status)).map(spanMs).filter((ms) => ms !== null);
|
|
141
|
+
if (!settledSpans.length) return null;
|
|
142
|
+
const meanMs = settledSpans.reduce((total, ms) => total + /** @type {number} */ (ms), 0) / settledSpans.length;
|
|
143
|
+
return `~${formatDuration(meanMs * unsettled)} remaining (from ${settledSpans.length} settled node${settledSpans.length === 1 ? "" : "s"})`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** @param {StatusPayloadNode[]} phaseNodes @returns {string} */
|
|
147
|
+
function phaseEstimateLine(phaseNodes) {
|
|
148
|
+
return `phase estimate: ${remainingEstimate(phaseNodes, phaseNodes) ?? "unknown (no settled node yet)"}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** @param {StatusPayloadNode[]} phaseNodes @returns {string} */
|
|
152
|
+
function nextNodeLine(phaseNodes) {
|
|
153
|
+
const next = phaseNodes.find((node) => !SETTLED.has(node.status));
|
|
154
|
+
return next ? `next: ${next.id}` : "next: phase complete";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The worker's own words, never the renderer's: the raw snapshot's own
|
|
159
|
+
* `result.summary`, labelled as such so a reader never mistakes it for the
|
|
160
|
+
* renderer's judgment.
|
|
161
|
+
*
|
|
162
|
+
* @param {Record<string, unknown>} raw
|
|
163
|
+
* @returns {string}
|
|
164
|
+
*/
|
|
165
|
+
function valueLine(raw) {
|
|
166
|
+
const result = /** @type {{summary?: unknown}|null} */ (raw.result && typeof raw.result === "object" ? raw.result : null);
|
|
167
|
+
const summary = typeof result?.summary === "string" && result.summary.trim() ? result.summary.trim() : null;
|
|
168
|
+
return `worker says: ${summary ?? NO_SUMMARY}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The attention variant names what must be decided and the exact command
|
|
173
|
+
* that unblocks it, the way `src/report/next.mjs` does for its own items.
|
|
174
|
+
*
|
|
175
|
+
* @param {string} runDir
|
|
176
|
+
* @param {StatusPayloadNode} subject
|
|
177
|
+
* @param {Record<string, unknown>} raw
|
|
178
|
+
* @returns {string}
|
|
179
|
+
*/
|
|
180
|
+
function attentionLine(runDir, subject, raw) {
|
|
181
|
+
const result = /** @type {{status?: unknown, summary?: unknown, missingContext?: unknown}|null} */ (raw.result && typeof raw.result === "object" ? raw.result : null);
|
|
182
|
+
if (result?.status === "blocked_context") {
|
|
183
|
+
const missing = Array.isArray(result.missingContext) ? result.missingContext.filter((entry) => typeof entry === "string") : [];
|
|
184
|
+
const asked = missing.length ? ` (needs ${missing.join(", ")})` : "";
|
|
185
|
+
return `decide: answer node ${subject.id}'s missing context${asked} → resume ${runDir} --answer ${subject.id}=<answer-file>`;
|
|
186
|
+
}
|
|
187
|
+
const gate = /** @type {{findings?: unknown}|null} */ (raw.gate && typeof raw.gate === "object" ? raw.gate : null);
|
|
188
|
+
if (subject.status === "exhausted" && Array.isArray(gate?.findings) && gate.findings.length) {
|
|
189
|
+
return `decide: review node ${subject.id}'s gate findings → findings ${runDir}`;
|
|
190
|
+
}
|
|
191
|
+
return `decide: resolve node ${subject.id} (${subject.status}) → status ${runDir}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The campaign's cumulative cost: every linked run's own usage.jsonl total
|
|
196
|
+
* (a phase-run per plan phase), summed through the same reader the status
|
|
197
|
+
* payload uses for this run alone -- never this run's own total standing in
|
|
198
|
+
* for the campaign's, which is the phase's spend, not the campaign's.
|
|
199
|
+
*
|
|
200
|
+
* `null` when the campaign record cannot be read at all (never once a
|
|
201
|
+
* linked run's own usage is merely unpriced or missing, which `readRunUsage`
|
|
202
|
+
* already folds into the sum as a no-op).
|
|
203
|
+
*
|
|
204
|
+
* @param {string} runsDir
|
|
205
|
+
* @param {string} campaignId
|
|
206
|
+
* @returns {number|null}
|
|
207
|
+
*/
|
|
208
|
+
function campaignCumulativeCostUsd(runsDir, campaignId) {
|
|
209
|
+
let campaign;
|
|
210
|
+
try {
|
|
211
|
+
campaign = readCampaign(campaignDir(runsDir, campaignId));
|
|
212
|
+
} catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
let total = null;
|
|
216
|
+
for (const runId of campaign.linkedRunIds) {
|
|
217
|
+
const cost = readRunUsage(join(runsDir, runId)).costUsd;
|
|
218
|
+
if (typeof cost === "number") total = (total ?? 0) + cost;
|
|
219
|
+
}
|
|
220
|
+
return total;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* @param {string} runDir
|
|
225
|
+
* @param {{campaignId: string, roles: {worker: import("./render.mjs").RoleUsage, judge: import("./render.mjs").RoleUsage}}} payload
|
|
226
|
+
* @returns {string}
|
|
227
|
+
*/
|
|
228
|
+
function costLine(runDir, payload) {
|
|
229
|
+
const cumulative = campaignCumulativeCostUsd(dirname(runDir), payload.campaignId);
|
|
230
|
+
return `cost — worker ${formatRole(payload.roles.worker)} · judge ${formatRole(payload.roles.judge)} · campaign total ${compactCost(cumulative)}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The value block is the only unbounded part of the message, so it is the
|
|
235
|
+
* only one truncated when the whole message would exceed the ceiling.
|
|
236
|
+
*
|
|
237
|
+
* @param {string[]} lines
|
|
238
|
+
* @param {number} valueLineIndex
|
|
239
|
+
* @returns {string}
|
|
240
|
+
*/
|
|
241
|
+
function boundToCeiling(lines, valueLineIndex) {
|
|
242
|
+
const overflow = Buffer.byteLength(lines.join("\n"), "utf8") - PROGRESS_MESSAGE_MAX_BYTES;
|
|
243
|
+
if (overflow <= 0) return lines.join("\n");
|
|
244
|
+
const valueLineBytes = Buffer.byteLength(lines[valueLineIndex], "utf8");
|
|
245
|
+
const trimmed = [...lines];
|
|
246
|
+
trimmed[valueLineIndex] = boundedUtf8(lines[valueLineIndex], Math.max(0, valueLineBytes - overflow));
|
|
247
|
+
return trimmed.join("\n");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* @typedef {{id: string, dependsOn: string[], status: string, attempt: number, workerRuntime: string|null, judgeRuntime: string|null, elapsedSpan: string|null, costUsd: number|null, runDir: string|null, workerLogPath: string|null, judgeLogPaths: string[], verificationRecordPath: string|null, attemptBranch: string|null, sealCommit: string|null}} RollupNode
|
|
252
|
+
* `status` carries every `NodeStatus` plus two the manifest alone can
|
|
253
|
+
* explain: `not_started` (the contract has no run yet) and `unreadable` (a
|
|
254
|
+
* run exists but this node's own snapshot does not).
|
|
255
|
+
* `workerRuntime` and `judgeRuntime` are two different facts, not one field
|
|
256
|
+
* whichever role happened to run last: a gated node's `state.runtime` is
|
|
257
|
+
* overwritten by the judge dispatch (`src/engine/dispatch.mjs` writes it at
|
|
258
|
+
* three points), so a single `runtime` field reads as though the judge did
|
|
259
|
+
* the worker's job. `judgeRuntime` is `null` for a node with no gate, or one
|
|
260
|
+
* whose gate never ran -- never the worker's own runtime repeated.
|
|
261
|
+
*/
|
|
262
|
+
/**
|
|
263
|
+
* `counts.done` and `counts.settled` are two different numbers: `done` is
|
|
264
|
+
* `SUCCESS` alone (a reader's "finished"), `settled` is `SETTLED` (the
|
|
265
|
+
* scheduler's "will not move again on its own", which also holds blocked,
|
|
266
|
+
* failed and exhausted). A phase holding a blocked node settles without
|
|
267
|
+
* finishing; a page that draws it from `settled` alone draws it as done,
|
|
268
|
+
* which is the lie this split exists to prevent.
|
|
269
|
+
*/
|
|
270
|
+
/** @typedef {{contractId: string|null, runId: string|null, phase: string|null, name: string|null, goal: string|null, declaredRequirementIds: string[], nodes: RollupNode[], counts: {done: number, settled: number, total: number}, newestSettledNode: RollupNewestNode|null, recordGone?: boolean}} RollupPhase */
|
|
271
|
+
/** @typedef {{contractId: string, nodeId: string, summary: string}} RollupNewestNode */
|
|
272
|
+
/** @typedef {{reason: string, command: string, runnable: boolean}} RollupNextAction */
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The campaign roll-up: `renderRunProgress`'s campaign-wide sibling. Every
|
|
276
|
+
* field comes from persisted state -- the campaign record, each linked run's
|
|
277
|
+
* own directory, and the runs' usage ledgers -- never from a live model.
|
|
278
|
+
*
|
|
279
|
+
* @param {string} runsDir
|
|
280
|
+
* @param {string} campaignId
|
|
281
|
+
* @param {number} [now] epoch ms the roll-up is rendered at; defaults to the
|
|
282
|
+
* wall clock, and injected by tests so the clock can be held still
|
|
283
|
+
* @returns {string}
|
|
284
|
+
*/
|
|
285
|
+
export function renderCampaignProgress(runsDir, campaignId, now = Date.now()) {
|
|
286
|
+
return `${JSON.stringify(buildCampaignProgress(runsDir, campaignId, now), null, 2)}\n`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* @param {string} runsDir
|
|
291
|
+
* @param {string} campaignId
|
|
292
|
+
* @param {number} now
|
|
293
|
+
* @returns {Record<string, unknown>}
|
|
294
|
+
*/
|
|
295
|
+
function buildCampaignProgress(runsDir, campaignId, now) {
|
|
296
|
+
const campaignPath = campaignDir(runsDir, campaignId);
|
|
297
|
+
const campaign = readCampaign(campaignPath);
|
|
298
|
+
// The manifest is a queue of what is declared to run next, and the
|
|
299
|
+
// orchestrator replaces its entry each phase, so a roll-up that walks it
|
|
300
|
+
// alone reports a campaign of one phase after thirteen have landed
|
|
301
|
+
// (measured 2026-09-18 on this campaign: manifest 1 contract,
|
|
302
|
+
// linkedRunIds 22, 20 run directories on disk). The record does not
|
|
303
|
+
// forget: every linked run is a phase that happened, in the order the
|
|
304
|
+
// record registered it, and every manifest contract with no run is a
|
|
305
|
+
// phase that has not started. Happened first, then next -- the order a
|
|
306
|
+
// reader expects. A manifest contract whose id is already linked is the
|
|
307
|
+
// same phase arriving by both roads (a launched run takes its contract's
|
|
308
|
+
// id as its own), so it reports once, from the run's own directory --
|
|
309
|
+
// decided before any snapshot is read, and without ever letting an
|
|
310
|
+
// unreadable authored contract abort the render (`buildManifestPhase`).
|
|
311
|
+
const linkedRunIds = new Set(campaign.linkedRunIds);
|
|
312
|
+
const built = campaign.linkedRunIds.map((runId) => buildLinkedRunPhase(runsDir, runId));
|
|
313
|
+
for (const entry of campaign.contracts) {
|
|
314
|
+
const phase = buildManifestPhase(entry, linkedRunIds);
|
|
315
|
+
if (phase) built.push(phase);
|
|
316
|
+
}
|
|
317
|
+
const phases = built.map((entry) => entry.output);
|
|
318
|
+
const settledNodes = built.flatMap((entry) => entry.settledCandidates);
|
|
319
|
+
const done = phases.reduce((total, phase) => total + phase.counts.done, 0);
|
|
320
|
+
const settled = phases.reduce((total, phase) => total + phase.counts.settled, 0);
|
|
321
|
+
const total = phases.reduce((total2, phase) => total2 + phase.counts.total, 0);
|
|
322
|
+
return {
|
|
323
|
+
schemaVersion: 1,
|
|
324
|
+
campaignId: campaign.id,
|
|
325
|
+
goal: campaign.goal,
|
|
326
|
+
landBranch: campaign.landBranch,
|
|
327
|
+
phases,
|
|
328
|
+
counts: { done, settled, total },
|
|
329
|
+
// The denominator is every node the campaign has ever declared: each
|
|
330
|
+
// linked run's own contract copy plus each never-launched manifest
|
|
331
|
+
// contract, so a campaign can never read as 100% while a declared phase
|
|
332
|
+
// is unauthored work. A pruned run's nodes are gone with its directory
|
|
333
|
+
// and count as nothing rather than as a guess. The percentage counts
|
|
334
|
+
// `done` (`SUCCESS` alone), never `settled`: a running or blocked node is
|
|
335
|
+
// not done either, but only `settled` also folds in blocked, failed and
|
|
336
|
+
// exhausted, which would read as progress.
|
|
337
|
+
percentDone: total ? Math.round((done / total) * 100) : 0,
|
|
338
|
+
costByRole: campaignRoleUsage(runsDir, campaign.linkedRunIds),
|
|
339
|
+
// The same sum the single-run message prints as "campaign total", from
|
|
340
|
+
// the same ledgers -- the roll-up and the message cannot disagree about
|
|
341
|
+
// what the campaign spent.
|
|
342
|
+
costTotalUsd: campaignCumulativeCostUsd(runsDir, campaignId),
|
|
343
|
+
newestSettledNode: newestSettledNode(settledNodes),
|
|
344
|
+
time: campaignTime(campaign, phases, settledNodes, now),
|
|
345
|
+
// The chain calls this contract's own run directory the campaign's
|
|
346
|
+
// repo root three levels above `campaignPath` (`record.mjs`'s
|
|
347
|
+
// `preserveCampaignLedger` derives it the same way); reused here so the
|
|
348
|
+
// next action's command matches what `next` itself would print.
|
|
349
|
+
nextAction: campaignNextAction(runsDir, campaignId, resolve(campaignPath, "..", "..", "..")),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* One manifest entry's roll-up: the contract's own declared nodes and graph
|
|
355
|
+
* edges when it has never run, or those same nodes filled in from the run's
|
|
356
|
+
* persisted snapshots once it has. `null` when the entry contributes no
|
|
357
|
+
* phase: its authored contract is unreadable, or its contract id is already
|
|
358
|
+
* linked and has reported from the run's own directory -- decided before any
|
|
359
|
+
* snapshot is read, so the dedupe never pays for the reads it discards.
|
|
360
|
+
*
|
|
361
|
+
* @param {{path: string, digest: string}} entry
|
|
362
|
+
* @param {Set<string>} linkedRunIds
|
|
363
|
+
* @returns {{output: RollupPhase, settledCandidates: {contractId: string, nodeId: string, snapshot: Record<string, unknown>}[]}|null}
|
|
364
|
+
*/
|
|
365
|
+
function buildManifestPhase(entry, linkedRunIds) {
|
|
366
|
+
let raw;
|
|
367
|
+
try {
|
|
368
|
+
raw = /** @type {Record<string, unknown>} */ (JSON.parse(readFileSync(entry.path, "utf8")));
|
|
369
|
+
} catch {
|
|
370
|
+
// The authored contract is gone or unreadable, so the entry cannot name
|
|
371
|
+
// its phase. If its run launched, the run is linked and the phase has
|
|
372
|
+
// already reported from its own directory -- an anonymous placeholder
|
|
373
|
+
// here would count one phase twice. If it never launched, the chain
|
|
374
|
+
// parks the campaign on the missing file at its next supervise
|
|
375
|
+
// (`validateManifestEntryAtLaunch` cannot even hash it), where the
|
|
376
|
+
// operator decides; the page omits the declaration rather than showing
|
|
377
|
+
// a phase nothing can read or run. Measured 2026-09-19: seven real
|
|
378
|
+
// campaigns hit the first case, and this read -- unguarded in the
|
|
379
|
+
// previous attempt -- aborted all four answers for each of them.
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
const contractId = String(raw.id);
|
|
383
|
+
if (linkedRunIds.has(contractId)) return null;
|
|
384
|
+
const cwd = resolve(dirname(entry.path), typeof raw.cwd === "string" ? raw.cwd : ".");
|
|
385
|
+
const runDir = runDirectory(cwd, contractId);
|
|
386
|
+
// The same signal the chain itself uses to decide a contract has started
|
|
387
|
+
// (`chain.mjs`'s own launch loop checks this file before it trusts a run
|
|
388
|
+
// directory's contents).
|
|
389
|
+
const hasRun = existsSync(join(runDir, "run.json"));
|
|
390
|
+
return buildPhaseFromContract(raw, runDir, hasRun ? contractId : null);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* One phase's roll-up from its contract object, shared by the two roads a
|
|
395
|
+
* phase arrives by: a manifest entry (whose run directory is derived from
|
|
396
|
+
* the contract id) and a linked run (whose directory the campaign record
|
|
397
|
+
* names directly). `runId` doubles as the has-run signal -- null means the
|
|
398
|
+
* contract has never launched, so its nodes report `not_started` from the
|
|
399
|
+
* declaration alone. `settledCandidates` carries the raw snapshot beside its
|
|
400
|
+
* contract and node id, for the campaign-wide newest-summary and
|
|
401
|
+
* remaining-time readers, which need the snapshot's own `result`, `status`
|
|
402
|
+
* and spans -- content this function's own output never repeats.
|
|
403
|
+
*
|
|
404
|
+
* @param {Record<string, unknown>} raw
|
|
405
|
+
* @param {string} runDir
|
|
406
|
+
* @param {string|null} runId
|
|
407
|
+
* @returns {{output: RollupPhase, settledCandidates: {contractId: string, nodeId: string, snapshot: Record<string, unknown>}[]}}
|
|
408
|
+
*/
|
|
409
|
+
function buildPhaseFromContract(raw, runDir, runId) {
|
|
410
|
+
const hasRun = runId !== null;
|
|
411
|
+
const contractId = String(raw.id);
|
|
412
|
+
const rawNodes = Array.isArray(raw.nodes) ? raw.nodes : [];
|
|
413
|
+
const phaseId = typeof rawNodes[0]?.phase === "string" ? rawNodes[0].phase : null;
|
|
414
|
+
const workerRuntimeById = hasRun ? workerRuntimeMap(runDir) : new Map();
|
|
415
|
+
|
|
416
|
+
/** @type {{contractId: string, nodeId: string, snapshot: Record<string, unknown>}[]} */
|
|
417
|
+
const settledCandidates = [];
|
|
418
|
+
const nodes = rawNodes.map((node) => rollupNode(node, contractId, runDir, hasRun, settledCandidates, workerRuntimeById));
|
|
419
|
+
const done = nodes.filter((node) => SUCCESS.has(node.status)).length;
|
|
420
|
+
const settled = nodes.filter((node) => SETTLED.has(node.status)).length;
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
output: {
|
|
424
|
+
contractId,
|
|
425
|
+
runId,
|
|
426
|
+
phase: phaseId,
|
|
427
|
+
name: phaseName(phaseId),
|
|
428
|
+
goal: typeof raw.goal === "string" ? raw.goal : null,
|
|
429
|
+
// Declared-not-measured, and empty until a frozen plan carries
|
|
430
|
+
// requirement ids on the phase itself (R9/R10): there used to be a
|
|
431
|
+
// scrape of requirement ids out of the campaign journal's decision
|
|
432
|
+
// entries, keyed by whether a decision's own text happened to name
|
|
433
|
+
// this phase or contract. Journal prose is not a traceability source —
|
|
434
|
+
// a phase-decomposition decision that names every requirement id in
|
|
435
|
+
// one sentence attributed all of them to whichever phase the decision
|
|
436
|
+
// text mentioned, which is a wrong mapping, and a wrong mapping is
|
|
437
|
+
// worse than a missing one.
|
|
438
|
+
declaredRequirementIds: [],
|
|
439
|
+
newestSettledNode: newestSettledNode(settledCandidates),
|
|
440
|
+
nodes,
|
|
441
|
+
counts: { done, settled, total: nodes.length },
|
|
442
|
+
},
|
|
443
|
+
settledCandidates,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* A phase from a run the campaign record links, read from the run's own
|
|
449
|
+
* directory: its contract copy supplies the declared nodes, goal and phase
|
|
450
|
+
* id, its snapshots supply what happened. A run id the record remembers but
|
|
451
|
+
* whose directory is gone (pruned, or never materialised) is still a phase
|
|
452
|
+
* that happened -- it is flagged `recordGone` and everything else about it
|
|
453
|
+
* is left unknown rather than guessed; the run id itself is the readable
|
|
454
|
+
* residue, being the name the operator ran.
|
|
455
|
+
*
|
|
456
|
+
* @param {string} runsDir
|
|
457
|
+
* @param {string} runId
|
|
458
|
+
* @returns {{output: RollupPhase, settledCandidates: {contractId: string, nodeId: string, snapshot: Record<string, unknown>}[]}}
|
|
459
|
+
*/
|
|
460
|
+
function buildLinkedRunPhase(runsDir, runId) {
|
|
461
|
+
try {
|
|
462
|
+
const raw = /** @type {Record<string, unknown>} */ (JSON.parse(readFileSync(join(runsDir, runId, "contract.json"), "utf8")));
|
|
463
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new TypeError("contract copy is not an object");
|
|
464
|
+
return buildPhaseFromContract(raw, join(runsDir, runId), runId);
|
|
465
|
+
} catch {
|
|
466
|
+
return {
|
|
467
|
+
output: {
|
|
468
|
+
contractId: null,
|
|
469
|
+
runId,
|
|
470
|
+
phase: null,
|
|
471
|
+
name: phaseName(runId),
|
|
472
|
+
goal: null,
|
|
473
|
+
declaredRequirementIds: [],
|
|
474
|
+
newestSettledNode: null,
|
|
475
|
+
nodes: [],
|
|
476
|
+
counts: { done: 0, settled: 0, total: 0 },
|
|
477
|
+
recordGone: true,
|
|
478
|
+
},
|
|
479
|
+
settledCandidates: [],
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* The honest per-node worker runtime, read from the same status payload
|
|
486
|
+
* `buildStatusPayload` already computes (`workerRuntimeLabel`'s scan of the
|
|
487
|
+
* invocation ledger for the worker that actually ran) -- never recomputed
|
|
488
|
+
* here, so this reader and `status --json`/`report` can never disagree about
|
|
489
|
+
* who worked a node. Empty when the run's own payload cannot be built at all
|
|
490
|
+
* (e.g. a snapshot elsewhere in the run is corrupt), so one broken sibling
|
|
491
|
+
* never hides this phase's readable nodes; `rollupNode` falls back to `null`
|
|
492
|
+
* per node in that case, same as an unreadable snapshot would.
|
|
493
|
+
*
|
|
494
|
+
* @param {string} runDir
|
|
495
|
+
* @returns {Map<string, string|null>}
|
|
496
|
+
*/
|
|
497
|
+
function workerRuntimeMap(runDir) {
|
|
498
|
+
try {
|
|
499
|
+
const payload = /** @type {{nodes: {id: string, workerRuntime: string|null}[]}} */ (JSON.parse(renderStatusJson(runDir)));
|
|
500
|
+
return new Map(payload.nodes.map((entry) => [entry.id, entry.workerRuntime]));
|
|
501
|
+
} catch {
|
|
502
|
+
return new Map();
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* The judge that actually reviewed this node, read the same way
|
|
508
|
+
* `workerRuntimeLabel` reads the worker: the invocation ledger's own last
|
|
509
|
+
* `role: "judge"` entry. Unlike the worker there is no fallback to
|
|
510
|
+
* `snapshot.runtime` -- a node with no gate, or one whose gate never ran, has
|
|
511
|
+
* no judge invocation at all, and must report no judge runtime rather than
|
|
512
|
+
* the worker's runtime standing in for it.
|
|
513
|
+
*
|
|
514
|
+
* @param {Record<string, unknown>[]} invocations
|
|
515
|
+
* @returns {string|null}
|
|
516
|
+
*/
|
|
517
|
+
function judgeRuntimeLabel(invocations) {
|
|
518
|
+
const judge = /** @type {{harness?: unknown, model?: unknown}|undefined} */ ([...invocations].reverse().find((invocation) => invocation?.role === "judge"));
|
|
519
|
+
return judge?.harness && judge?.model ? `${judge.harness}/${judge.model}` : null;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* @param {Record<string, unknown>} node the manifest's own declared node
|
|
524
|
+
* @param {string} contractId
|
|
525
|
+
* @param {string} runDir
|
|
526
|
+
* @param {boolean} hasRun
|
|
527
|
+
* @param {{contractId: string, nodeId: string, snapshot: Record<string, unknown>}[]} settledCandidates appended to when this node's own snapshot is readable
|
|
528
|
+
* @param {Map<string, string|null>} workerRuntimeById
|
|
529
|
+
* @returns {RollupNode}
|
|
530
|
+
*/
|
|
531
|
+
function rollupNode(node, contractId, runDir, hasRun, settledCandidates, workerRuntimeById) {
|
|
532
|
+
const id = String(node.id);
|
|
533
|
+
const dependsOn = Array.isArray(node.dependsOn) ? node.dependsOn.map(String) : [];
|
|
534
|
+
const empty = { id, dependsOn, attempt: 0, workerRuntime: null, judgeRuntime: null, elapsedSpan: null, costUsd: null, workerLogPath: null, judgeLogPaths: /** @type {string[]} */ ([]), attemptBranch: null, sealCommit: null };
|
|
535
|
+
if (!hasRun) return { ...empty, status: "not_started", runDir: null, verificationRecordPath: null };
|
|
536
|
+
|
|
537
|
+
let snapshot;
|
|
538
|
+
try {
|
|
539
|
+
snapshot = readNodeSnapshot(runDir, id);
|
|
540
|
+
} catch {
|
|
541
|
+
return { ...empty, status: "unreadable", runDir, verificationRecordPath: nodeSnapshotPath(runDir, id) };
|
|
542
|
+
}
|
|
543
|
+
settledCandidates.push({ contractId, nodeId: id, snapshot });
|
|
544
|
+
|
|
545
|
+
const invocations = Array.isArray(snapshot.invocations) ? snapshot.invocations : [];
|
|
546
|
+
const worker = [...invocations].reverse().find((invocation) => invocation?.role === "worker");
|
|
547
|
+
const judgeLogPaths = invocations
|
|
548
|
+
.filter((invocation) => invocation?.role === "judge")
|
|
549
|
+
.map((invocation) => invocation.stdoutPath)
|
|
550
|
+
.filter((path) => typeof path === "string");
|
|
551
|
+
const worktree = snapshot.worktree && typeof snapshot.worktree === "object" ? /** @type {Record<string, unknown>} */ (snapshot.worktree) : null;
|
|
552
|
+
|
|
553
|
+
return {
|
|
554
|
+
id,
|
|
555
|
+
dependsOn,
|
|
556
|
+
status: typeof snapshot.status === "string" ? snapshot.status : "unreadable",
|
|
557
|
+
attempt: typeof snapshot.attempt === "number" ? snapshot.attempt : 0,
|
|
558
|
+
workerRuntime: workerRuntimeById.get(id) ?? null,
|
|
559
|
+
judgeRuntime: judgeRuntimeLabel(invocations),
|
|
560
|
+
elapsedSpan: spanOf({ startedAt: typeof snapshot.startedAt === "string" ? snapshot.startedAt : null, updatedAt: typeof snapshot.updatedAt === "string" ? snapshot.updatedAt : null }),
|
|
561
|
+
costUsd: typeof snapshot.costUsd === "number" ? snapshot.costUsd : null,
|
|
562
|
+
runDir,
|
|
563
|
+
workerLogPath: typeof worker?.stdoutPath === "string" ? worker.stdoutPath : null,
|
|
564
|
+
judgeLogPaths,
|
|
565
|
+
verificationRecordPath: nodeSnapshotPath(runDir, id),
|
|
566
|
+
attemptBranch: typeof worktree?.branch === "string" ? worktree.branch : null,
|
|
567
|
+
sealCommit: typeof worktree?.sealedSha === "string" ? worktree.sealedSha : null,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* A phase id is not a name (the owner's own words on seeing `0b3` unlabelled):
|
|
573
|
+
* hyphens become spaces and the first letter is capitalized, so
|
|
574
|
+
* `verdict-and-write-check` reads as `Verdict and write check`.
|
|
575
|
+
*
|
|
576
|
+
* @param {string|null} phaseId
|
|
577
|
+
* @returns {string|null}
|
|
578
|
+
*/
|
|
579
|
+
function phaseName(phaseId) {
|
|
580
|
+
if (!phaseId) return null;
|
|
581
|
+
const words = phaseId.replaceAll("-", " ");
|
|
582
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* The most recently settled node across every linked run, campaign-wide --
|
|
587
|
+
* `renderRunProgress`'s `subjectNode` narrowed to one run; here there is no
|
|
588
|
+
* event naming a node, only whichever settled last.
|
|
589
|
+
*
|
|
590
|
+
* @param {{contractId: string, nodeId: string, snapshot: Record<string, unknown>}[]} candidates
|
|
591
|
+
* @returns {RollupNewestNode|null}
|
|
592
|
+
*/
|
|
593
|
+
function newestSettledNode(candidates) {
|
|
594
|
+
const settled = candidates.filter((candidate) => SETTLED.has(/** @type {import("../contract/index.mjs").NodeStatus} */ (String(candidate.snapshot.status)))
|
|
595
|
+
&& typeof candidate.snapshot.updatedAt === "string");
|
|
596
|
+
const latest = settled.sort((left, right) => String(left.snapshot.updatedAt).localeCompare(String(right.snapshot.updatedAt))).at(-1);
|
|
597
|
+
if (!latest) return null;
|
|
598
|
+
const result = /** @type {{summary?: unknown}|null} */ (latest.snapshot.result && typeof latest.snapshot.result === "object" ? latest.snapshot.result : null);
|
|
599
|
+
const summary = typeof result?.summary === "string" && result.summary.trim() ? result.summary.trim() : null;
|
|
600
|
+
return { contractId: latest.contractId, nodeId: latest.nodeId, summary: summary ?? NO_SUMMARY };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* The campaign's per-role cost and token counts, summed from every linked
|
|
605
|
+
* run's own `usage.jsonl` -- the same records `campaignCumulativeCostUsd`
|
|
606
|
+
* sums the money from, so the per-role view and the campaign total cannot
|
|
607
|
+
* disagree. Node snapshots cannot promise this: a pruned or torn run keeps
|
|
608
|
+
* its usage ledger after its snapshots are gone. The three token kinds stay
|
|
609
|
+
* three numbers -- they differ by two orders of magnitude on a real campaign
|
|
610
|
+
* (measured 2026-09-18: 6.9M input against 333.5M cache-read) -- and a role
|
|
611
|
+
* with no price on record reports its tokens as unpriced, never a zero. A
|
|
612
|
+
* role only partly priced reports the dollars it does have on record, with
|
|
613
|
+
* `costProvenance` still saying `partial` (`withRecordedCost`).
|
|
614
|
+
*
|
|
615
|
+
* @param {string} runsDir
|
|
616
|
+
* @param {string[]} linkedRunIds
|
|
617
|
+
* @returns {{worker: import("./render.mjs").RoleUsage, judge: import("./render.mjs").RoleUsage}}
|
|
618
|
+
*/
|
|
619
|
+
function campaignRoleUsage(runsDir, linkedRunIds) {
|
|
620
|
+
/** @type {Record<string, unknown>[]} */
|
|
621
|
+
const invocations = [];
|
|
622
|
+
/** @type {Record<"worker"|"judge", number>} */
|
|
623
|
+
const pricedUsd = { worker: 0, judge: 0 };
|
|
624
|
+
for (const runId of linkedRunIds) {
|
|
625
|
+
const path = join(runsDir, runId, "usage.jsonl");
|
|
626
|
+
if (!existsSync(path)) continue;
|
|
627
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
628
|
+
if (!line.trim()) continue;
|
|
629
|
+
let record;
|
|
630
|
+
try { record = JSON.parse(line); } catch { continue; }
|
|
631
|
+
if (!record || typeof record !== "object" || Array.isArray(record)) continue;
|
|
632
|
+
const value = /** @type {Record<string, unknown>} */ (record);
|
|
633
|
+
invocations.push({
|
|
634
|
+
role: value.role,
|
|
635
|
+
costUsd: value.costUsd,
|
|
636
|
+
usage: { inputTokens: value.inputTokens, outputTokens: value.outputTokens, cacheReadInputTokens: value.cacheReadInputTokens },
|
|
637
|
+
});
|
|
638
|
+
const cost = finite(value.costUsd);
|
|
639
|
+
if ((value.role === "worker" || value.role === "judge") && cost !== null) pricedUsd[value.role] += cost;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
const roles = roleUsage(/** @type {import("../contract/index.mjs").NodeSnapshot[]} */ (/** @type {unknown} */ ([{ invocations }])));
|
|
643
|
+
return {
|
|
644
|
+
worker: withRecordedCost(roles.worker, pricedUsd.worker),
|
|
645
|
+
judge: withRecordedCost(roles.judge, pricedUsd.judge),
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* The dollars a role has on record, even when only some of its invocations
|
|
651
|
+
* carry a price. `summarizeRole` keeps `costUsd` null for a partly priced
|
|
652
|
+
* role so no surface can read a partial sum as the whole cost; the campaign
|
|
653
|
+
* page makes the other half of that trade -- it prints the recorded dollars
|
|
654
|
+
* while `costProvenance` still says `partial` -- because "what did it cost?"
|
|
655
|
+
* answered with null while real dollars sit in the ledger hides the answer
|
|
656
|
+
* the page exists to give (measured 2026-09-18: $3.69 and $6.80 of recorded
|
|
657
|
+
* spend rendering as no money at all). A fully unpriced role keeps null:
|
|
658
|
+
* no dollars are on record, and none are invented.
|
|
659
|
+
*
|
|
660
|
+
* @param {import("./render.mjs").RoleUsage} role
|
|
661
|
+
* @param {number} pricedUsd
|
|
662
|
+
* @returns {import("./render.mjs").RoleUsage}
|
|
663
|
+
*/
|
|
664
|
+
function withRecordedCost(role, pricedUsd) {
|
|
665
|
+
if (role.costUsd === null && pricedUsd > 0) return { ...role, costUsd: pricedUsd };
|
|
666
|
+
return role;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Answer four: how long the campaign has been running and what is left.
|
|
671
|
+
* Elapsed runs from the campaign record's own `createdAt` -- the earliest
|
|
672
|
+
* clock the record durably carries -- to its own `closedAt` once the record
|
|
673
|
+
* carries one: a closed campaign's clock stopped when it closed, and an
|
|
674
|
+
* elapsed that keeps counting to now reports how long ago the campaign was
|
|
675
|
+
* born, not how long it ran (measured 2026-09-19: a campaign closed after
|
|
676
|
+
* ~14h52m rendering 140h21m of running time). A first linked run's start
|
|
677
|
+
* lives in a run directory pruning can take, and an elapsed that moves when
|
|
678
|
+
* history is pruned is not a record. Remaining reuses `remainingEstimate`,
|
|
679
|
+
* the single-run renderer's own rule, over the whole campaign's nodes.
|
|
680
|
+
*
|
|
681
|
+
* @param {import("../campaign/index.mjs").Campaign} campaign
|
|
682
|
+
* @param {RollupPhase[]} phases
|
|
683
|
+
* @param {{contractId: string, nodeId: string, snapshot: Record<string, unknown>}[]} settledCandidates
|
|
684
|
+
* @param {number} now epoch ms
|
|
685
|
+
* @returns {{startedAt: string|null, elapsed: string|null, remaining: string|null}}
|
|
686
|
+
*/
|
|
687
|
+
function campaignTime(campaign, phases, settledCandidates, now) {
|
|
688
|
+
const startedAt = typeof campaign.createdAt === "string" ? campaign.createdAt : null;
|
|
689
|
+
const start = startedAt === null ? NaN : Date.parse(startedAt);
|
|
690
|
+
const closedAt = typeof campaign.closedAt === "string" ? campaign.closedAt : null;
|
|
691
|
+
const closed = closedAt === null ? NaN : Date.parse(closedAt);
|
|
692
|
+
const end = Number.isFinite(closed) ? closed : now;
|
|
693
|
+
const nodes = phases.flatMap((phase) => phase.nodes);
|
|
694
|
+
const spans = settledCandidates.map((candidate) => ({
|
|
695
|
+
status: String(candidate.snapshot.status),
|
|
696
|
+
startedAt: typeof candidate.snapshot.startedAt === "string" ? candidate.snapshot.startedAt : null,
|
|
697
|
+
updatedAt: typeof candidate.snapshot.updatedAt === "string" ? candidate.snapshot.updatedAt : null,
|
|
698
|
+
}));
|
|
699
|
+
return {
|
|
700
|
+
startedAt,
|
|
701
|
+
elapsed: Number.isFinite(start) ? formatDuration(Math.max(0, end - start)) : null,
|
|
702
|
+
// A campaign with no readable node at all has nothing to estimate from
|
|
703
|
+
// and nothing to be complete about: absent, like the estimate itself.
|
|
704
|
+
remaining: nodes.length ? remainingEstimate(nodes, spans) : null,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* The operator's next action for this one campaign, reusing `next`'s own
|
|
710
|
+
* ranked predicates rather than re-deriving them: a second implementation of
|
|
711
|
+
* "what needs the operator" is exactly how a page and `faberun next` could
|
|
712
|
+
* disagree. `null` when `next` has nothing runnable or worth naming for this
|
|
713
|
+
* campaign (an empty command, e.g. "run live; nothing to do").
|
|
714
|
+
*
|
|
715
|
+
* @param {string} runsDir
|
|
716
|
+
* @param {string} campaignId
|
|
717
|
+
* @param {string} cwd
|
|
718
|
+
* @returns {RollupNextAction|null}
|
|
719
|
+
*/
|
|
720
|
+
function campaignNextAction(runsDir, campaignId, cwd) {
|
|
721
|
+
const item = computeNextItems(runsDir, cwd).find((candidate) => candidate.campaign === campaignId);
|
|
722
|
+
if (!item || !item.command) return null;
|
|
723
|
+
return { reason: item.reason, command: item.command, runnable: item.runnable };
|
|
724
|
+
}
|