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.
- package/package.json +1 -1
- package/src/campaign/index.mjs +58 -49
- package/src/campaign/journal.mjs +8 -0
- package/src/campaign/layout.mjs +1 -0
- package/src/campaign/ledger.mjs +270 -0
- package/src/campaign/metrics-command.mjs +232 -0
- package/src/campaign/metrics.mjs +234 -153
- package/src/campaign/record.mjs +12 -0
- package/src/cli/campaign.mjs +40 -1
- package/src/cli/manual.mjs +13 -5
- package/src/cli.mjs +1 -1
- package/src/engine/bulk-read.mjs +2 -1
- package/src/engine/lifecycle.mjs +1 -2
- package/src/engine/scheduler.mjs +2 -1
- package/src/engine/settle.mjs +1 -1
- package/src/report/metrics-report.mjs +16 -3
- package/src/run/usage.mjs +61 -4
package/src/campaign/metrics.mjs
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Metrics projector (TECH-SPEC lean section 6). One pure function over a
|
|
3
|
-
* campaign's own
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* a
|
|
7
|
-
*
|
|
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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
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(
|
|
108
|
-
const notify = notifyReceiptRateOf(
|
|
109
|
-
const stalls = silentStallRateOf(
|
|
110
|
-
|
|
111
|
-
|
|
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: {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
}
|
package/src/campaign/record.mjs
CHANGED
|
@@ -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.
|
package/src/cli/campaign.mjs
CHANGED
|
@@ -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
|
}
|
package/src/cli/manual.mjs
CHANGED
|
@@ -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 {{
|
|
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))
|
|
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
|
|
295
|
-
const
|
|
296
|
-
const
|
|
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";
|