faberun 0.22.0 → 0.23.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.
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * Metrics projector (TECH-SPEC lean section 6). One pure function over a
3
- * campaign's own recorded artefacts — every linked run's persisted node
4
- * snapshots, `events.jsonl`, `usage.jsonl` and `notify.jsonl` — returning
5
- * exactly the indicators section 6 measures at close. Nothing here estimates
6
- * a token count or reads a heartbeat: every value comes from a record the
7
- * platform already wrote for another reason (a node snapshot, a transition,
8
- * a priced invocation, a delivery receipt).
3
+ * campaign's own parsed artefacts — node snapshots, transitions, priced
4
+ * invocations and delivery receipts — returning exactly the indicators
5
+ * section 6 measures at close. Nothing here estimates a token count or reads
6
+ * a heartbeat: every value comes from a record the platform already wrote for
7
+ * another reason.
9
8
  *
10
9
  * `projectMetrics` takes already-parsed records and never a filesystem path,
11
10
  * which keeps every indicator testable without fixtures on disk. Each
@@ -23,21 +22,14 @@
23
22
  * 1 only when a whole run had to be re-authored after a failure that
24
23
  * `resume` could not repair.
25
24
  *
26
- * The second half of this module is the `faberun metrics` command: reading
27
- * a campaign's linked runs and parsing the command's flags live here, while
28
- * `metrics-report.mjs` decides how the projection is printed.
25
+ * Filesystem reads and the `faberun metrics` command's flags live in
26
+ * `metrics-command.mjs`; keeping them separate leaves this module a pure
27
+ * function over parsed records.
29
28
  */
30
29
 
31
- import { existsSync, readFileSync } from "node:fs";
32
- import { join, resolve } from "node:path";
33
-
34
30
  import { jsonObjectOf, round4, timestampMs } from "./metrics-evals.mjs";
31
+ import { UNKNOWN_COST_REASONS } from "../run/usage.mjs";
35
32
  import { MAX_ATTEMPTS as NOTIFY_MAX_ATTEMPTS } from "../notify/index.mjs";
36
- import { renderMetricsJson, renderMetricsReport } from "../report/metrics-report.mjs";
37
- import { campaignDir } from "./layout.mjs";
38
- import { readCampaign } from "./record.mjs";
39
- import { listNodeSnapshots, nodeSnapshotPath } from "../run/node-store.mjs";
40
- import { runsRoot } from "../run/paths.mjs";
41
33
 
42
34
  /** Node statuses that are not terminal: everything else settles a logical node. */
43
35
  const OPEN_STATUSES = new Set(["pending", "running"]);
@@ -57,9 +49,9 @@ const UNKNOWN_COST_PROVENANCE = "unknown";
57
49
 
58
50
  /** @typedef {Record<string, unknown>} JsonObject */
59
51
  /** @typedef {"down"|"up"|"informative"} Direction */
60
- /** @typedef {{value: number|null, direction: Direction, count: number}} Indicator */
61
- /** @typedef {Indicator & {unknownCount: number}} CostIndicator */
62
- /** @typedef {{value: Record<string, number>|null, direction: Direction, count: number}} GroupedIndicator */
52
+ /** @typedef {{value: number|null, direction: Direction, count: number, numerator?: number, denominator?: number, excludedRunIds?: string[], missingSources?: string[]}} Indicator */
53
+ /** @typedef {Indicator & {unknownCount: number, unknownCountByReason: Record<string, number>, unknownFractionByReason: Record<string, number>}} CostIndicator */
54
+ /** @typedef {{value: Record<string, number>|null, direction: Direction, count: number, numerator?: Record<string, number>, denominator?: Record<string, number>, excludedRunIds?: string[], missingSources?: string[]}} GroupedIndicator */
63
55
  /** @typedef {{atMs: number, index: number, event: JsonObject}} RunEvent */
64
56
  /** @typedef {{runId: string, id: string, status: string, attempt?: number|null, revisions?: number|null, review?: string|null}} RunNode */
65
57
 
@@ -69,6 +61,11 @@ const UNKNOWN_COST_PROVENANCE = "unknown";
69
61
  * usageRecords?: unknown[],
70
62
  * notifications?: unknown[],
71
63
  * nodes?: RunNode[],
64
+ * journal?: unknown[],
65
+ * campaign?: JsonObject,
66
+ * requirements?: unknown[],
67
+ * excludedRunIds?: string[],
68
+ * missingSources?: string[],
72
69
  * now?: number,
73
70
  * }} MetricsInput
74
71
  */
@@ -85,6 +82,8 @@ const UNKNOWN_COST_PROVENANCE = "unknown";
85
82
  * blockingJudgeFirstPassRate: GroupedIndicator,
86
83
  * notifyReceiptRate: Indicator,
87
84
  * silentStallRate: Indicator,
85
+ * intentToVerifiedSeconds: Indicator,
86
+ * humanTouches: Indicator,
88
87
  * }} CampaignMetrics
89
88
  */
90
89
 
@@ -96,31 +95,94 @@ const UNKNOWN_COST_PROVENANCE = "unknown";
96
95
  * @param {MetricsInput} [input]
97
96
  * @returns {CampaignMetrics}
98
97
  */
99
- export function projectMetrics({ events = [], usageRecords = [], notifications = [], nodes = [], now = Date.now() } = {}) {
98
+ export function projectMetrics({ events = [], usageRecords = [], notifications = [], nodes = [], journal = [], campaign = undefined, requirements = undefined, excludedRunIds = [], missingSources = [], now = Date.now() } = {}) {
100
99
  void now;
101
100
  const eventList = events.map(jsonObjectOf).filter((event) => event !== null);
102
- const nodesDone = nodesDoneRateOf(nodes);
101
+ const excluded = excludedRunIdsFor(campaign, excludedRunIds);
102
+ const eligibleNodes = nodes.filter((node) => !excluded.includes(node.runId));
103
+ const eligibleEvents = eventList.filter((event) => typeof event.runId !== "string" || !excluded.includes(event.runId));
104
+ const eligibleNotifications = notifications.filter((record) => {
105
+ const object = jsonObjectOf(record);
106
+ return object === null || typeof object.runId !== "string" || !excluded.includes(object.runId);
107
+ });
108
+ const nodesDone = nodesDoneRateOf(eligibleNodes);
103
109
  const runs = runsPerCampaignOf(nodes, eventList);
104
110
  const closedCheckpoints = closedCheckpointsOf(nodes);
105
111
  const span = eventSpanOf(eventList);
106
112
  const usage = usageTotalsOf(usageRecords);
107
- const gates = blockingJudgeFirstPassRateOf(nodes, eventList);
108
- const notify = notifyReceiptRateOf(notifications);
109
- const stalls = silentStallRateOf(nodes, eventList);
110
- return {
111
- nodesDoneRate: measured("up", nodesDone.terminal, nodesDone.terminal === 0 ? null : nodesDone.done / nodesDone.terminal),
113
+ const gates = blockingJudgeFirstPassRateOf(eligibleNodes, eligibleEvents);
114
+ const notify = notifyReceiptRateOf(eligibleNotifications);
115
+ const stalls = silentStallRateOf(eligibleNodes, eligibleEvents);
116
+ const declaredRequirements = campaign !== undefined && Array.isArray(campaign.requirements) ? campaign.requirements : requirements;
117
+ const northStar = intentToVerifiedSecondsOf(journal, declaredRequirements, eventList);
118
+ const touches = humanTouchesOf(journal, eventList);
119
+ const metrics = {
120
+ nodesDoneRate: rate("up", nodesDone.done, nodesDone.terminal, excluded),
112
121
  linkedRunsPerClosedCheckpoint: measured("down", closedCheckpoints, closedCheckpoints === 0 ? null : runs / closedCheckpoints),
113
122
  runsPerCampaign: measured("down", runs, runs === 0 ? null : runs),
114
123
  wallClockSec: measured("down", span.count, span.seconds),
115
124
  usageTokensByKind: grouped("informative", usage.tokenCount, usage.tokensByKind),
116
125
  usageTokensByKindByRuntime: grouped("informative", usage.tokenCount, usage.tokensByKindByRuntime),
117
- usageCostUsd: { ...measured("down", usage.costCount, usage.costCount === 0 ? null : usage.costUsd), unknownCount: usage.unknownCount },
118
- blockingJudgeFirstPassRate: grouped("up", gates.count, gates.value),
119
- notifyReceiptRate: measured("up", notify.count, notify.count === 0 ? null : notify.satisfied / notify.count),
120
- silentStallRate: measured("down", stalls.activeIntervals, stalls.activeHours === 0 ? null : stalls.stalled / stalls.activeHours),
126
+ usageCostUsd: {
127
+ ...measured("down", usage.costCount, usage.costCount === 0 ? null : usage.costUsd),
128
+ unknownCount: usage.unknownCount,
129
+ unknownCountByReason: usage.unknownCountByReason,
130
+ unknownFractionByReason: usage.unknownFractionByReason,
131
+ },
132
+ blockingJudgeFirstPassRate: groupedRate("up", gates, excluded),
133
+ notifyReceiptRate: rate("up", notify.satisfied, notify.count, excluded),
134
+ silentStallRate: rate("down", stalls.stalled, stalls.activeHours, excluded, stalls.activeIntervals),
135
+ intentToVerifiedSeconds: northStar,
136
+ humanTouches: touches,
137
+ };
138
+ return applyMissingSources(metrics, missingSources);
139
+ }
140
+
141
+ /**
142
+ * A missing ledger file is incomplete evidence, not an empty measurement.
143
+ * Preserve the exact filenames so both report forms explain the gap.
144
+ *
145
+ * @param {CampaignMetrics} metrics
146
+ * @param {string[]} missingSources
147
+ * @returns {CampaignMetrics}
148
+ */
149
+ function applyMissingSources(metrics, missingSources) {
150
+ const missing = [...new Set(missingSources)].sort();
151
+ if (missing.length === 0) return metrics;
152
+ /** @template {Indicator|GroupedIndicator} T @param {T} indicator @param {string[]} suffixes @returns {T} */
153
+ const withMissing = (indicator, suffixes) => {
154
+ const sources = missing.filter((source) => suffixes.some((suffix) => source.endsWith(`.${suffix}`) || source === suffix));
155
+ return /** @type {T} */ (sources.length === 0 ? indicator : { ...indicator, value: null, missingSources: sources });
156
+ };
157
+ return {
158
+ nodesDoneRate: withMissing(metrics.nodesDoneRate, ["nodes.json"]),
159
+ linkedRunsPerClosedCheckpoint: withMissing(metrics.linkedRunsPerClosedCheckpoint, ["nodes.json"]),
160
+ runsPerCampaign: withMissing(metrics.runsPerCampaign, ["events.jsonl"]),
161
+ wallClockSec: withMissing(metrics.wallClockSec, ["events.jsonl"]),
162
+ usageTokensByKind: withMissing(metrics.usageTokensByKind, ["usage.jsonl"]),
163
+ usageTokensByKindByRuntime: withMissing(metrics.usageTokensByKindByRuntime, ["usage.jsonl"]),
164
+ usageCostUsd: withMissing(metrics.usageCostUsd, ["usage.jsonl"]),
165
+ blockingJudgeFirstPassRate: withMissing(metrics.blockingJudgeFirstPassRate, ["nodes.json", "events.jsonl"]),
166
+ notifyReceiptRate: withMissing(metrics.notifyReceiptRate, ["notify.jsonl"]),
167
+ silentStallRate: withMissing(metrics.silentStallRate, ["nodes.json", "events.jsonl"]),
168
+ intentToVerifiedSeconds: withMissing(metrics.intentToVerifiedSeconds, ["journal.jsonl", "campaign.json"]),
169
+ humanTouches: withMissing(metrics.humanTouches, ["journal.jsonl"]),
121
170
  };
122
171
  }
123
172
 
173
+ /** @param {JsonObject|undefined} campaign @param {string[]} requested @returns {string[]} */
174
+ export function excludedRunIdsFor(campaign, requested) {
175
+ const fromCampaign = Array.isArray(campaign?.replacements)
176
+ ? campaign.replacements.flatMap((entry) => {
177
+ const replacement = jsonObjectOf(entry);
178
+ return replacement !== null && Array.isArray(replacement.runIds)
179
+ ? replacement.runIds.filter((id) => typeof id === "string")
180
+ : [];
181
+ })
182
+ : [];
183
+ return [...new Set([...requested, ...fromCampaign])].sort();
184
+ }
185
+
124
186
  /**
125
187
  * Wrap one scalar indicator. A count of zero is a missing measurement and
126
188
  * yields a null value whatever was computed; a non-finite value is missing too.
@@ -135,6 +197,18 @@ function measured(direction, count, value) {
135
197
  return { value: missing ? null : round4(/** @type {number} */ (value)), direction, count };
136
198
  }
137
199
 
200
+ /** @param {Direction} direction @param {number} numerator @param {number} denominator @param {string[]} excludedRunIds @param {number} [count] @returns {Indicator} */
201
+ function rate(direction, numerator, denominator, excludedRunIds, count = denominator) {
202
+ return {
203
+ value: denominator === 0 || !Number.isFinite(numerator / denominator) ? null : round4(numerator / denominator),
204
+ direction,
205
+ count,
206
+ numerator,
207
+ denominator,
208
+ excludedRunIds: [...excludedRunIds],
209
+ };
210
+ }
211
+
138
212
  /**
139
213
  * Wrap one indicator reported per group (lane or runtime). The empty group map
140
214
  * is a missing measurement, not a measured zero.
@@ -148,6 +222,106 @@ function grouped(direction, count, value) {
148
222
  return { value: count === 0 || Object.keys(value).length === 0 ? null : value, direction, count };
149
223
  }
150
224
 
225
+ /** @param {{value: Record<string, number>, numerator: Record<string, number>, denominator: Record<string, number>, count: number}} groups @param {Direction} direction @param {string[]} excludedRunIds @returns {GroupedIndicator} */
226
+ function groupedRate(direction, groups, excludedRunIds) {
227
+ return {
228
+ value: groups.count === 0 || Object.keys(groups.value).length === 0 ? null : groups.value,
229
+ direction,
230
+ count: groups.count,
231
+ numerator: groups.numerator,
232
+ denominator: groups.denominator,
233
+ excludedRunIds: [...excludedRunIds],
234
+ };
235
+ }
236
+
237
+ /**
238
+ * The north star is complete only when every declared requirement has an
239
+ * approved closure evidence entry. The completion instant is the matching
240
+ * done/proof event, not campaign close, so a late retrospective cannot make
241
+ * the work look slower or faster than it was.
242
+ *
243
+ * @param {unknown[]} journal
244
+ * @param {unknown[]|undefined} requirements
245
+ * @param {JsonObject[]} events
246
+ * @returns {Indicator}
247
+ */
248
+ function intentToVerifiedSecondsOf(journal, requirements, events) {
249
+ const entries = journal.map(jsonObjectOf).filter((entry) => entry !== null);
250
+ const initialized = entries
251
+ .filter((entry) => entry.type === "campaign.initialized")
252
+ .map((entry) => timestampMs(entry.at))
253
+ .find((atMs) => Number.isFinite(atMs));
254
+ if (!Number.isFinite(initialized) || !Array.isArray(requirements) || requirements.length === 0) {
255
+ return measured("down", 0, null);
256
+ }
257
+ /** @type {number[]} */
258
+ const proofTimes = [];
259
+ for (const raw of requirements) {
260
+ const requirement = jsonObjectOf(raw);
261
+ if (requirement === null || requirement.status !== "covered" || !Array.isArray(requirement.nodes)) {
262
+ return measured("down", 0, null);
263
+ }
264
+ const approved = requirement.nodes.flatMap((rawEvidence) => {
265
+ const evidence = jsonObjectOf(rawEvidence);
266
+ return evidence !== null && evidence.passed === true && (evidence.verdict === null || evidence.verdict === "pass") ? [evidence] : [];
267
+ });
268
+ if (approved.length === 0) return measured("down", 0, null);
269
+ const times = approved.flatMap((evidence) => proofTimesForEvidence(evidence, events));
270
+ if (times.length === 0) return measured("down", 0, null);
271
+ proofTimes.push(Math.min(...times));
272
+ }
273
+ const lastProof = Math.max(...proofTimes);
274
+ return measured("down", 1, (lastProof - /** @type {number} */ (initialized)) / 1000);
275
+ }
276
+
277
+ /** @param {JsonObject} evidence @param {JsonObject[]} events @returns {number[]} */
278
+ function proofTimesForEvidence(evidence, events) {
279
+ for (const field of ["approvedAt", "at"]) {
280
+ const direct = timestampMs(evidence[field]);
281
+ if (Number.isFinite(direct)) return [direct];
282
+ }
283
+ return events
284
+ .filter((event) => event.runId === evidence.runId && event.node === evidence.node)
285
+ .filter((event) => event.to === "done" || event.proofApproved === true || (event.phase === "verification" && event.passed === true))
286
+ .map((event) => timestampMs(event.at))
287
+ .filter((atMs) => Number.isFinite(atMs));
288
+ }
289
+
290
+ /** @param {unknown[]} journal @param {JsonObject[]} events @returns {Indicator} */
291
+ function humanTouchesOf(journal, events) {
292
+ const entries = journal.map(jsonObjectOf).filter((entry) => entry !== null);
293
+ if (entries.length === 0) return { value: null, direction: "informative", count: 0 };
294
+ const launchTimes = entries
295
+ .filter((entry) => entry.type === "run.registered")
296
+ .map((entry) => timestampMs(entry.at))
297
+ .filter((atMs) => Number.isFinite(atMs));
298
+ if (launchTimes.length === 0) return { value: 0, direction: "informative", count: 0 };
299
+ const firstLaunch = Math.min(...launchTimes);
300
+ const recoveryTouches = events
301
+ .filter(isRecordedOperatorEvent)
302
+ .sort((left, right) => timestampMs(left.at) - timestampMs(right.at))
303
+ .filter((event, index, all) => event.to !== "canceled" || all.findIndex((candidate) => candidate.to === "canceled" && candidate.runId === event.runId) === index);
304
+ const touches = [...entries.filter(isRecordedJournalTouch), ...recoveryTouches].filter((entry) => {
305
+ const atMs = timestampMs(entry.at);
306
+ return Number.isFinite(atMs) && atMs > firstLaunch;
307
+ });
308
+ return { value: touches.length, direction: "informative", count: touches.length };
309
+ }
310
+
311
+ /** @param {JsonObject} entry @returns {boolean} */
312
+ function isRecordedJournalTouch(entry) {
313
+ return entry.type === "question.resolved"
314
+ || (entry.type === "operator.command" && entry.command === "campaign add-contract");
315
+ }
316
+
317
+ /** @param {JsonObject} entry @returns {boolean} */
318
+ function isRecordedOperatorEvent(entry) {
319
+ const override = jsonObjectOf(entry.override);
320
+ return entry.to === "canceled"
321
+ || override?.kind === "operator-answer"
322
+ || entry.recovery === "reconcile_acknowledged";
323
+ }
324
+
151
325
  /**
152
326
  * Logical nodes done at any attempt, over logical nodes that reached a
153
327
  * terminal state. A node still `pending` or `running` at close is censored:
@@ -223,7 +397,7 @@ function eventSpanOf(events) {
223
397
  * invocation is counted separately rather than folded into a measured zero.
224
398
  *
225
399
  * @param {unknown[]} usageRecords
226
- * @returns {{tokensByKind: Record<string, number>, tokensByKindByRuntime: Record<string, number>, tokenCount: number, costUsd: number|null, costCount: number, unknownCount: number}}
400
+ * @returns {{tokensByKind: Record<string, number>, tokensByKindByRuntime: Record<string, number>, tokenCount: number, costUsd: number|null, costCount: number, unknownCount: number, unknownCountByReason: Record<string, number>, unknownFractionByReason: Record<string, number>}}
227
401
  */
228
402
  function usageTotalsOf(usageRecords) {
229
403
  const tokensByKind = { inputTokens: 0, cacheReadInputTokens: 0, outputTokens: 0 };
@@ -233,6 +407,8 @@ function usageTotalsOf(usageRecords) {
233
407
  let costUsd = 0;
234
408
  let costCount = 0;
235
409
  let unknownCount = 0;
410
+ /** @type {Record<string, number>} */
411
+ const unknownCountByReason = {};
236
412
  const kinds = /** @type {("inputTokens"|"cacheReadInputTokens"|"outputTokens")[]} */ (["inputTokens", "cacheReadInputTokens", "outputTokens"]);
237
413
  for (const raw of usageRecords) {
238
414
  const record = jsonObjectOf(raw);
@@ -252,9 +428,26 @@ function usageTotalsOf(usageRecords) {
252
428
  costCount += 1;
253
429
  } else {
254
430
  unknownCount += 1;
431
+ const reason = typeof record.unknownReason === "string" && UNKNOWN_COST_REASONS.includes(record.unknownReason)
432
+ ? record.unknownReason
433
+ : "legacy";
434
+ unknownCountByReason[reason] = (unknownCountByReason[reason] ?? 0) + 1;
255
435
  }
256
436
  }
257
- return { tokensByKind, tokensByKindByRuntime, tokenCount, costUsd: costCount === 0 ? null : costUsd, costCount, unknownCount };
437
+ const totalCount = costCount + unknownCount;
438
+ const unknownFractionByReason = Object.fromEntries(
439
+ Object.entries(unknownCountByReason).map(([reason, count]) => [reason, round4(count / totalCount)]),
440
+ );
441
+ return {
442
+ tokensByKind,
443
+ tokensByKindByRuntime,
444
+ tokenCount,
445
+ costUsd: costCount === 0 ? null : costUsd,
446
+ costCount,
447
+ unknownCount,
448
+ unknownCountByReason,
449
+ unknownFractionByReason,
450
+ };
258
451
  }
259
452
 
260
453
  /**
@@ -265,7 +458,7 @@ function usageTotalsOf(usageRecords) {
265
458
  *
266
459
  * @param {RunNode[]} nodes
267
460
  * @param {JsonObject[]} events
268
- * @returns {{value: Record<string, number>, count: number}}
461
+ * @returns {{value: Record<string, number>, numerator: Record<string, number>, denominator: Record<string, number>, count: number}}
269
462
  */
270
463
  function blockingJudgeFirstPassRateOf(nodes, events) {
271
464
  /** @type {Map<string, string|null>} */
@@ -288,11 +481,17 @@ function blockingJudgeFirstPassRateOf(nodes, events) {
288
481
  }
289
482
  /** @type {Record<string, number>} */
290
483
  const value = {};
484
+ /** @type {Record<string, number>} */
485
+ const numerator = {};
486
+ /** @type {Record<string, number>} */
487
+ const denominator = {};
291
488
  for (const lane of [...lanes.keys()].sort()) {
292
489
  const tally = /** @type {{gated: number, passed: number}} */ (lanes.get(lane));
293
490
  value[lane] = round4(tally.passed / tally.gated);
491
+ numerator[lane] = tally.passed;
492
+ denominator[lane] = tally.gated;
294
493
  }
295
- return { value, count: gated };
494
+ return { value, numerator, denominator, count: gated };
296
495
  }
297
496
 
298
497
  /**
@@ -399,121 +598,3 @@ function groupEventsByRunNode(events) {
399
598
  function orderOf(entry) {
400
599
  return Number.isFinite(entry.atMs) ? entry.atMs : 0;
401
600
  }
402
-
403
- /** Flags of `faberun metrics`, declared here so the router only names them. */
404
- /** @type {import("node:util").ParseArgsOptionsConfig} */
405
- export const METRICS_OPTIONS = { cwd: { type: "string" }, json: { type: "boolean" } };
406
-
407
- const RUN_EVENTS_FILE = "events.jsonl";
408
- const USAGE_LOG_FILE = "usage.jsonl";
409
- const NOTIFY_LOG_FILE = "notify.jsonl";
410
-
411
- /**
412
- * @typedef {{
413
- * campaignId: string,
414
- * runIds: string[],
415
- * events: unknown[],
416
- * usageRecords: unknown[],
417
- * notifications: unknown[],
418
- * nodes: RunNode[],
419
- * }} MetricsSources
420
- */
421
-
422
- /**
423
- * Read the recorded sources of one campaign: every linked run's persisted
424
- * node snapshots, transition events (tagged with the run id, since a node id
425
- * is only unique within one run), `usage.jsonl` and `notify.jsonl`. A missing
426
- * artefact reads as empty, which the projector reports as a missing
427
- * measurement and never as a measured zero.
428
- *
429
- * @param {string} campaignPath
430
- * @param {{runsDir?: string}} [options]
431
- * @returns {MetricsSources}
432
- */
433
- export function readMetricsSources(campaignPath, { runsDir = join(campaignPath, "..", "..") } = {}) {
434
- const campaign = readCampaign(campaignPath);
435
- /** @type {unknown[]} */
436
- const events = [];
437
- /** @type {unknown[]} */
438
- const usageRecords = [];
439
- /** @type {unknown[]} */
440
- const notifications = [];
441
- /** @type {RunNode[]} */
442
- const nodes = [];
443
- for (const runId of campaign.linkedRunIds) {
444
- for (const record of readJsonlRecords(join(runsDir, runId, RUN_EVENTS_FILE))) events.push({ ...jsonObjectOf(record), runId });
445
- for (const record of readJsonlRecords(join(runsDir, runId, USAGE_LOG_FILE))) usageRecords.push(record);
446
- for (const record of readJsonlRecords(join(runsDir, runId, NOTIFY_LOG_FILE))) notifications.push(record);
447
- for (const node of readRunNodes(join(runsDir, runId))) nodes.push({ ...node, runId });
448
- }
449
- return { campaignId: campaign.id, runIds: [...campaign.linkedRunIds], events, usageRecords, notifications, nodes };
450
- }
451
-
452
- /**
453
- * Persisted node snapshots of one run, reduced to the fields metrics reads.
454
- * Reading is tolerant of a run directory with no `nodes/` yet (freshly
455
- * dispatched) and of a snapshot that fails to parse (never blocks a report on
456
- * a torn write).
457
- *
458
- * @param {string} runDir
459
- * @returns {Omit<RunNode, "runId">[]}
460
- */
461
- function readRunNodes(runDir) {
462
- /** @type {Omit<RunNode, "runId">[]} */
463
- const nodes = [];
464
- for (const name of listNodeSnapshots(runDir)) {
465
- let record;
466
- try {
467
- record = jsonObjectOf(JSON.parse(readFileSync(nodeSnapshotPath(runDir, name.slice(0, -".json".length)), "utf8")));
468
- } catch {
469
- continue;
470
- }
471
- if (record === null || typeof record.id !== "string" || typeof record.status !== "string") continue;
472
- nodes.push({
473
- id: record.id,
474
- status: record.status,
475
- attempt: typeof record.attempt === "number" ? record.attempt : null,
476
- revisions: typeof record.revisions === "number" ? record.revisions : null,
477
- review: typeof record.review === "string" ? record.review : null,
478
- });
479
- }
480
- return nodes;
481
- }
482
-
483
- /**
484
- * `faberun metrics <campaign-id> [--cwd <dir>] [--json]`: project the
485
- * campaign's recorded artefacts and return what the command prints. Reading
486
- * only, and never a write: a report of a closed campaign must not touch it.
487
- *
488
- * @param {string} campaignId
489
- * @param {{cwd?: unknown, json?: unknown}} [values]
490
- * @returns {string}
491
- */
492
- export function renderCampaignMetrics(campaignId, values = {}) {
493
- const runsDir = runsRoot(resolve(typeof values.cwd === "string" && values.cwd !== "" ? values.cwd : process.cwd()));
494
- const sources = readMetricsSources(campaignDir(runsDir, campaignId), { runsDir });
495
- const metrics = projectMetrics(sources);
496
- return values.json === true ? renderMetricsJson(sources, metrics) : renderMetricsReport(sources, metrics);
497
- }
498
-
499
- /**
500
- * Records of one JSONL artefact. An unterminated final line was never a
501
- * committed record — the newline is written with the record — so it is skipped
502
- * rather than parsed.
503
- *
504
- * @param {string} path
505
- * @returns {unknown[]}
506
- */
507
- function readJsonlRecords(path) {
508
- if (!existsSync(path)) return [];
509
- const text = readFileSync(path, "utf8");
510
- const lines = text.split(/\r?\n/u);
511
- /** @type {unknown[]} */
512
- const records = [];
513
- for (let index = 0; index < lines.length; index += 1) {
514
- if (!lines[index].trim()) continue;
515
- if (index === lines.length - 1 && !text.endsWith("\n")) continue;
516
- records.push(JSON.parse(lines[index]));
517
- }
518
- return records;
519
- }
@@ -81,6 +81,18 @@ export function validateCampaign(campaign) {
81
81
  requirePacketHash(/** @type {JsonObject} */ (entry).digest, `campaign.contracts[${index}].digest`);
82
82
  }
83
83
  }
84
+ if (record.replacements !== undefined) {
85
+ if (!Array.isArray(record.replacements)) throw new TypeError("campaign.replacements must be an array");
86
+ for (const [index, entry] of record.replacements.entries()) {
87
+ assertObject(entry, `campaign.replacements[${index}]`);
88
+ const replacement = /** @type {JsonObject} */ (entry);
89
+ requireString(replacement.oldPath, `campaign.replacements[${index}].oldPath`);
90
+ requireString(replacement.newPath, `campaign.replacements[${index}].newPath`);
91
+ requireTimestamp(replacement.at, `campaign.replacements[${index}].at`);
92
+ if (!Array.isArray(replacement.runIds)) throw new TypeError(`campaign.replacements[${index}].runIds must be an array`);
93
+ for (const runId of replacement.runIds) requireId(runId, `campaign.replacements[${index}].runIds[]`);
94
+ }
95
+ }
84
96
  if (record.landBranch !== undefined) requireText(record.landBranch, "campaign.landBranch");
85
97
  // The chain's durable park: the campaign stays active but carries the reason
86
98
  // it stopped, naming the contract, the node and the status when they exist.
@@ -7,6 +7,7 @@ import {
7
7
  closeCampaign,
8
8
  discoverCampaigns,
9
9
  initializeCampaign,
10
+ reledgerCampaign,
10
11
  renderHandoff,
11
12
  resolveCampaign,
12
13
  } from "../campaign/index.mjs";
@@ -18,6 +19,8 @@ import { acknowledgeJournalEvent, appendJournal, appendSeatAllowanceEvent, readJ
18
19
  import { driveCampaignChain } from "../campaign/chain.mjs";
19
20
  import { unparkCampaign } from "../campaign/unpark.mjs";
20
21
  import { readCampaign } from "../campaign/record.mjs";
22
+ import { projectMetrics } from "../campaign/metrics.mjs";
23
+ import { readMetricsSources } from "../campaign/metrics-command.mjs";
21
24
  import { DEFAULT_WAKE_POLL_MS, watchCampaignWake } from "../campaign/watch.mjs";
22
25
  import { allowanceEventFields, sampleAllowance } from "../seat/allowance.mjs";
23
26
  import { detectOperatorHarness } from "../seat/harnesses.mjs";
@@ -80,6 +83,7 @@ const OPERATION_OPTIONS = {
80
83
  "event-id": { type: "string" },
81
84
  },
82
85
  close: { cwd: { type: "string" }, "event-id": { type: "string" } },
86
+ reledger: { cwd: { type: "string" } },
83
87
  supervise: { cwd: { type: "string" }, "allow-main": { type: "boolean" } },
84
88
  unpark: { cwd: { type: "string" }, force: { type: "boolean" }, "event-id": { type: "string" } },
85
89
  "add-contract": { cwd: { type: "string" }, path: { type: "string" } },
@@ -131,6 +135,7 @@ export async function campaignCli(args) {
131
135
  if (operation === "note") return note(campaignId, values);
132
136
  if (operation === "resolve") return resolveQuestion(campaignId, values);
133
137
  if (operation === "close") return close(campaignId, values);
138
+ if (operation === "reledger") return reledger(campaignId, values);
134
139
  if (operation === "supervise") return supervise(campaignId, values);
135
140
  if (operation === "unpark") return unpark(campaignId, values);
136
141
  if (operation === "show") return show(campaignId, values);
@@ -310,10 +315,44 @@ function close(campaignId, values) {
310
315
  renderHandoff(path, runsDir);
311
316
  process.stdout.write(`[campaign] ${closed.campaign.id} closed\n`);
312
317
  process.stdout.write(`[campaign] ledger · docs/campaigns/${closed.campaign.id}/ledger · ${closed.ledgerFiles.length} files\n`);
318
+ reportUnknownCostFraction(path, runsDir);
319
+ for (const skipped of closed.ledgerSkipped) {
320
+ process.stdout.write(`[campaign] ledger skipped · ${skipped.runId}/${skipped.source}\n`);
321
+ }
313
322
  reportRequirementClosure(closed.campaign.requirements ?? []);
314
323
  if (syncAgentSignal(runsDir)) process.stdout.write(`[campaign] AGENTS.md signal updated\n`);
315
324
  }
316
325
 
326
+ /**
327
+ * Print the unknown-cost fraction beside the close's ledger summary. The
328
+ * denominator is every usage record, so each reason can be recomputed from the
329
+ * copied ledger rather than from the operator's transient run directory.
330
+ *
331
+ * @param {string} campaignPath
332
+ * @param {string} runsDir
333
+ */
334
+ function reportUnknownCostFraction(campaignPath, runsDir) {
335
+ const metrics = projectMetrics(readMetricsSources(campaignPath, { runsDir }));
336
+ const indicator = metrics.usageCostUsd;
337
+ const total = indicator.count + indicator.unknownCount;
338
+ const reasons = Object.entries(indicator.unknownFractionByReason)
339
+ .map(([reason, fraction]) => `${reason}=${fraction}`)
340
+ .join(", ");
341
+ process.stdout.write(`[campaign] unknown cost · ${indicator.unknownCount}/${total} (${reasons || "none"})\n`);
342
+ }
343
+
344
+ /**
345
+ * @param {string} campaignId
346
+ * @param {CliValues} values
347
+ */
348
+ function reledger(campaignId, values) {
349
+ const { path } = selectCampaign(campaignId, values);
350
+ const result = reledgerCampaign(path);
351
+ process.stdout.write(`[campaign] ${campaignId} reledger · docs/campaigns/${campaignId}/ledger\n`);
352
+ for (const change of result.copied) process.stdout.write(`[campaign] reledger copied · ${change.runId}/${change.source}\n`);
353
+ for (const change of result.gone) process.stdout.write(`[campaign] reledger gone · ${change.runId}/${change.source}\n`);
354
+ }
355
+
317
356
  /**
318
357
  * Say out loud what the close just wrote, and what it measured.
319
358
  *
@@ -645,7 +684,7 @@ function positiveIntervalMs(value) {
645
684
 
646
685
  function usage() {
647
686
  process.stderr.write(
648
- "usage: faberun campaign <init|watch|attach|note|resolve|close|supervise|unpark|show|list|sync|ack|add-contract|replace-contract|brief> <campaign-id> [--cwd <dir>] ...\n",
687
+ "usage: faberun campaign <init|watch|attach|note|resolve|close|reledger|supervise|unpark|show|list|sync|ack|add-contract|replace-contract|brief> <campaign-id> [--cwd <dir>] ...\n",
649
688
  );
650
689
  process.exitCode = 2;
651
690
  }
@@ -10,13 +10,15 @@ import { readFileSync, writeFileSync } from "node:fs";
10
10
  import { resolve } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { COMMAND_OPTIONS } from "../cli.mjs";
13
+ import { METRICS_MANUAL } from "../campaign/metrics-command.mjs";
13
14
  import CAMPAIGN_OPERATIONS from "./campaign.mjs";
14
15
  import SEAT_OPERATIONS from "./seat.mjs";
15
16
  import CONTRACT_OPERATIONS from "./contract.mjs";
16
17
  import SKILLS_OPERATIONS from "./skills.mjs";
17
18
  import SPEC_OPERATIONS from "./spec.mjs";
18
19
 
19
- /** @typedef {{type: "string"|"boolean", multiple?: boolean}} FlagSpec */
20
+ /** @typedef {{value: string, effect: string, default: string}} ManualFlag */
21
+ /** @typedef {{type: "string"|"boolean", multiple?: boolean, manual?: ManualFlag}} FlagSpec */
20
22
  /** @typedef {{flags?: Record<string, FlagSpec>, operations?: Record<string, Record<string, FlagSpec>>}} VerbSurface */
21
23
  /** @typedef {{verbs: Record<string, VerbSurface>}} Surface */
22
24
 
@@ -51,7 +53,12 @@ const CONTAINER_OPERATIONS = {
51
53
  export function collectSurface() {
52
54
  /** @type {Record<string, VerbSurface>} */
53
55
  const verbs = {};
54
- for (const [verb, flags] of Object.entries(COMMAND_OPTIONS)) verbs[verb] = { flags };
56
+ for (const [verb, flags] of Object.entries(COMMAND_OPTIONS)) {
57
+ const manualFlags = verb === "metrics"
58
+ ? Object.fromEntries(Object.entries(flags).map(([name, spec]) => [name, { ...spec, manual: METRICS_MANUAL[name] }]))
59
+ : flags;
60
+ verbs[verb] = { flags: manualFlags };
61
+ }
55
62
  if (verbs.supervise) verbs.supervise.operations = { campaign: CAMPAIGN_OPERATIONS.supervise };
56
63
  for (const [verb, operations] of Object.entries(CONTAINER_OPERATIONS)) verbs[verb] = { operations };
57
64
  return { verbs };
@@ -291,9 +298,10 @@ function buildRows(flags, existingRows) {
291
298
  }
292
299
  return names.map((name) => {
293
300
  const existing = existingRows.get(name);
294
- const value = existing ? existing.value : "<value>";
295
- const effect = existing ? existing.effect : "";
296
- const fallback = existing ? existing.default : "—";
301
+ const manual = flags[name].manual;
302
+ const value = existing?.value && existing.value !== "<value>" ? existing.value : manual?.value ?? existing?.value ?? "<value>";
303
+ const effect = existing?.effect || manual?.effect || "";
304
+ const fallback = existing?.default && existing.default !== "—" ? existing.default : manual?.default ?? existing?.default ?? "—";
297
305
  return `| \`--${name}\` | ${value} | ${effect} | ${fallback} |`;
298
306
  });
299
307
  }
package/src/cli.mjs CHANGED
@@ -39,7 +39,7 @@ import { updateCommand } from "./cli/update.mjs";
39
39
  import { contractCli, validateContractFile } from "./cli/contract.mjs";
40
40
  import { specCli } from "./cli/spec.mjs";
41
41
  import { planCli } from "./cli/plan.mjs";
42
- import { METRICS_OPTIONS, renderCampaignMetrics } from "./campaign/metrics.mjs";
42
+ import { METRICS_OPTIONS, renderCampaignMetrics } from "./campaign/metrics-command.mjs";
43
43
  import { runContract } from "./engine/scheduler.mjs";
44
44
  import { resumeRun } from "./engine/resume.mjs";
45
45
  import { cancelRun } from "./engine/cancel.mjs";