faberun 0.30.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/package.json +1 -1
  2. package/src/campaign/campaign-brief.mjs +17 -6
  3. package/src/campaign/chain.mjs +124 -8
  4. package/src/campaign/ledger.mjs +19 -7
  5. package/src/campaign/metrics-command.mjs +68 -9
  6. package/src/campaign/metrics.mjs +75 -45
  7. package/src/campaign/record.mjs +8 -1
  8. package/src/campaign/reserve.mjs +340 -0
  9. package/src/campaign/spend.mjs +229 -0
  10. package/src/campaign/unpark.mjs +54 -1
  11. package/src/campaign/watch.mjs +314 -37
  12. package/src/cli/brand.mjs +2 -1
  13. package/src/cli.mjs +52 -2
  14. package/src/contract/judge-envelope.mjs +17 -2
  15. package/src/contract/scope-findings.mjs +3 -1
  16. package/src/contract/task-packet.mjs +40 -5
  17. package/src/contract/verification.mjs +67 -1
  18. package/src/engine/aux-admission.mjs +124 -0
  19. package/src/engine/capacity.mjs +78 -0
  20. package/src/engine/dispatch.mjs +116 -23
  21. package/src/engine/failure-cause.mjs +59 -0
  22. package/src/engine/lifecycle.mjs +3 -2
  23. package/src/engine/phase-session.mjs +56 -10
  24. package/src/engine/prompts.mjs +57 -2
  25. package/src/engine/reserve-admission.mjs +99 -0
  26. package/src/engine/resume.mjs +3 -1
  27. package/src/engine/review.mjs +21 -0
  28. package/src/engine/run-command.mjs +166 -11
  29. package/src/engine/scheduler.mjs +92 -88
  30. package/src/engine/settle.mjs +15 -8
  31. package/src/engine/settlement-overlap.mjs +143 -0
  32. package/src/engine/verify.mjs +131 -7
  33. package/src/harnesses/session-metrics.mjs +112 -20
  34. package/src/notify/index.mjs +86 -10
  35. package/src/plan/freeze.mjs +7 -2
  36. package/src/plan/human-step.mjs +36 -0
  37. package/src/plan/pipeline.mjs +53 -22
  38. package/src/plan/preflight.mjs +81 -5
  39. package/src/plan/progress.mjs +101 -1
  40. package/src/plan/repo-facts.mjs +403 -49
  41. package/src/plan/resolve.mjs +7 -0
  42. package/src/plan/revise-context.mjs +108 -0
  43. package/src/plan/rounds.mjs +180 -45
  44. package/src/plan/sizing.mjs +33 -1
  45. package/src/plan/template.mjs +134 -22
  46. package/src/repo/integrate.mjs +47 -5
  47. package/src/report/campaign-brief-estimate.mjs +47 -1
  48. package/src/report/campaign-brief.mjs +31 -2
  49. package/src/report/locale.mjs +20 -0
  50. package/src/report/message.mjs +13 -1
  51. package/src/report/metrics-report.mjs +38 -0
  52. package/src/report/progress.mjs +194 -10
  53. package/src/report/render.mjs +1 -1
  54. package/src/report/report-html.mjs +235 -0
  55. package/src/report/report-video.mjs +323 -0
  56. package/src/run/usage-windows.mjs +99 -4
  57. package/src/run/usage.mjs +69 -7
  58. package/src/web/api.mjs +179 -24
  59. package/src/web/app.mjs +27 -2
  60. package/src/web/server.mjs +32 -0
@@ -0,0 +1,229 @@
1
+ /**
2
+ * The money half of a metrics projection: priced dollars per class, the
3
+ * retries, and the optional reserve (ADR 0011).
4
+ *
5
+ * `metrics.mjs` projects the section-6 indicators from a campaign's parsed
6
+ * records. Costs are the one part of that projection with its own vocabulary —
7
+ * a class split, a retry subset, and a reserve lifecycle of held, released and
8
+ * late-charged dollars — and it is arithmetic over records rather than a
9
+ * measurement of the campaign's shape, so it lives here. Growing it inside
10
+ * `metrics.mjs` pushed that module past the 800-line ceiling; this is the
11
+ * second job it was carrying, not a new one.
12
+ *
13
+ * Every function takes already-parsed usage records and reads nothing else:
14
+ * no path, no clock, no network. Unmeasured spend stays `null` at every level,
15
+ * because a class with no priced record and a class that cost nothing are
16
+ * different facts.
17
+ */
18
+
19
+ import { finite } from "../util.mjs";
20
+ import { UNKNOWN_COST_REASONS } from "../run/usage.mjs";
21
+ import { round4 } from "./metrics-evals.mjs";
22
+
23
+ /** @typedef {Record<string, unknown>} JsonObject */
24
+ /** @typedef {"planning"|"worker"|"judge"} SpendClass */
25
+ /**
26
+ * The optional reserve a campaign configured, projected from the same records
27
+ * the spend is. `configuredUsd` is the campaign's own balance
28
+ * (`campaign.reserveUsd`), `heldUsd` is what is still reserved against charges
29
+ * that have not arrived, `releasedUsd` is what those charges released, and
30
+ * `lateChargeUsd` is the part of a released reservation the real charge went
31
+ * past. `cap` is always false: the reserve refuses a new dispatch when the
32
+ * balance is short and cannot bound a call already running, so no surface may
33
+ * read it as an absolute ceiling.
34
+ *
35
+ * @typedef {{
36
+ * configuredUsd: number|null,
37
+ * heldUsd: number|null,
38
+ * heldCount: number,
39
+ * releasedUsd: number|null,
40
+ * reconciledCount: number,
41
+ * chargedUsd: number|null,
42
+ * lateChargeUsd: number|null,
43
+ * lateChargeCount: number,
44
+ * unknownExposureCount: number,
45
+ * cap: boolean,
46
+ * missingSources?: string[],
47
+ * }} ReserveProjection
48
+ */
49
+ /**
50
+ * @typedef {{
51
+ * costUsd: number|null,
52
+ * costCount: number,
53
+ * unknownCount: number,
54
+ * unknownCountByReason: Record<string, number>,
55
+ * unknownFractionByReason: Record<string, number>,
56
+ * }} CostTotals
57
+ */
58
+
59
+ /** A usage record with no provider-reported cost is `unknown` provenance (`appendUsageRecord`). */
60
+ const UNKNOWN_COST_PROVENANCE = "unknown";
61
+
62
+ /**
63
+ * The priced cost a record carries, or null when the provider's number never
64
+ * arrived. One flag decides both: a cost that is missing and a cost that
65
+ * arrived as zero are different facts everywhere this module reports spend.
66
+ *
67
+ * @param {JsonObject} record
68
+ * @returns {number|null}
69
+ */
70
+ export function pricedCostOf(record) {
71
+ const cost = finite(record.costUsd);
72
+ return cost === null || record.costProvenance === UNKNOWN_COST_PROVENANCE ? null : cost;
73
+ }
74
+
75
+ /** @param {JsonObject} record @returns {string} */
76
+ function unknownReasonOf(record) {
77
+ return typeof record.unknownReason === "string" && UNKNOWN_COST_REASONS.includes(record.unknownReason)
78
+ ? record.unknownReason
79
+ : "legacy";
80
+ }
81
+
82
+ /**
83
+ * Priced cost and unknown-cost accounting for one set of usage records: the
84
+ * total of the records that carry a provider cost, and the reasons the rest
85
+ * carry none. The sum is never rounded, so a caller that adds nothing to it
86
+ * reports the same dollars the campaign total always reported.
87
+ *
88
+ * @param {JsonObject[]} records
89
+ * @returns {CostTotals}
90
+ */
91
+ export function costTotalsOf(records) {
92
+ let costUsd = 0;
93
+ let costCount = 0;
94
+ let unknownCount = 0;
95
+ /** @type {Record<string, number>} */
96
+ const unknownCountByReason = {};
97
+ for (const record of records) {
98
+ const cost = pricedCostOf(record);
99
+ if (cost === null) {
100
+ unknownCount += 1;
101
+ const reason = unknownReasonOf(record);
102
+ unknownCountByReason[reason] = (unknownCountByReason[reason] ?? 0) + 1;
103
+ continue;
104
+ }
105
+ costUsd += cost;
106
+ costCount += 1;
107
+ }
108
+ const totalCount = costCount + unknownCount;
109
+ const unknownFractionByReason = Object.fromEntries(
110
+ Object.entries(unknownCountByReason).map(([reason, count]) => [reason, round4(count / totalCount)]),
111
+ );
112
+ return { costUsd: costCount === 0 ? null : costUsd, costCount, unknownCount, unknownCountByReason, unknownFractionByReason };
113
+ }
114
+
115
+ /**
116
+ * Which class a usage record belongs to. A planning stage run is the class the
117
+ * run was launched as (R5: the ledger separates planning from execution), so
118
+ * its stages count as planning whatever role the harness recorded; every other
119
+ * record is a judge's call or the worker call that remains.
120
+ *
121
+ * @param {JsonObject} record
122
+ * @param {Set<string>} planningRunIds
123
+ * @returns {SpendClass}
124
+ */
125
+ export function spendClassOf(record, planningRunIds) {
126
+ if (typeof record.runId === "string" && planningRunIds.has(record.runId)) return "planning";
127
+ return record.role === "judge" ? "judge" : "worker";
128
+ }
129
+
130
+ /**
131
+ * Priced spend split by class, with the unpriced records counted per class.
132
+ * A class with no priced record is absent from the map rather than zero, and
133
+ * `unknownCountByClass` is what says the class was seen and could not be
134
+ * priced: a planning stage that reported no cost must not read as a free one.
135
+ *
136
+ * @param {JsonObject[]} records
137
+ * @param {string[]} planningRunIds
138
+ * @returns {{value: Record<string, number>, count: number, unknownCount: number, unknownCountByClass: Record<string, number>}}
139
+ */
140
+ export function costByClassOf(records, planningRunIds) {
141
+ const planning = new Set(planningRunIds);
142
+ /** @type {Record<string, number>} */
143
+ const value = {};
144
+ /** @type {Record<string, number>} */
145
+ const unknownCountByClass = {};
146
+ let count = 0;
147
+ let unknownCount = 0;
148
+ for (const record of records) {
149
+ const spendClass = spendClassOf(record, planning);
150
+ const cost = pricedCostOf(record);
151
+ if (cost === null) {
152
+ unknownCountByClass[spendClass] = (unknownCountByClass[spendClass] ?? 0) + 1;
153
+ unknownCount += 1;
154
+ continue;
155
+ }
156
+ value[spendClass] = (value[spendClass] ?? 0) + cost;
157
+ count += 1;
158
+ }
159
+ return { value, count, unknownCount, unknownCountByClass };
160
+ }
161
+
162
+ /** Records of a second or later attempt: the retries reported beside the classes. @param {JsonObject[]} records @returns {JsonObject[]} */
163
+ export function retryRecordsOf(records) {
164
+ return records.filter((record) => typeof record.attempt === "number"
165
+ && Number.isFinite(record.attempt)
166
+ && record.attempt > 1);
167
+ }
168
+
169
+ /**
170
+ * The optional reserve, projected from the same usage records the spend is. A
171
+ * record that carries `reservedUsd` had that value reserved before its
172
+ * dispatch: while its provider cost is still unknown the reservation is
173
+ * *held*, and once the real charge arrives the reservation is *released*
174
+ * against it. A charge that landed above its reservation is the late-charge
175
+ * difference, and a record with neither a reservation nor a cost is an
176
+ * exposure with no number at all.
177
+ *
178
+ * @param {JsonObject[]} records
179
+ * @param {JsonObject|undefined} campaign
180
+ * @param {string[]} missingSources
181
+ * @returns {ReserveProjection}
182
+ */
183
+ export function reserveOf(records, campaign, missingSources) {
184
+ let heldUsd = 0;
185
+ let heldCount = 0;
186
+ let releasedUsd = 0;
187
+ let reconciledCount = 0;
188
+ let chargedUsd = 0;
189
+ let lateChargeUsd = 0;
190
+ let lateChargeCount = 0;
191
+ let unknownExposureCount = 0;
192
+ for (const record of records) {
193
+ const reserved = finite(record.reservedUsd);
194
+ const charged = pricedCostOf(record);
195
+ if (reserved === null) {
196
+ if (charged === null) unknownExposureCount += 1;
197
+ continue;
198
+ }
199
+ if (charged === null) {
200
+ heldUsd += reserved;
201
+ heldCount += 1;
202
+ continue;
203
+ }
204
+ releasedUsd += reserved;
205
+ chargedUsd += charged;
206
+ reconciledCount += 1;
207
+ if (charged > reserved) {
208
+ lateChargeUsd += charged - reserved;
209
+ lateChargeCount += 1;
210
+ }
211
+ }
212
+ // A usage source that was not preserved leaves these dollars unknown rather
213
+ // than absent: zero reservations read is not zero reservations made.
214
+ const incomplete = missingSources.length > 0;
215
+ const amount = (/** @type {number} */ total, /** @type {number} */ count) => count === 0 || incomplete ? null : total;
216
+ return {
217
+ configuredUsd: finite(campaign?.reserveUsd),
218
+ heldUsd: amount(heldUsd, heldCount),
219
+ heldCount,
220
+ releasedUsd: amount(releasedUsd, reconciledCount),
221
+ reconciledCount,
222
+ chargedUsd: amount(chargedUsd, reconciledCount),
223
+ lateChargeUsd: amount(lateChargeUsd, lateChargeCount),
224
+ lateChargeCount,
225
+ unknownExposureCount,
226
+ cap: false,
227
+ ...(incomplete ? { missingSources: [...new Set(missingSources)].sort() } : {}),
228
+ };
229
+ }
@@ -1,5 +1,6 @@
1
1
  /**
2
- * The way back from a parked campaign.
2
+ * The campaign's operator-level stop state: the pause it carries, and the way
3
+ * back from a park.
3
4
  *
4
5
  * The chain parks by writing `campaign.attention`, and `parkCampaign` in
5
6
  * `index.mjs` is its only writer. Clearing it needs both the campaign record
@@ -7,6 +8,12 @@
7
8
  * `chain.mjs` already imports `index.mjs`. Putting the clearing in either would
8
9
  * close a runtime import cycle, so it lives here, where both directions of that
9
10
  * dependency are already acyclic.
11
+ *
12
+ * `pauseCampaign` writes the same field for the other kind of stop: the durable
13
+ * pause an operator asks for, which the chain already honours (`attention` is
14
+ * consulted before it dispatches, recovers or advances) and which only an
15
+ * explicit resume clears. It may import `index.mjs`: that dependency runs one
16
+ * way, unlike the clearing path, which needs `chain.mjs`'s classification.
10
17
  */
11
18
  import { randomUUID } from "node:crypto";
12
19
  import { existsSync } from "node:fs";
@@ -17,11 +24,57 @@ import { CAMPAIGN_FILE } from "./layout.mjs";
17
24
  import { readCampaign } from "./record.mjs";
18
25
  import { appendJournal } from "./journal.mjs";
19
26
  import { classifyRunProgress } from "./chain.mjs";
27
+ import { parkCampaign } from "./index.mjs";
20
28
  import { runProgress } from "../engine/supervise.mjs";
21
29
 
22
30
  /** @typedef {import("./index.mjs").Campaign} Campaign */
23
31
  /** @typedef {import("./index.mjs").CampaignAttention} CampaignAttention */
24
32
 
33
+ /**
34
+ * The attention code an operator's pause writes, so a pause the dashboard
35
+ * recorded can be told from the chain's own reasons for stopping.
36
+ */
37
+ export const PAUSE_ATTENTION_CODE = "operator_paused";
38
+
39
+ /**
40
+ * Park a campaign on an operator's pause. The campaign stays active and the
41
+ * chain stops dispatching until the pause is cleared, exactly as it does for
42
+ * its own park; the paused runs are named in the message.
43
+ *
44
+ * A campaign already carrying this pause is left untouched and reports
45
+ * `recorded: false`, so a repeated pause is safe. A campaign parked on anything
46
+ * else is refused: overwriting the chain's reason would hide the failure the
47
+ * operator has to act on.
48
+ *
49
+ * @param {string} campaignPath
50
+ * @param {{at?: string, eventId?: string, runIds?: string[], reason?: string, sessionId?: string}} [options]
51
+ * @returns {{campaign: Campaign, attention: CampaignAttention, recorded: boolean}}
52
+ */
53
+ export function pauseCampaign(campaignPath, options = {}) {
54
+ const at = options.at ?? new Date().toISOString();
55
+ requireTimestamp(at, "at");
56
+ const campaign = readCampaign(campaignPath);
57
+ if (campaign.status === "closed") throw new Error(`campaign is closed: ${campaign.id}`);
58
+ const existing = campaign.attention;
59
+ if (existing?.code === PAUSE_ATTENTION_CODE) return { campaign, attention: existing, recorded: false };
60
+ if (existing) throw new Error(`campaign is parked on ${existing.code}: ${existing.message}`);
61
+ const runIds = options.runIds ?? [];
62
+ const attention = /** @type {CampaignAttention} */ ({
63
+ code: PAUSE_ATTENTION_CODE,
64
+ message: `${options.reason ?? "paused from the dashboard"}${runIds.length > 0 ? `: ${runIds.join(", ")}` : ""}`,
65
+ at,
66
+ });
67
+ const paused = parkCampaign(campaignPath, attention);
68
+ appendJournal(campaignPath, {
69
+ type: "operator.command",
70
+ at,
71
+ eventId: options.eventId ?? randomUUID(),
72
+ command: `campaign pause${runIds.length > 0 ? ` ${runIds.join(" ")}` : ""}`,
73
+ ...(options.sessionId ? { sessionId: options.sessionId } : {}),
74
+ });
75
+ return { campaign: paused, attention, recorded: true };
76
+ }
77
+
25
78
  /**
26
79
  * Clear a campaign's attention once the run it names is no longer parked, and
27
80
  * append the `campaign.unparked` journal event. A closed campaign and a