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.
- 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/cli/brand.mjs +2 -1
- package/src/cli.mjs +52 -2
- 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 +92 -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 +31 -2
- package/src/report/locale.mjs +20 -0
- package/src/report/message.mjs +13 -1
- package/src/report/metrics-report.mjs +38 -0
- package/src/report/progress.mjs +194 -10
- package/src/report/render.mjs +1 -1
- package/src/report/report-html.mjs +235 -0
- package/src/report/report-video.mjs +323 -0
- 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
- package/src/web/server.mjs +32 -0
package/src/campaign/metrics.mjs
CHANGED
|
@@ -22,14 +22,30 @@
|
|
|
22
22
|
* 1 only when a whole run had to be re-authored after a failure that
|
|
23
23
|
* `resume` could not repair.
|
|
24
24
|
*
|
|
25
|
+
* Costs are split the way the ledger separates them: the stages of a planning
|
|
26
|
+
* run, a judge's calls, and the worker calls that remain, with the retries the
|
|
27
|
+
* attempt counter records reported beside the split. Unmeasured spend stays
|
|
28
|
+
* unknown at every level — a class with no priced record is absent from the
|
|
29
|
+
* map and named in `unknownCountByClass` instead, never folded into a measured
|
|
30
|
+
* zero.
|
|
31
|
+
*
|
|
32
|
+
* The optional reserve a campaign configures (ADR 0011) is projected beside
|
|
33
|
+
* the spend: what is still held, what was released when the real charge
|
|
34
|
+
* arrived, and the part of a late charge that went past its reservation. A
|
|
35
|
+
* reservation blocks a new dispatch; it is not an absolute cap, and the
|
|
36
|
+
* projection says so rather than implying provider-side enforcement. All of
|
|
37
|
+
* that arithmetic is `spend.mjs`; this module projects the indicators.
|
|
38
|
+
*
|
|
25
39
|
* Filesystem reads and the `faberun metrics` command's flags live in
|
|
26
40
|
* `metrics-command.mjs`; keeping them separate leaves this module a pure
|
|
27
41
|
* function over parsed records.
|
|
28
42
|
*/
|
|
29
43
|
|
|
30
44
|
import { jsonObjectOf, round4, timestampMs } from "./metrics-evals.mjs";
|
|
31
|
-
import { UNKNOWN_COST_REASONS } from "../run/usage.mjs";
|
|
32
45
|
import { MAX_ATTEMPTS as NOTIFY_MAX_ATTEMPTS } from "../notify/index.mjs";
|
|
46
|
+
import { costByClassOf, costTotalsOf, pricedCostOf, reserveOf, retryRecordsOf } from "./spend.mjs";
|
|
47
|
+
|
|
48
|
+
/** @typedef {import("./spend.mjs").ReserveProjection} ReserveProjection */
|
|
33
49
|
|
|
34
50
|
/** Node statuses that are not terminal: everything else settles a logical node. */
|
|
35
51
|
const OPEN_STATUSES = new Set(["pending", "running"]);
|
|
@@ -44,8 +60,6 @@ const SETTLED_NOTIFY_STATUSES = new Set(["delivered", "no_transport", "filtered"
|
|
|
44
60
|
/** Target latency for a terminal/attention event to carry a settled receipt (TECH-SPEC section 6). */
|
|
45
61
|
const NOTIFY_TARGET_SEC = 60;
|
|
46
62
|
const SECONDS_PER_HOUR = 3600;
|
|
47
|
-
/** A usage record with no provider-reported cost is `unknown` provenance (`appendUsageRecord`). */
|
|
48
|
-
const UNKNOWN_COST_PROVENANCE = "unknown";
|
|
49
63
|
|
|
50
64
|
/** @typedef {Record<string, unknown>} JsonObject */
|
|
51
65
|
/** @typedef {"down"|"up"|"informative"} Direction */
|
|
@@ -53,6 +67,7 @@ const UNKNOWN_COST_PROVENANCE = "unknown";
|
|
|
53
67
|
/** @typedef {Indicator & {unknownCount: number, unknownCountByReason: Record<string, number>, unknownFractionByReason: Record<string, number>}} CostIndicator */
|
|
54
68
|
/** @typedef {Indicator & {unknownCount: number}} JudgeCostShareIndicator */
|
|
55
69
|
/** @typedef {{value: Record<string, number>|null, direction: Direction, count: number, numerator?: Record<string, number>, denominator?: Record<string, number>, excludedRunIds?: string[], missingSources?: string[]}} GroupedIndicator */
|
|
70
|
+
/** @typedef {GroupedIndicator & {unknownCount: number, unknownCountByClass: Record<string, number>}} ClassCostIndicator */
|
|
56
71
|
/** @typedef {{atMs: number, index: number, event: JsonObject}} RunEvent */
|
|
57
72
|
/** @typedef {{runId: string, id: string, status: string, attempt?: number|null, revisions?: number|null, review?: string|null, judgeIndependence?: string|null}} RunNode */
|
|
58
73
|
|
|
@@ -66,6 +81,7 @@ const UNKNOWN_COST_PROVENANCE = "unknown";
|
|
|
66
81
|
* campaign?: JsonObject,
|
|
67
82
|
* requirements?: unknown[],
|
|
68
83
|
* excludedRunIds?: string[],
|
|
84
|
+
* planningRunIds?: string[],
|
|
69
85
|
* missingSources?: string[],
|
|
70
86
|
* now?: number,
|
|
71
87
|
* }} MetricsInput
|
|
@@ -80,6 +96,8 @@ const UNKNOWN_COST_PROVENANCE = "unknown";
|
|
|
80
96
|
* usageTokensByKind: GroupedIndicator,
|
|
81
97
|
* usageTokensByKindByRuntime: GroupedIndicator,
|
|
82
98
|
* usageCostUsd: CostIndicator,
|
|
99
|
+
* usageCostUsdByClass: ClassCostIndicator,
|
|
100
|
+
* retryCostUsd: CostIndicator,
|
|
83
101
|
* judgeFindingRate: Indicator,
|
|
84
102
|
* judgeCostShare: JudgeCostShareIndicator,
|
|
85
103
|
* blockingJudgeFirstPassRate: GroupedIndicator,
|
|
@@ -98,16 +116,14 @@ const UNKNOWN_COST_PROVENANCE = "unknown";
|
|
|
98
116
|
* @param {MetricsInput} [input]
|
|
99
117
|
* @returns {CampaignMetrics}
|
|
100
118
|
*/
|
|
101
|
-
export function projectMetrics({ events = [], usageRecords = [], notifications = [], nodes = [], journal = [], campaign = undefined, requirements = undefined, excludedRunIds = [], missingSources = [], now = Date.now() } = {}) {
|
|
119
|
+
export function projectMetrics({ events = [], usageRecords = [], notifications = [], nodes = [], journal = [], campaign = undefined, requirements = undefined, excludedRunIds = [], planningRunIds = [], missingSources = [], now = Date.now() } = {}) {
|
|
102
120
|
void now;
|
|
103
121
|
const eventList = events.map(jsonObjectOf).filter((event) => event !== null);
|
|
104
122
|
const excluded = excludedRunIdsFor(campaign, excludedRunIds);
|
|
105
123
|
const eligibleNodes = nodes.filter((node) => !excluded.includes(node.runId));
|
|
106
124
|
const eligibleEvents = eventList.filter((event) => typeof event.runId !== "string" || !excluded.includes(event.runId));
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
return object === null || typeof object.runId !== "string" || !excluded.includes(object.runId);
|
|
110
|
-
});
|
|
125
|
+
const usageRecordList = usageRecords.map(jsonObjectOf).filter((record) => record !== null);
|
|
126
|
+
const eligibleUsageRecords = usageRecordList.filter((record) => typeof record.runId !== "string" || !excluded.includes(record.runId));
|
|
111
127
|
const eligibleNotifications = notifications.filter((record) => {
|
|
112
128
|
const object = jsonObjectOf(record);
|
|
113
129
|
return object === null || typeof object.runId !== "string" || !excluded.includes(object.runId);
|
|
@@ -116,7 +132,11 @@ export function projectMetrics({ events = [], usageRecords = [], notifications =
|
|
|
116
132
|
const runs = runsPerCampaignOf(nodes, eventList);
|
|
117
133
|
const closedCheckpoints = closedCheckpointsOf(nodes);
|
|
118
134
|
const span = eventSpanOf(eventList);
|
|
119
|
-
|
|
135
|
+
// Spend is campaign-wide, exactly like `usageCostUsd`: a run a replacement
|
|
136
|
+
// took out of the rates still spent what it spent.
|
|
137
|
+
const usage = usageTotalsOf(usageRecordList);
|
|
138
|
+
const usageByClass = costByClassOf(usageRecordList, planningRunIds);
|
|
139
|
+
const retries = costTotalsOf(retryRecordsOf(usageRecordList));
|
|
120
140
|
const judgeYield = judgeYieldOf(eligibleEvents, eligibleUsageRecords);
|
|
121
141
|
const gates = blockingJudgeFirstPassRateOf(eligibleNodes, eligibleEvents);
|
|
122
142
|
const notify = notifyReceiptRateOf(eligibleNotifications);
|
|
@@ -137,6 +157,17 @@ export function projectMetrics({ events = [], usageRecords = [], notifications =
|
|
|
137
157
|
unknownCountByReason: usage.unknownCountByReason,
|
|
138
158
|
unknownFractionByReason: usage.unknownFractionByReason,
|
|
139
159
|
},
|
|
160
|
+
usageCostUsdByClass: {
|
|
161
|
+
...grouped("down", usageByClass.count, usageByClass.value),
|
|
162
|
+
unknownCount: usageByClass.unknownCount,
|
|
163
|
+
unknownCountByClass: usageByClass.unknownCountByClass,
|
|
164
|
+
},
|
|
165
|
+
retryCostUsd: {
|
|
166
|
+
...measured("down", retries.costCount, retries.costUsd),
|
|
167
|
+
unknownCount: retries.unknownCount,
|
|
168
|
+
unknownCountByReason: retries.unknownCountByReason,
|
|
169
|
+
unknownFractionByReason: retries.unknownFractionByReason,
|
|
170
|
+
},
|
|
140
171
|
judgeFindingRate: rate("up", judgeYield.findings, judgeYield.judged, excluded),
|
|
141
172
|
judgeCostShare: {
|
|
142
173
|
...measured("down", usage.costCount, usage.costUsd === null ? null : usage.judgeCostUsd / usage.costUsd),
|
|
@@ -148,7 +179,7 @@ export function projectMetrics({ events = [], usageRecords = [], notifications =
|
|
|
148
179
|
intentToVerifiedSeconds: northStar,
|
|
149
180
|
humanTouches: touches,
|
|
150
181
|
};
|
|
151
|
-
const projected = /** @type {CampaignMetrics & {effectiveJudgeIndependence: string|null}} */ (applyMissingSources(metrics, missingSources));
|
|
182
|
+
const projected = /** @type {CampaignMetrics & {effectiveJudgeIndependence: string|null, reserve: ReserveProjection}} */ (applyMissingSources(metrics, missingSources));
|
|
152
183
|
// R36: the effective judge-independence mode the launch recorded on the run
|
|
153
184
|
// snapshots, read back from the reduced node input rather than re-derived
|
|
154
185
|
// from the contract or the machine config. It is metadata about the sources,
|
|
@@ -158,9 +189,21 @@ export function projectMetrics({ events = [], usageRecords = [], notifications =
|
|
|
158
189
|
value: recordedJudgeIndependenceOf(eligibleNodes),
|
|
159
190
|
enumerable: false,
|
|
160
191
|
});
|
|
192
|
+
// The optional reserve is policy state derived from the same records, not a
|
|
193
|
+
// section-6 indicator: non-enumerable for the same reason, and nulled out
|
|
194
|
+
// when the usage source it reads was not preserved.
|
|
195
|
+
Object.defineProperty(projected, "reserve", {
|
|
196
|
+
value: reserveOf(usageRecordList, campaign, missingUsageSources(projected)),
|
|
197
|
+
enumerable: false,
|
|
198
|
+
});
|
|
161
199
|
return projected;
|
|
162
200
|
}
|
|
163
201
|
|
|
202
|
+
/** @param {CampaignMetrics} metrics @returns {string[]} */
|
|
203
|
+
function missingUsageSources(metrics) {
|
|
204
|
+
return Array.isArray(metrics.usageCostUsd.missingSources) ? metrics.usageCostUsd.missingSources : [];
|
|
205
|
+
}
|
|
206
|
+
|
|
164
207
|
/**
|
|
165
208
|
* The effective judge-independence mode the launch recorded on the run
|
|
166
209
|
* snapshots, reduced to the `judgeIndependence` field of the node input. The
|
|
@@ -202,6 +245,8 @@ function applyMissingSources(metrics, missingSources) {
|
|
|
202
245
|
usageTokensByKind: withMissing(metrics.usageTokensByKind, ["usage.jsonl"]),
|
|
203
246
|
usageTokensByKindByRuntime: withMissing(metrics.usageTokensByKindByRuntime, ["usage.jsonl"]),
|
|
204
247
|
usageCostUsd: withMissing(metrics.usageCostUsd, ["usage.jsonl"]),
|
|
248
|
+
usageCostUsdByClass: withMissing(metrics.usageCostUsdByClass, ["usage.jsonl"]),
|
|
249
|
+
retryCostUsd: withMissing(metrics.retryCostUsd, ["usage.jsonl"]),
|
|
205
250
|
judgeFindingRate: withMissing(metrics.judgeFindingRate, ["events.jsonl", "usage.jsonl"]),
|
|
206
251
|
judgeCostShare: withMissing(metrics.judgeCostShare, ["usage.jsonl"]),
|
|
207
252
|
blockingJudgeFirstPassRate: withMissing(metrics.blockingJudgeFirstPassRate, ["nodes.json", "events.jsonl"]),
|
|
@@ -433,30 +478,24 @@ function eventSpanOf(events) {
|
|
|
433
478
|
* Tokens by kind and total cost across every linked run's usage records, both
|
|
434
479
|
* as a campaign total and broken out per runtime (TECH-SPEC section 6). A
|
|
435
480
|
* record contributes exactly what it recorded: uncached input, cache-read
|
|
436
|
-
* input and output tokens are independent totals.
|
|
437
|
-
* whose provenance is not `unknown`
|
|
438
|
-
* exactly when the provider reported no
|
|
439
|
-
*
|
|
481
|
+
* input and output tokens are independent totals. The cost half is
|
|
482
|
+
* `costTotalsOf`: it sums only records whose provenance is not `unknown`
|
|
483
|
+
* (`appendUsageRecord` sets `unknown` exactly when the provider reported no
|
|
484
|
+
* cost) and counts every other record separately rather than folding it into a
|
|
485
|
+
* measured zero.
|
|
440
486
|
*
|
|
441
|
-
* @param {
|
|
487
|
+
* @param {JsonObject[]} records
|
|
442
488
|
* @returns {{tokensByKind: Record<string, number>, tokensByKindByRuntime: Record<string, number>, tokenCount: number, costUsd: number|null, judgeCostUsd: number, costCount: number, unknownCount: number, unknownJudgeCount: number, unknownCountByReason: Record<string, number>, unknownFractionByReason: Record<string, number>}}
|
|
443
489
|
*/
|
|
444
|
-
function usageTotalsOf(
|
|
490
|
+
function usageTotalsOf(records) {
|
|
445
491
|
const tokensByKind = { inputTokens: 0, cacheReadInputTokens: 0, outputTokens: 0 };
|
|
446
492
|
/** @type {Record<string, number>} */
|
|
447
493
|
const tokensByKindByRuntime = {};
|
|
448
494
|
let tokenCount = 0;
|
|
449
|
-
let costUsd = 0;
|
|
450
495
|
let judgeCostUsd = 0;
|
|
451
|
-
let costCount = 0;
|
|
452
|
-
let unknownCount = 0;
|
|
453
496
|
let unknownJudgeCount = 0;
|
|
454
|
-
/** @type {Record<string, number>} */
|
|
455
|
-
const unknownCountByReason = {};
|
|
456
497
|
const kinds = /** @type {("inputTokens"|"cacheReadInputTokens"|"outputTokens")[]} */ (["inputTokens", "cacheReadInputTokens", "outputTokens"]);
|
|
457
|
-
for (const
|
|
458
|
-
const record = jsonObjectOf(raw);
|
|
459
|
-
if (record === null) continue;
|
|
498
|
+
for (const record of records) {
|
|
460
499
|
let measuredAny = false;
|
|
461
500
|
const runtime = typeof record.runtimeId === "string" && record.runtimeId !== "" ? record.runtimeId : null;
|
|
462
501
|
for (const kind of kinds) {
|
|
@@ -467,34 +506,25 @@ function usageTotalsOf(usageRecords) {
|
|
|
467
506
|
measuredAny = true;
|
|
468
507
|
}
|
|
469
508
|
if (measuredAny) tokenCount += 1;
|
|
470
|
-
if (
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
if (record.role === "judge") unknownJudgeCount += 1;
|
|
477
|
-
const reason = typeof record.unknownReason === "string" && UNKNOWN_COST_REASONS.includes(record.unknownReason)
|
|
478
|
-
? record.unknownReason
|
|
479
|
-
: "legacy";
|
|
480
|
-
unknownCountByReason[reason] = (unknownCountByReason[reason] ?? 0) + 1;
|
|
481
|
-
}
|
|
509
|
+
if (record.role !== "judge") continue;
|
|
510
|
+
// The judge share divides the same priced dollars this class split reports,
|
|
511
|
+
// so a judge retry stays judge cost here and counts as a retry too.
|
|
512
|
+
const judgeCost = pricedCostOf(record);
|
|
513
|
+
if (judgeCost === null) unknownJudgeCount += 1;
|
|
514
|
+
else judgeCostUsd += judgeCost;
|
|
482
515
|
}
|
|
483
|
-
const
|
|
484
|
-
const unknownFractionByReason = Object.fromEntries(
|
|
485
|
-
Object.entries(unknownCountByReason).map(([reason, count]) => [reason, round4(count / totalCount)]),
|
|
486
|
-
);
|
|
516
|
+
const cost = costTotalsOf(records);
|
|
487
517
|
return {
|
|
488
518
|
tokensByKind,
|
|
489
519
|
tokensByKindByRuntime,
|
|
490
520
|
tokenCount,
|
|
491
|
-
costUsd:
|
|
521
|
+
costUsd: cost.costUsd,
|
|
492
522
|
judgeCostUsd,
|
|
493
|
-
costCount,
|
|
494
|
-
unknownCount,
|
|
523
|
+
costCount: cost.costCount,
|
|
524
|
+
unknownCount: cost.unknownCount,
|
|
495
525
|
unknownJudgeCount,
|
|
496
|
-
unknownCountByReason,
|
|
497
|
-
unknownFractionByReason,
|
|
526
|
+
unknownCountByReason: cost.unknownCountByReason,
|
|
527
|
+
unknownFractionByReason: cost.unknownFractionByReason,
|
|
498
528
|
};
|
|
499
529
|
}
|
|
500
530
|
|
package/src/campaign/record.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { errorCode } from "../util.mjs";
|
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { randomUUID } from "node:crypto";
|
|
13
13
|
import { readFileSync } from "node:fs";
|
|
14
|
-
import { assertObject, requireId, requirePacketHash, requireString, requireText, requireTimestamp } from "../contract/assert.mjs";
|
|
14
|
+
import { assertObject, nonNegativeNumber, requireId, requirePacketHash, requireString, requireText, requireTimestamp } from "../contract/assert.mjs";
|
|
15
15
|
|
|
16
16
|
/** @typedef {import("./index.mjs").Campaign} Campaign */
|
|
17
17
|
/** @typedef {import("../notify/index.mjs").JsonObject} JsonObject */
|
|
@@ -122,6 +122,13 @@ export function validateCampaign(campaign) {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
if (record.landBranch !== undefined) requireText(record.landBranch, "campaign.landBranch");
|
|
125
|
+
// The optional balance the reserve gate gates new known-cost dispatches
|
|
126
|
+
// against (ADR 0011). Absent means no reserve policy at all, which must stay
|
|
127
|
+
// distinct from a balance of zero — zero gates every known-cost dispatch —
|
|
128
|
+
// so the field is never defaulted on read, and present it must be a finite
|
|
129
|
+
// non-negative number, because anything else either gates everything or
|
|
130
|
+
// reserves money the campaign does not have.
|
|
131
|
+
if (record.reserveUsd !== undefined) nonNegativeNumber(record.reserveUsd, "campaign.reserveUsd");
|
|
125
132
|
// The ledger preservation marker is optional on read (a record written
|
|
126
133
|
// before it existed reads as not preserved), but once written it must be a
|
|
127
134
|
// boolean so removal can rely on the state it carries.
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The enforcement half of the optional campaign balance (ADR 0011): the one
|
|
3
|
+
* mutator that arms `campaign.reserveUsd` on the record, and the reservation
|
|
4
|
+
* store a dispatch gate holds, releases and reconciles against.
|
|
5
|
+
*
|
|
6
|
+
* The balance is one field on `campaign.json`; the reservations against it
|
|
7
|
+
* are runtime state, so they live in their own file under the campaign
|
|
8
|
+
* directory instead of in a record every mutator rewrites whole — a
|
|
9
|
+
* check-and-deduct cannot be atomic under a last-writer-wins whole-file
|
|
10
|
+
* write, and two runs' controllers dispatching under one campaign do race.
|
|
11
|
+
* Every state mutation therefore runs under a lock that reuses the run
|
|
12
|
+
* controller lock's mechanics (`../run/lock.mjs`), which brings pid-liveness
|
|
13
|
+
* takeover: a holder that crashed mid-reservation cannot wedge the gate shut.
|
|
14
|
+
*
|
|
15
|
+
* Known costs hold their amount before dispatch and settle to released or
|
|
16
|
+
* charged. A cost the provider never priced is recorded with `costUsd: null`
|
|
17
|
+
* and holds nothing — the exposure stays unknown instead of reading as free —
|
|
18
|
+
* and its reconciliation charges the real amount with no late difference,
|
|
19
|
+
* because there was no reservation to be late against. A charge above its
|
|
20
|
+
* reservation settles anyway and records the overshoot: the reserve gates new
|
|
21
|
+
* dispatches, it is never an absolute ceiling.
|
|
22
|
+
*/
|
|
23
|
+
import { randomUUID } from "node:crypto";
|
|
24
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { acquire, LockBusyError } from "../run/lock.mjs";
|
|
27
|
+
import { writeJsonAtomic } from "../run/store.mjs";
|
|
28
|
+
import { nonNegativeNumber } from "../contract/assert.mjs";
|
|
29
|
+
import { sleepSync } from "../util.mjs";
|
|
30
|
+
import { CAMPAIGN_FILE } from "./layout.mjs";
|
|
31
|
+
import { round4 } from "./metrics-evals.mjs";
|
|
32
|
+
import { readCampaign } from "./record.mjs";
|
|
33
|
+
|
|
34
|
+
/** @typedef {import("./index.mjs").Campaign} Campaign */
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The record as the reserve reads it: the shared `Campaign` typedef lives in
|
|
38
|
+
* `index.mjs`, and this module's writes and reads of the balance are typed
|
|
39
|
+
* against the field added here, so a record without the field reads as
|
|
40
|
+
* unconfigured — never as a zero.
|
|
41
|
+
*
|
|
42
|
+
* @typedef {Campaign & {reserveUsd?: number}} CampaignWithReserve
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** The campaign-directory subdirectory holding the reserve's lock and state. */
|
|
46
|
+
export const RESERVE_DIR = "reserve";
|
|
47
|
+
/** The reservation state file inside `RESERVE_DIR`. */
|
|
48
|
+
export const RESERVE_STATE_FILE = "state.json";
|
|
49
|
+
const RESERVE_STATE_SCHEMA_VERSION = 1;
|
|
50
|
+
// No measurement behind these bounds: a critical section is two atomic file
|
|
51
|
+
// writes, so a 5 s wait exceeds it by orders of magnitude on any machine, and
|
|
52
|
+
// the budget is what turns a wedged live holder into a loud throw instead of
|
|
53
|
+
// an unbounded queue.
|
|
54
|
+
const LOCK_WAIT_BUDGET_MS = 5_000;
|
|
55
|
+
const LOCK_RETRY_MS = 5;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* One reservation against the balance. `status` is `held` from admission,
|
|
59
|
+
* then `released` (no charge to reconcile) or `charged` (the real cost
|
|
60
|
+
* arrived). A `costUsd: null` reservation holds nothing and is the unknown
|
|
61
|
+
* exposure ADR 0011 keeps visible until a real charge reconciles it.
|
|
62
|
+
*
|
|
63
|
+
* @typedef {{
|
|
64
|
+
* id: string,
|
|
65
|
+
* at: string,
|
|
66
|
+
* runId?: string,
|
|
67
|
+
* node?: string,
|
|
68
|
+
* costUsd: number|null,
|
|
69
|
+
* status: "held"|"released"|"charged",
|
|
70
|
+
* chargedUsd?: number,
|
|
71
|
+
* lateChargeUsd?: number|null,
|
|
72
|
+
* settledAt?: string,
|
|
73
|
+
* }} Reservation
|
|
74
|
+
*/
|
|
75
|
+
/** @typedef {{schemaVersion: number, reservations: Reservation[]}} ReserveState */
|
|
76
|
+
/**
|
|
77
|
+
* @typedef {{
|
|
78
|
+
* armed: boolean,
|
|
79
|
+
* configuredUsd: number|null,
|
|
80
|
+
* heldUsd: number,
|
|
81
|
+
* chargedUsd: number,
|
|
82
|
+
* availableUsd: number|null,
|
|
83
|
+
* unknownExposureCount: number,
|
|
84
|
+
* reservations: Reservation[],
|
|
85
|
+
* }} ReserveStatus
|
|
86
|
+
*/
|
|
87
|
+
/**
|
|
88
|
+
* @typedef {{
|
|
89
|
+
* admitted: boolean,
|
|
90
|
+
* armed: boolean,
|
|
91
|
+
* reservation: Reservation|null,
|
|
92
|
+
* availableUsd: number|null,
|
|
93
|
+
* }} ReserveDecision
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The one writer of `campaign.reserveUsd` (docs/FIELD-OWNERSHIP.md): arm the
|
|
98
|
+
* optional balance a campaign gates new known-cost dispatches with. The
|
|
99
|
+
* amount is validated by the same schema that reads the record back; a closed
|
|
100
|
+
* campaign refuses, like every other record mutator.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} campaignPath
|
|
103
|
+
* @param {number} reserveUsd
|
|
104
|
+
* @param {{at?: string}} [options]
|
|
105
|
+
* @returns {Campaign}
|
|
106
|
+
*/
|
|
107
|
+
export function configureCampaignReserve(campaignPath, reserveUsd, { at = new Date().toISOString() } = {}) {
|
|
108
|
+
nonNegativeNumber(reserveUsd, "reserveUsd");
|
|
109
|
+
const campaign = readCampaign(campaignPath);
|
|
110
|
+
if (campaign.status === "closed") throw new Error(`campaign is closed: ${campaign.id}`);
|
|
111
|
+
/** @type {CampaignWithReserve} */
|
|
112
|
+
const updated = { ...campaign, reserveUsd, updatedAt: at };
|
|
113
|
+
writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), updated);
|
|
114
|
+
return updated;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Reserve a dispatch's cost against the campaign balance before the dispatch
|
|
119
|
+
* happens. A campaign with no configured balance admits without writing any
|
|
120
|
+
* state — the reserve is only applied where it was armed. A cost with no
|
|
121
|
+
* provider price (`costUsd` null) is admitted without holding anything and
|
|
122
|
+
* recorded as an unknown exposure, never as a zero. Atomic: the
|
|
123
|
+
* check-and-deduct runs under the campaign's reserve lock, so two competing
|
|
124
|
+
* reservations cannot both be admitted when their combined cost passes the
|
|
125
|
+
* available balance.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} campaignPath
|
|
128
|
+
* @param {{runId?: string, node?: string, costUsd?: number|null, at?: string}} request
|
|
129
|
+
* an absent `costUsd` is an unknown cost, the same as an explicit null
|
|
130
|
+
* @returns {ReserveDecision}
|
|
131
|
+
*/
|
|
132
|
+
export function reserveCampaignCost(campaignPath, { runId, node, costUsd, at = new Date().toISOString() } = {}) {
|
|
133
|
+
if (costUsd !== null && costUsd !== undefined) nonNegativeNumber(costUsd, "costUsd");
|
|
134
|
+
return withReserveLock(campaignPath, () => {
|
|
135
|
+
const configured = reserveUsdOf(readCampaign(campaignPath));
|
|
136
|
+
if (configured === undefined) {
|
|
137
|
+
return { admitted: true, armed: false, reservation: null, availableUsd: null };
|
|
138
|
+
}
|
|
139
|
+
const state = readStateFile(campaignPath);
|
|
140
|
+
const available = availableUsdOf(configured, state);
|
|
141
|
+
// Rounded on both sides so float dust cannot refuse a reservation that
|
|
142
|
+
// exactly empties the balance (measured shape of the failure: 100 - 30 -
|
|
143
|
+
// 70 lands 4.5e-15 above zero).
|
|
144
|
+
if (costUsd !== null && costUsd !== undefined && round4(costUsd) > round4(available)) {
|
|
145
|
+
return { admitted: false, armed: true, reservation: null, availableUsd: available };
|
|
146
|
+
}
|
|
147
|
+
/** @type {Reservation} */
|
|
148
|
+
const reservation = { id: randomUUID(), at, costUsd: costUsd ?? null, status: "held" };
|
|
149
|
+
if (runId !== undefined) reservation.runId = runId;
|
|
150
|
+
if (node !== undefined) reservation.node = node;
|
|
151
|
+
state.reservations.push(reservation);
|
|
152
|
+
writeStateFile(campaignPath, state);
|
|
153
|
+
const spent = reservation.costUsd === null ? 0 : reservation.costUsd;
|
|
154
|
+
return { admitted: true, armed: true, reservation, availableUsd: round4(available - spent) };
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Give a reservation's hold back without a charge: the gated call never
|
|
160
|
+
* launched, or ended with no provider cost to reconcile. Idempotent: an
|
|
161
|
+
* already settled reservation is returned unchanged.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} campaignPath
|
|
164
|
+
* @param {string} reservationId
|
|
165
|
+
* @param {{at?: string}} [options]
|
|
166
|
+
* @returns {Reservation}
|
|
167
|
+
*/
|
|
168
|
+
export function releaseCampaignReservation(campaignPath, reservationId, { at = new Date().toISOString() } = {}) {
|
|
169
|
+
return withReserveLock(campaignPath, () => {
|
|
170
|
+
const state = readStateFile(campaignPath);
|
|
171
|
+
const reservation = findReservation(state, reservationId);
|
|
172
|
+
if (reservation.status === "held") {
|
|
173
|
+
reservation.status = "released";
|
|
174
|
+
reservation.settledAt = at;
|
|
175
|
+
writeStateFile(campaignPath, state);
|
|
176
|
+
}
|
|
177
|
+
return reservation;
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Reconcile a reservation against the charge that actually arrived. A charge
|
|
183
|
+
* above the reservation settles anyway and records the difference as the
|
|
184
|
+
* late charge; a reservation of an unmeasured cost charges the real amount
|
|
185
|
+
* with `lateChargeUsd: null`, because there was no reservation to be late
|
|
186
|
+
* against. Idempotent by amount: reconciling the same charge twice returns
|
|
187
|
+
* the settled reservation instead of charging twice, and a different amount
|
|
188
|
+
* for an already charged reservation refuses.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} campaignPath
|
|
191
|
+
* @param {string} reservationId
|
|
192
|
+
* @param {number|null} costUsd a null charge is refused by the same validation
|
|
193
|
+
* that reads the record: reconciling must carry a real number, never another
|
|
194
|
+
* unknown
|
|
195
|
+
* @param {{at?: string}} [options]
|
|
196
|
+
* @returns {Reservation}
|
|
197
|
+
*/
|
|
198
|
+
export function reconcileCampaignReservation(campaignPath, reservationId, costUsd, { at = new Date().toISOString() } = {}) {
|
|
199
|
+
const charge = nonNegativeNumber(costUsd, "costUsd");
|
|
200
|
+
return withReserveLock(campaignPath, () => {
|
|
201
|
+
const state = readStateFile(campaignPath);
|
|
202
|
+
const reservation = findReservation(state, reservationId);
|
|
203
|
+
if (reservation.status === "charged") {
|
|
204
|
+
if (reservation.chargedUsd === charge) return reservation;
|
|
205
|
+
throw new Error(`reservation ${reservationId} already charged ${reservation.chargedUsd} USD, refusing to recharge at ${charge} USD`);
|
|
206
|
+
}
|
|
207
|
+
if (reservation.status === "released") {
|
|
208
|
+
throw new Error(`reservation ${reservationId} was released without a charge; it cannot take one afterwards`);
|
|
209
|
+
}
|
|
210
|
+
reservation.status = "charged";
|
|
211
|
+
reservation.chargedUsd = charge;
|
|
212
|
+
reservation.lateChargeUsd = reservation.costUsd === null ? null : round4(Math.max(0, charge - reservation.costUsd));
|
|
213
|
+
reservation.settledAt = at;
|
|
214
|
+
writeStateFile(campaignPath, state);
|
|
215
|
+
return reservation;
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Read the reserve's current state beside the record that armed it. The
|
|
221
|
+
* numbers are derived from the reservation list on every read, so a state
|
|
222
|
+
* file written by an older admission carries no migration.
|
|
223
|
+
*
|
|
224
|
+
* @param {string} campaignPath
|
|
225
|
+
* @returns {ReserveStatus}
|
|
226
|
+
*/
|
|
227
|
+
export function readCampaignReserve(campaignPath) {
|
|
228
|
+
const configured = reserveUsdOf(readCampaign(campaignPath));
|
|
229
|
+
const state = readStateFile(campaignPath);
|
|
230
|
+
let heldUsd = 0;
|
|
231
|
+
let chargedUsd = 0;
|
|
232
|
+
let unknownExposureCount = 0;
|
|
233
|
+
for (const reservation of state.reservations) {
|
|
234
|
+
if (reservation.status === "held") {
|
|
235
|
+
if (reservation.costUsd === null) unknownExposureCount += 1;
|
|
236
|
+
else heldUsd += reservation.costUsd;
|
|
237
|
+
} else if (reservation.status === "charged") {
|
|
238
|
+
chargedUsd += reservation.chargedUsd ?? 0;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
armed: configured !== undefined,
|
|
243
|
+
configuredUsd: configured ?? null,
|
|
244
|
+
heldUsd: round4(heldUsd),
|
|
245
|
+
chargedUsd: round4(chargedUsd),
|
|
246
|
+
availableUsd: configured === undefined ? null : availableUsdOf(configured, state),
|
|
247
|
+
unknownExposureCount,
|
|
248
|
+
reservations: state.reservations,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The balance off the record. Read through one cast instead of spreading
|
|
254
|
+
* `any` over the module: the schema validates the field, and an unconfigured
|
|
255
|
+
* record must reach readers as `undefined`, not as a zero.
|
|
256
|
+
*
|
|
257
|
+
* @param {Campaign} campaign
|
|
258
|
+
* @returns {number|undefined}
|
|
259
|
+
*/
|
|
260
|
+
function reserveUsdOf(campaign) {
|
|
261
|
+
return /** @type {number|undefined} */ (/** @type {CampaignWithReserve} */ (campaign).reserveUsd);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* @param {number} configured
|
|
266
|
+
* @param {ReserveState} state
|
|
267
|
+
* @returns {number}
|
|
268
|
+
*/
|
|
269
|
+
function availableUsdOf(configured, state) {
|
|
270
|
+
let held = 0;
|
|
271
|
+
let charged = 0;
|
|
272
|
+
for (const reservation of state.reservations) {
|
|
273
|
+
if (reservation.status === "held") {
|
|
274
|
+
if (reservation.costUsd !== null) held += reservation.costUsd;
|
|
275
|
+
} else if (reservation.status === "charged") {
|
|
276
|
+
charged += reservation.chargedUsd ?? 0;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return round4(configured - held - charged);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* @template T
|
|
284
|
+
* @param {string} campaignPath
|
|
285
|
+
* @param {() => T} body
|
|
286
|
+
* @returns {T}
|
|
287
|
+
*/
|
|
288
|
+
function withReserveLock(campaignPath, body) {
|
|
289
|
+
const deadline = Date.now() + LOCK_WAIT_BUDGET_MS;
|
|
290
|
+
for (;;) {
|
|
291
|
+
let lock;
|
|
292
|
+
try {
|
|
293
|
+
lock = acquire(join(campaignPath, RESERVE_DIR));
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (!(error instanceof LockBusyError) || Date.now() >= deadline) {
|
|
296
|
+
throw error instanceof LockBusyError
|
|
297
|
+
? new Error(`campaign reserve lock still held after ${LOCK_WAIT_BUDGET_MS} ms: ${error.message}`)
|
|
298
|
+
: error;
|
|
299
|
+
}
|
|
300
|
+
sleepSync(LOCK_RETRY_MS);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
return body();
|
|
305
|
+
} finally {
|
|
306
|
+
lock.release();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* @param {string} campaignPath
|
|
313
|
+
* @returns {ReserveState}
|
|
314
|
+
*/
|
|
315
|
+
function readStateFile(campaignPath) {
|
|
316
|
+
const path = join(campaignPath, RESERVE_DIR, RESERVE_STATE_FILE);
|
|
317
|
+
if (!existsSync(path)) return { schemaVersion: RESERVE_STATE_SCHEMA_VERSION, reservations: [] };
|
|
318
|
+
const state = /** @type {ReserveState} */ (JSON.parse(readFileSync(path, "utf8")));
|
|
319
|
+
if (!Array.isArray(state.reservations)) throw new TypeError(`reserve state ${path} has no reservations array`);
|
|
320
|
+
return state;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* @param {string} campaignPath
|
|
325
|
+
* @param {ReserveState} state
|
|
326
|
+
*/
|
|
327
|
+
function writeStateFile(campaignPath, state) {
|
|
328
|
+
writeJsonAtomic(join(campaignPath, RESERVE_DIR, RESERVE_STATE_FILE), state);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* @param {ReserveState} state
|
|
333
|
+
* @param {string} reservationId
|
|
334
|
+
* @returns {Reservation}
|
|
335
|
+
*/
|
|
336
|
+
function findReservation(state, reservationId) {
|
|
337
|
+
const reservation = state.reservations.find((entry) => entry.id === reservationId);
|
|
338
|
+
if (reservation === undefined) throw new Error(`no reservation ${reservationId} in the campaign's reserve`);
|
|
339
|
+
return reservation;
|
|
340
|
+
}
|