faberun 0.30.0 → 0.31.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/campaign-brief.mjs +17 -6
- package/src/campaign/chain.mjs +124 -8
- package/src/campaign/ledger.mjs +19 -7
- package/src/campaign/metrics-command.mjs +68 -9
- package/src/campaign/metrics.mjs +75 -45
- package/src/campaign/record.mjs +8 -1
- package/src/campaign/reserve.mjs +340 -0
- package/src/campaign/spend.mjs +229 -0
- package/src/campaign/unpark.mjs +54 -1
- package/src/campaign/watch.mjs +314 -37
- package/src/contract/judge-envelope.mjs +17 -2
- package/src/contract/scope-findings.mjs +3 -1
- package/src/contract/task-packet.mjs +40 -5
- package/src/contract/verification.mjs +67 -1
- package/src/engine/aux-admission.mjs +124 -0
- package/src/engine/capacity.mjs +78 -0
- package/src/engine/dispatch.mjs +116 -23
- package/src/engine/failure-cause.mjs +59 -0
- package/src/engine/lifecycle.mjs +3 -2
- package/src/engine/phase-session.mjs +56 -10
- package/src/engine/prompts.mjs +57 -2
- package/src/engine/reserve-admission.mjs +99 -0
- package/src/engine/resume.mjs +3 -1
- package/src/engine/review.mjs +21 -0
- package/src/engine/run-command.mjs +166 -11
- package/src/engine/scheduler.mjs +71 -88
- package/src/engine/settle.mjs +15 -8
- package/src/engine/settlement-overlap.mjs +143 -0
- package/src/engine/verify.mjs +131 -7
- package/src/harnesses/session-metrics.mjs +112 -20
- package/src/notify/index.mjs +86 -10
- package/src/plan/freeze.mjs +7 -2
- package/src/plan/human-step.mjs +36 -0
- package/src/plan/pipeline.mjs +53 -22
- package/src/plan/preflight.mjs +81 -5
- package/src/plan/progress.mjs +101 -1
- package/src/plan/repo-facts.mjs +403 -49
- package/src/plan/resolve.mjs +7 -0
- package/src/plan/revise-context.mjs +108 -0
- package/src/plan/rounds.mjs +180 -45
- package/src/plan/sizing.mjs +33 -1
- package/src/plan/template.mjs +134 -22
- package/src/repo/integrate.mjs +47 -5
- package/src/report/campaign-brief-estimate.mjs +47 -1
- package/src/report/campaign-brief.mjs +27 -0
- package/src/report/locale.mjs +20 -0
- package/src/report/metrics-report.mjs +38 -0
- package/src/report/progress.mjs +194 -10
- package/src/run/usage-windows.mjs +99 -4
- package/src/run/usage.mjs +69 -7
- package/src/web/api.mjs +179 -24
- package/src/web/app.mjs +27 -2
|
@@ -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
|
+
}
|
package/src/campaign/unpark.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
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
|