bareloop 0.34.0 → 0.35.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/CHANGELOG.md +31 -0
- package/bareloop.context.md +241 -13
- package/package.json +1 -1
- package/src/bundlerun.js +13 -4
- package/src/draftspend.js +82 -0
- package/src/exits.js +10 -1
- package/src/interviewrun.js +7 -7
- package/src/job.js +37 -0
- package/src/judged.js +2 -2
- package/src/kinds.js +2 -1
- package/src/ledger.js +101 -5
- package/src/legs.js +171 -0
- package/src/monthly.js +111 -72
- package/src/panel/authorroutes.js +84 -14
- package/src/panel/authorsession.js +408 -113
- package/src/panel/importroutes.js +359 -0
- package/src/panel/importrun.js +203 -0
- package/src/panel/index.html +1071 -184
- package/src/panel/runroutes.js +219 -0
- package/src/panel/server.js +839 -70
- package/src/panel/status.js +51 -0
- package/src/planrun.js +60 -26
- package/src/providerrows.js +17 -0
- package/src/ralph.js +3 -0
- package/src/replay.js +139 -19
- package/src/replayio.js +11 -4
- package/src/reuse.js +20 -3
- package/src/run.js +6 -3
- package/src/runlist.js +75 -7
- package/src/text.js +20 -0
- package/src/u-readout.js +9 -1
- package/src/u-watchdog.mjs +4 -2
- package/src/userrun.js +138 -57
- package/types/draftspend.d.ts +53 -0
- package/types/job.d.ts +25 -0
- package/types/ledger.d.ts +44 -0
- package/types/legs.d.ts +103 -0
- package/types/monthly.d.ts +23 -18
- package/types/panel/authorroutes.d.ts +23 -1
- package/types/panel/authorsession.d.ts +109 -2
- package/types/panel/importroutes.d.ts +129 -0
- package/types/panel/importrun.d.ts +77 -0
- package/types/panel/runroutes.d.ts +32 -0
- package/types/panel/server.d.ts +255 -2
- package/types/panel/status.d.ts +51 -0
- package/types/planrun.d.ts +13 -21
- package/types/providerrows.d.ts +12 -0
- package/types/replay.d.ts +25 -1
- package/types/replayio.d.ts +2 -2
- package/types/reuse.d.ts +5 -0
- package/types/run.d.ts +6 -2
- package/types/runlist.d.ts +52 -2
- package/types/text.d.ts +19 -0
package/src/monthly.js
CHANGED
|
@@ -20,13 +20,18 @@
|
|
|
20
20
|
//
|
|
21
21
|
// Honesty: a run whose spend is not fully known (a died/still-running spine, an unpriced
|
|
22
22
|
// round, a missing spine file) makes the month total an "at least" figure — never a clean
|
|
23
|
-
// number. The refusal text
|
|
23
|
+
// number. The refusal text leads with `Max $X (monthly limit`; when runs hold money it adds
|
|
24
|
+
// `, $Y held by a run in progress` before the closing paren (`monthlyRefusalText`). `atLeast` travels beside it.
|
|
24
25
|
import { existsSync } from 'node:fs';
|
|
25
26
|
import { readConfig, configPath, ConfigError } from './config.js';
|
|
26
|
-
import { readRunList, appendRun, appendRunEvent, isLiveRunner, runIsAlive } from './runlist.js';
|
|
27
|
+
import { readRunList, appendRun, appendLegStart, appendRunEvent, isLiveRunner, runIsAlive, isSettled } from './runlist.js';
|
|
28
|
+
import { legsOf } from './legs.js';
|
|
27
29
|
import { parseJsonl } from './replayio.js';
|
|
28
|
-
import { SPEND_RECORD_TYPES, spendProvenance, floorsFromRecords } from './ledger.js';
|
|
30
|
+
import { SPEND_RECORD_TYPES, spendProvenance, floorsFromRecords, legSpend, runSpend, chainSpend } from './ledger.js';
|
|
29
31
|
import { findRow } from './providerrows.js';
|
|
32
|
+
import { readOrphanDraftSpends } from './draftspend.js';
|
|
33
|
+
// the per-leg / per-run spend readers live with the ledger (one home, no import cycle through the replay reader)
|
|
34
|
+
export { legSpend, runSpend, chainSpend };
|
|
30
35
|
|
|
31
36
|
// The Money tab's per-provider breakdown groups a run by the endpoint it ran on and names
|
|
32
37
|
// the groups it knows the vendor of. This is HISTORY labelling for spend already recorded
|
|
@@ -61,41 +66,35 @@ function rowIdFor(provider, baseUrl) {
|
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
/**
|
|
64
|
-
* One run's
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* @
|
|
69
|
+
* One run's wall time off its spine: job-start -> job-end when the run ended (exact), else the
|
|
70
|
+
* first -> last record floor (the SAME floor a died / still-running run's "at least" wall uses,
|
|
71
|
+
* `floorsFromRecords`). `ms` null = unknown — never 0.
|
|
72
|
+
* @param {any[]} records one spine's records (one leg or several)
|
|
73
|
+
* @returns {{ ms: number|null, complete: boolean }}
|
|
68
74
|
*/
|
|
69
|
-
export function
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
// drafting spend: a RESUMED / door-rerun leg carries the flag again but the first leg already counted it
|
|
83
|
-
const resumed = !!jobStart && 'priorSpentUsd' in jobStart;
|
|
84
|
-
if (jobStart && !resumed && typeof jobStart.draftSpentUsd === 'number' && Number.isFinite(jobStart.draftSpentUsd) && jobStart.draftSpentUsd > 0) {
|
|
85
|
-
usd += jobStart.draftSpentUsd;
|
|
86
|
-
if (jobStart.draftSpendComplete === false) complete = false;
|
|
75
|
+
export function legWall(records) {
|
|
76
|
+
const legs = legsOf(records);
|
|
77
|
+
if (legs.length > 1) {
|
|
78
|
+
// P5-R: the sum of each leg's own working window, the gap between legs excluded (hamr 2026-10-02, 2A)
|
|
79
|
+
let ms = 0;
|
|
80
|
+
let complete = true;
|
|
81
|
+
for (const l of legs) {
|
|
82
|
+
const w = legWallOne(l.records);
|
|
83
|
+
if (w.ms === null) { complete = false; continue; }
|
|
84
|
+
ms += w.ms;
|
|
85
|
+
if (!w.complete) complete = false;
|
|
86
|
+
}
|
|
87
|
+
return { ms: legs.every((l) => legWallOne(l.records).ms === null) ? null : ms, complete };
|
|
87
88
|
}
|
|
88
|
-
return
|
|
89
|
+
return legWallOne(records);
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
/**
|
|
92
|
-
* One
|
|
93
|
-
* first -> last record floor (the SAME floor a died / still-running run's "at least" wall uses,
|
|
94
|
-
* `floorsFromRecords`). `ms` null = unknown — never 0.
|
|
93
|
+
* One LEG's wall (the single-leg rule {@link legWall} sums).
|
|
95
94
|
* @param {any[]} records
|
|
96
95
|
* @returns {{ ms: number|null, complete: boolean }}
|
|
97
96
|
*/
|
|
98
|
-
|
|
97
|
+
function legWallOne(records) {
|
|
99
98
|
const jobStart = records.find((r) => r && r.type === 'job-start') ?? null;
|
|
100
99
|
const jobEnd = records.findLast((r) => r && r.type === 'job-end') ?? null;
|
|
101
100
|
const a = Date.parse(jobStart?.ts);
|
|
@@ -137,6 +136,7 @@ export function legTokens(records) {
|
|
|
137
136
|
* @property {number} otherRounds every other round: a built-in guess, unpriced, or no provenance on record
|
|
138
137
|
* @property {number} reservedUsd what the refusal check counts: `usd`, or the leg's cap when the run is in flight
|
|
139
138
|
* @property {boolean} unreadable the spine was missing/unreadable or the listed date was bad (spend unknown)
|
|
139
|
+
* @property {boolean} [draft] drafting spend of a panel session that never became a run (src/draftspend.js) — not a run: no wall, not counted in `runs`
|
|
140
140
|
*/
|
|
141
141
|
|
|
142
142
|
/**
|
|
@@ -145,7 +145,7 @@ export function legTokens(records) {
|
|
|
145
145
|
* spend and the leg's own cap — the signed `budgetUsd` on the spine's `job-start` less the fold
|
|
146
146
|
* a resumed leg inherited (`priorSpentUsd`), the same figure the run-start seam computes
|
|
147
147
|
* (`legCapUsd`, src/userrun.js). A spine that records no `budgetUsd` reserves nothing extra.
|
|
148
|
-
* @param {any[]} records
|
|
148
|
+
* @param {any[]} records the LATEST leg's records
|
|
149
149
|
* @param {import('./runlist.js').RunRow} row
|
|
150
150
|
* @param {number} nowMs
|
|
151
151
|
* @param {number} usd the leg's spend so far
|
|
@@ -170,20 +170,22 @@ function inFlightCapUsd(records, row, nowMs, usd) {
|
|
|
170
170
|
function rowsAbove(rows, runid) {
|
|
171
171
|
if (runid === undefined) return rows;
|
|
172
172
|
const i = rows.findIndex((r) => r.runid === runid);
|
|
173
|
-
|
|
173
|
+
if (i < 0) return rows;
|
|
174
|
+
// a RESUMED run's earlier legs are already spent money in this month: its own row stays in (its spend
|
|
175
|
+
// counts, its claim does not — `readLegs`' `ownClaim`). A first-leg claim has no earlier legs to count.
|
|
176
|
+
return rows[i].leg !== undefined && rows[i].leg > 1 ? rows.slice(0, i + 1) : rows.slice(0, i);
|
|
174
177
|
}
|
|
175
178
|
|
|
176
179
|
/**
|
|
177
180
|
* Every listed run as one {@link Leg}. The ONE reader the month total, the all-time total
|
|
178
181
|
* and the per-provider figures share.
|
|
179
|
-
* @param {{ home?: string, now?: () => number, aboveRunid?: string, thisMonthOnly?: boolean }} [opts] `now` = the clock for the no-pid in-flight test; `aboveRunid` = read only the runs listed EARLIER in the file than that run (a claim yields to the claims above it, never below); `thisMonthOnly` = do not parse the spine of a run listed in another local month (the refusal check drops it anyway) unless its claim is still held
|
|
182
|
+
* @param {{ home?: string, sessionsRoot?: string, now?: () => number, aboveRunid?: string, thisMonthOnly?: boolean }} [opts] `sessionsRoot` = where the panel's sessions live (default `<home>/panel-sessions`); `now` = the clock for the no-pid in-flight test; `aboveRunid` = read only the runs listed EARLIER in the file than that run (a claim yields to the claims above it, never below); `thisMonthOnly` = do not parse the spine of a run listed in another local month (the refusal check drops it anyway) unless its claim is still held
|
|
180
183
|
* @returns {Leg[]}
|
|
181
184
|
*/
|
|
182
185
|
export function readLegs(opts = {}) {
|
|
183
186
|
const nowMs = (opts.now ?? Date.now)();
|
|
184
187
|
const { rows: allRows, events } = readRunList({ home: opts.home });
|
|
185
188
|
const rows = rowsAbove(allRows, opts.aboveRunid);
|
|
186
|
-
const settled = new Set(events.filter((e) => e.type === 'settled').map((e) => e.runid));
|
|
187
189
|
/** @type {Leg[]} */
|
|
188
190
|
const legs = [];
|
|
189
191
|
for (const row of rows) {
|
|
@@ -193,31 +195,60 @@ export function readLegs(opts = {}) {
|
|
|
193
195
|
// pid is a live bareloop runner, however quiet its spine — killed = process gone, not silence.
|
|
194
196
|
// An older row with no `pid` keeps the spine-mtime rule (`inFlightCapUsd`), never guessed alive.
|
|
195
197
|
const hasPid = Number.isInteger(row.pid);
|
|
196
|
-
|
|
197
|
-
|
|
198
|
+
// a resume's OWN claim never holds against itself: its earlier legs' spend counts, its cap is what is being checked
|
|
199
|
+
const ownClaim = opts.aboveRunid !== undefined && row.runid === opts.aboveRunid;
|
|
200
|
+
const heldCap = !ownClaim && hasPid && !isSettled(events, row) && typeof row.capUsd === 'number' && Number.isFinite(row.capUsd) && runIsAlive(row, nowMs) ? row.capUsd : null;
|
|
201
|
+
// a RESUMED run (`row.leg`) may hold a leg of this month under a start date of another: read its spine
|
|
202
|
+
if (opts.thisMonthOnly && heldCap === null && row.leg === undefined && !Number.isNaN(at.getTime()) && !sameLocalMonth(at, new Date(nowMs))) continue;
|
|
198
203
|
if (Number.isNaN(at.getTime()) || !existsSync(row.spine)) { legs.push({ ...base, reservedUsd: heldCap ?? 0 }); continue; }
|
|
199
204
|
/** @type {any[]} */
|
|
200
205
|
let records;
|
|
201
206
|
try { records = parseJsonl(row.spine).records; } catch { legs.push({ ...base, reservedUsd: heldCap ?? 0 }); continue; }
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
207
|
+
// P5-R: a resumed run is ONE spine of several legs and ONE listed row. The month and the Money tab still
|
|
208
|
+
// count each leg on its own date with its own spend (the figures they always read), so a leg resumed in a
|
|
209
|
+
// later month is that month's, never silently folded back into the month the run began in. The claim (the
|
|
210
|
+
// hold) belongs to the LATEST leg only.
|
|
211
|
+
const found = legsOf(records);
|
|
212
|
+
const spineLegs = found.length > 0 ? found : [{ leg: 1, records, start: null, jobStart: null }];
|
|
213
|
+
const lastLegNo = spineLegs.length;
|
|
214
|
+
const first = records.find((r) => r && r.type === 'job-start') ?? null;
|
|
215
|
+
for (const sl of spineLegs) {
|
|
216
|
+
const lstart = sl.jobStart ?? first;
|
|
217
|
+
const leg = legSpend(sl.records);
|
|
218
|
+
const prov = spendProvenance(sl.records);
|
|
219
|
+
const wall = legWall(sl.records);
|
|
220
|
+
const isLast = sl.leg === lastLegNo;
|
|
221
|
+
const legAt = sl.leg === 1 ? at : new Date(sl.start?.ts ?? sl.start?.at ?? row.at);
|
|
222
|
+
const ended = sl.records.some((r) => r && r.type === 'job-end');
|
|
223
|
+
legs.push({
|
|
224
|
+
at: Number.isNaN(legAt.getTime()) ? at : legAt,
|
|
225
|
+
model: typeof lstart?.model === 'string' ? lstart.model : null,
|
|
226
|
+
wallMs: wall.ms,
|
|
227
|
+
wallComplete: wall.complete,
|
|
228
|
+
provider: typeof lstart?.provider === 'string' ? lstart.provider : null,
|
|
229
|
+
baseUrl: typeof lstart?.baseUrl === 'string' ? lstart.baseUrl : null,
|
|
230
|
+
usd: leg.usd,
|
|
231
|
+
complete: leg.complete,
|
|
232
|
+
tokens: legTokens(sl.records),
|
|
233
|
+
vouchedRounds: prov.vouched.rounds,
|
|
234
|
+
otherRounds: prov.guessed.rounds + prov.unpriced.rounds + prov.unknown.rounds,
|
|
235
|
+
reservedUsd: !isLast || ownClaim ? leg.usd
|
|
236
|
+
: hasPid ? (ended || heldCap === null ? leg.usd : Math.max(leg.usd, heldCap))
|
|
237
|
+
: inFlightCapUsd(sl.records, row, nowMs, leg.usd),
|
|
238
|
+
unreadable: false,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
// a leg that CLAIMED (its `leg-start` is on the list) but has not yet written its marker: its claim still
|
|
242
|
+
// holds, exactly as a first leg's row does before its spine exists
|
|
243
|
+
if (row.leg !== undefined && row.leg > lastLegNo && heldCap !== null) legs.push({ ...base, reservedUsd: heldCap });
|
|
244
|
+
}
|
|
245
|
+
// drafting spend of panel sessions that never became a run (abandoned / refused / panel restarted): money
|
|
246
|
+
// already spent, counted by the SAME readers as a run's leg (src/draftspend.js — a signed run carries its own)
|
|
247
|
+
for (const d of readOrphanDraftSpends({ home: opts.home, sessionsRoot: opts.sessionsRoot })) {
|
|
206
248
|
legs.push({
|
|
207
|
-
at,
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
wallComplete: wall.complete,
|
|
211
|
-
provider: typeof start?.provider === 'string' ? start.provider : null,
|
|
212
|
-
baseUrl: typeof start?.baseUrl === 'string' ? start.baseUrl : null,
|
|
213
|
-
usd: leg.usd,
|
|
214
|
-
complete: leg.complete,
|
|
215
|
-
tokens: legTokens(records),
|
|
216
|
-
vouchedRounds: prov.vouched.rounds,
|
|
217
|
-
otherRounds: prov.guessed.rounds + prov.unpriced.rounds + prov.unknown.rounds,
|
|
218
|
-
reservedUsd: hasPid ? (records.some((r) => r && r.type === 'job-end') || heldCap === null ? leg.usd : Math.max(leg.usd, heldCap))
|
|
219
|
-
: inFlightCapUsd(records, row, nowMs, leg.usd),
|
|
220
|
-
unreadable: false,
|
|
249
|
+
at: new Date(d.startedAt), provider: d.provider, baseUrl: d.baseUrl, model: d.model, wallMs: null, wallComplete: false,
|
|
250
|
+
usd: d.spentUsd, complete: d.spendComplete, tokens: 0, vouchedRounds: 0, otherRounds: 0, reservedUsd: d.spentUsd,
|
|
251
|
+
unreadable: false, draft: true,
|
|
221
252
|
});
|
|
222
253
|
}
|
|
223
254
|
return legs;
|
|
@@ -231,7 +262,7 @@ export function readLegs(opts = {}) {
|
|
|
231
262
|
const sameLocalMonth = (at, now) => at.getFullYear() === now.getFullYear() && at.getMonth() === now.getMonth();
|
|
232
263
|
|
|
233
264
|
/**
|
|
234
|
-
* @param {{ home?: string, now?: () => number, aboveRunid?: string }} [opts] `aboveRunid`: see {@link readLegs}
|
|
265
|
+
* @param {{ home?: string, sessionsRoot?: string, now?: () => number, aboveRunid?: string }} [opts] `aboveRunid`, `sessionsRoot`: see {@link readLegs}
|
|
235
266
|
* @returns {{ usd: number, atLeast: boolean, runs: number, reservedUsd: number, heldUsd: number, heldRuns: number }} `usd` = real spend (the Money tab's figure); `reservedUsd` = the same with in-flight runs at their cap (the refusal check's); `heldUsd` = what in-progress runs set aside beyond what they have spent (`reservedUsd - usd`), over `heldRuns` runs
|
|
236
267
|
*/
|
|
237
268
|
export function monthSpend(opts = {}) {
|
|
@@ -242,11 +273,11 @@ export function monthSpend(opts = {}) {
|
|
|
242
273
|
let runs = 0;
|
|
243
274
|
let heldUsd = 0;
|
|
244
275
|
let heldRuns = 0;
|
|
245
|
-
for (const leg of readLegs({ home: opts.home, now: opts.now, aboveRunid: opts.aboveRunid, thisMonthOnly: true })) {
|
|
276
|
+
for (const leg of readLegs({ home: opts.home, sessionsRoot: opts.sessionsRoot, now: opts.now, aboveRunid: opts.aboveRunid, thisMonthOnly: true })) {
|
|
246
277
|
// an unreadable date could belong to this month — unknown, never dropped
|
|
247
278
|
if (Number.isNaN(leg.at.getTime())) { atLeast = true; continue; }
|
|
248
279
|
if (!sameLocalMonth(leg.at, nowDate)) continue;
|
|
249
|
-
runs += 1;
|
|
280
|
+
if (!leg.draft) runs += 1;
|
|
250
281
|
if (leg.unreadable) { atLeast = true; reservedUsd += leg.reservedUsd; if (leg.reservedUsd > 0) { heldUsd += leg.reservedUsd; heldRuns += 1; } continue; }
|
|
251
282
|
usd += leg.usd;
|
|
252
283
|
reservedUsd += leg.reservedUsd;
|
|
@@ -261,7 +292,7 @@ export function monthSpend(opts = {}) {
|
|
|
261
292
|
* (rows from `rowIdFor`; a run on no known row lands under its own provider name, never
|
|
262
293
|
* pooled into another row). Any leg with unknown spend makes the figure it belongs to
|
|
263
294
|
* an "at least".
|
|
264
|
-
* @param {{ home?: string, now?: () => number, rows?: readonly import('./providerrows.js').KeyRow[] }} [opts] `rows` = the Providers rows: when given, `tokensByRow` sums each row's tokens
|
|
295
|
+
* @param {{ home?: string, sessionsRoot?: string, now?: () => number, rows?: readonly import('./providerrows.js').KeyRow[] }} [opts] `rows` = the Providers rows: when given, `tokensByRow` sums each row's tokens
|
|
265
296
|
* @returns {{ tokensByRow: Record<string, number>, total: {usd: number, atLeast: boolean, tokens: number}, month: {usd: number, atLeast: boolean, tokens: number},
|
|
266
297
|
* byProvider: Record<string, {label: string, monthUsd: number, monthAtLeast: boolean, totalUsd: number, totalAtLeast: boolean, tokens: number, vouchedRounds: number, otherRounds: number, monthWallMs: number|null, monthWallAtLeast: boolean, totalWallMs: number|null, totalWallAtLeast: boolean}> }}
|
|
267
298
|
*/
|
|
@@ -276,7 +307,7 @@ export function spendSummary(opts = {}) {
|
|
|
276
307
|
const wallSeen = {};
|
|
277
308
|
/** total tokens of the runs each Providers row served (matched by shape + endpoint + model) @type {Record<string, number>} */
|
|
278
309
|
const tokensByRow = {};
|
|
279
|
-
for (const leg of readLegs({ home: opts.home })) {
|
|
310
|
+
for (const leg of readLegs({ home: opts.home, sessionsRoot: opts.sessionsRoot })) {
|
|
280
311
|
let served = opts.rows ? findRow(opts.rows, { provider: leg.provider, baseUrl: leg.baseUrl, model: leg.model }) : null;
|
|
281
312
|
// an older spine records a model but no provider: it counts toward the row whose Name EXACTLY
|
|
282
313
|
// equals that model. Two rows sharing the Name make it ambiguous, so it counts toward neither.
|
|
@@ -295,9 +326,11 @@ export function spendSummary(opts = {}) {
|
|
|
295
326
|
: (notRecorded ? (leg.model ? `not recorded (model ${leg.model})` : 'not recorded') : key.slice('other:'.length));
|
|
296
327
|
const p = (byProvider[key] ??= { label, monthUsd: 0, monthAtLeast: false, totalUsd: 0, totalAtLeast: false, tokens: 0, vouchedRounds: 0, otherRounds: 0, monthWallMs: 0, monthWallAtLeast: false, totalWallMs: 0, totalWallAtLeast: false });
|
|
297
328
|
const w = (wallSeen[key] ??= { monthRuns: 0, monthKnown: 0, totalKnown: 0 });
|
|
329
|
+
// drafting spend has no wall (it is not a run): it never turns a provider's wall into an "at least" or an unknown
|
|
330
|
+
const noWall = leg.draft === true;
|
|
298
331
|
const unknown = leg.unreadable || !leg.complete;
|
|
299
332
|
const wallKnown = !leg.unreadable && leg.wallMs !== null;
|
|
300
|
-
const wallAtLeast = !wallKnown || !leg.wallComplete;
|
|
333
|
+
const wallAtLeast = !noWall && (!wallKnown || !leg.wallComplete);
|
|
301
334
|
total.usd += leg.usd; total.tokens += leg.tokens; if (unknown) total.atLeast = true;
|
|
302
335
|
p.totalUsd += leg.usd; p.tokens += leg.tokens; p.vouchedRounds += leg.vouchedRounds; p.otherRounds += leg.otherRounds; if (unknown) p.totalAtLeast = true;
|
|
303
336
|
if (wallKnown) { p.totalWallMs = /** @type {number} */ (p.totalWallMs) + /** @type {number} */ (leg.wallMs); w.totalKnown += 1; }
|
|
@@ -305,7 +338,7 @@ export function spendSummary(opts = {}) {
|
|
|
305
338
|
if (inMonth) {
|
|
306
339
|
month.usd += leg.usd; month.tokens += leg.tokens; if (unknown) month.atLeast = true;
|
|
307
340
|
p.monthUsd += leg.usd; if (unknown) p.monthAtLeast = true;
|
|
308
|
-
w.monthRuns += 1;
|
|
341
|
+
if (!noWall) w.monthRuns += 1;
|
|
309
342
|
if (wallKnown) { p.monthWallMs = /** @type {number} */ (p.monthWallMs) + /** @type {number} */ (leg.wallMs); w.monthKnown += 1; }
|
|
310
343
|
if (wallAtLeast) p.monthWallAtLeast = true;
|
|
311
344
|
}
|
|
@@ -350,17 +383,17 @@ export function monthlyLimitOf(config, home) {
|
|
|
350
383
|
* number above 0 throws `ConfigError`.
|
|
351
384
|
* Compared in whole cents. Also throws `ConfigError` when config.json is unreadable — a gate
|
|
352
385
|
* whose own instrument is broken refuses; it never silently runs with no limit.
|
|
353
|
-
* @param {{ capUsd: number, home?: string, now?: () => number, aboveRunid?: string }} args `aboveRunid`: count only the claims listed above that run (a claim in progress)
|
|
386
|
+
* @param {{ capUsd: number, home?: string, sessionsRoot?: string, now?: () => number, aboveRunid?: string }} args `aboveRunid`: count only the claims listed above that run (a claim in progress)
|
|
354
387
|
* @returns {MonthlyRoom}
|
|
355
388
|
*/
|
|
356
|
-
export function checkMonthlyRoom({ capUsd, home, now, aboveRunid }) {
|
|
389
|
+
export function checkMonthlyRoom({ capUsd, home, now, aboveRunid, sessionsRoot }) {
|
|
357
390
|
const cfg = readConfig({ home });
|
|
358
391
|
if (cfg.problem) throw new ConfigError(cfg.problem);
|
|
359
392
|
const limit = monthlyLimitOf(cfg.config, home);
|
|
360
393
|
if (limit === null) {
|
|
361
394
|
return { ok: true, leftUsd: null, limitUsd: null, atLeast: false };
|
|
362
395
|
}
|
|
363
|
-
const spent = monthSpend({ home, now, aboveRunid });
|
|
396
|
+
const spent = monthSpend({ home, sessionsRoot, now, aboveRunid });
|
|
364
397
|
const leftCents = Math.max(0, Math.floor((limit - spent.reservedUsd) * 100 + 1e-6));
|
|
365
398
|
const capCents = Math.ceil(capUsd * 100 - 1e-6); // a non-finite cap never fits (NaN/Infinity compare false) — never read as $0
|
|
366
399
|
return { ok: capCents <= leftCents, leftUsd: leftCents / 100, limitUsd: limit, atLeast: spent.atLeast, heldUsd: spent.heldUsd, heldRuns: spent.heldRuns };
|
|
@@ -389,13 +422,14 @@ export function monthlyRefusalText(room) {
|
|
|
389
422
|
*/
|
|
390
423
|
export function settleDeadClaims({ home, by, aboveRunid }) {
|
|
391
424
|
const { rows, events } = readRunList({ home });
|
|
392
|
-
const settled = new Set(events.filter((e) => e.type === 'settled').map((e) => e.runid));
|
|
393
425
|
let n = 0;
|
|
394
426
|
for (const row of rowsAbove(rows, aboveRunid)) {
|
|
395
|
-
|
|
427
|
+
// the run's own claim is never closed by itself (a resume's own row stays in `rowsAbove` for its SPEND)
|
|
428
|
+
if (aboveRunid !== undefined && row.runid === aboveRunid) continue;
|
|
429
|
+
if (!Number.isInteger(row.pid) || isSettled(events, row) || isLiveRunner(/** @type {number} */ (row.pid))) continue;
|
|
396
430
|
let spentUsd = 0;
|
|
397
|
-
try { spentUsd = legSpend(parseJsonl(row.spine).records).usd; } catch { /* no spine: nothing was recorded spent */ }
|
|
398
|
-
appendRunEvent({ runid: row.runid, type: 'settled', by, reason: 'process gone', spentUsd, spendComplete: false, at: new Date().toISOString() }, { home });
|
|
431
|
+
try { spentUsd = legSpend(legsOf(parseJsonl(row.spine).records).at(-1)?.records ?? []).usd; } catch { /* no spine: nothing was recorded spent */ }
|
|
432
|
+
appendRunEvent({ runid: row.runid, type: 'settled', by, reason: 'process gone', spentUsd, spendComplete: false, at: new Date().toISOString(), ...(row.leg === undefined ? {} : { leg: row.leg }) }, { home });
|
|
399
433
|
n += 1;
|
|
400
434
|
}
|
|
401
435
|
return n;
|
|
@@ -409,20 +443,25 @@ export function settleDeadClaims({ home, by, aboveRunid }) {
|
|
|
409
443
|
* it appends `{type:'released', reason:'refused'}` and reports the refusal, nothing spent. No limit set =
|
|
410
444
|
* no claim (`claimed: false`; the caller lists the run as it always did). An unreadable config, or a row
|
|
411
445
|
* that cannot be written, throws `ConfigError` — the gate refuses, never reads "no limit".
|
|
412
|
-
*
|
|
446
|
+
* A RESUMED leg (`leg` 2+, P5-R) claims the same way but appends a `leg-start` beside the run's one row
|
|
447
|
+
* (the row itself is added only when the list has none), and a refusal gives back THAT leg's claim.
|
|
448
|
+
* @param {{ row: import('./runlist.js').RunRow, capUsd: number, home?: string, now?: () => number, leg?: number }} args
|
|
413
449
|
* @returns {{ room: MonthlyRoom, claimed: boolean }}
|
|
414
450
|
*/
|
|
415
|
-
export function claimRun({ row, capUsd, home, now }) {
|
|
451
|
+
export function claimRun({ row, capUsd, home, now, leg = 1 }) {
|
|
416
452
|
const cfg = readConfig({ home });
|
|
417
453
|
if (cfg.problem) throw new ConfigError(cfg.problem);
|
|
418
454
|
if (monthlyLimitOf(cfg.config, home) === null) return { room: { ok: true, leftUsd: null, limitUsd: null, atLeast: false }, claimed: false };
|
|
419
|
-
try {
|
|
455
|
+
try {
|
|
456
|
+
appendRun(row, { home });
|
|
457
|
+
if (leg > 1) appendLegStart({ type: 'leg-start', runid: row.runid, leg, pid: row.pid, capUsd: row.capUsd, at: new Date().toISOString() }, { home });
|
|
458
|
+
} catch (/** @type {any} */ e) {
|
|
420
459
|
throw new ConfigError(`cannot write this run's claim to the run list (${e.message})`);
|
|
421
460
|
}
|
|
422
461
|
settleDeadClaims({ home, by: row.runid, aboveRunid: row.runid });
|
|
423
462
|
const room = checkMonthlyRoom({ capUsd, home, now, aboveRunid: row.runid });
|
|
424
463
|
if (!room.ok) {
|
|
425
|
-
try { appendRunEvent({ runid: row.runid, type: 'released', by: row.runid, reason: 'refused', at: new Date().toISOString() }, { home }); } catch { /* the refusal stands; a stranded row of a dead process is closed by the next run */ }
|
|
464
|
+
try { appendRunEvent({ runid: row.runid, type: 'released', by: row.runid, reason: 'refused', at: new Date().toISOString(), ...(leg > 1 ? { leg } : {}) }, { home }); } catch { /* the refusal stands; a stranded row of a dead process is closed by the next run */ }
|
|
426
465
|
return { room, claimed: false };
|
|
427
466
|
}
|
|
428
467
|
return { room, claimed: true };
|
|
@@ -22,7 +22,8 @@ import { spawn as realSpawn } from 'node:child_process';
|
|
|
22
22
|
import { openSync } from 'node:fs';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
import { readFileSync } from 'node:fs';
|
|
25
|
-
import { createSession, validateJobCard } from './authorsession.js';
|
|
25
|
+
import { createSession, validateJobCard, validateReuseCard, lockedFieldChanged, buildReuseSpec, cardFields, STEP_LABELS } from './authorsession.js';
|
|
26
|
+
import { validateJob } from '../job.js';
|
|
26
27
|
import { checkMonthlyRoom, monthlyRefusalText } from '../monthly.js';
|
|
27
28
|
import { ConfigError } from '../config.js';
|
|
28
29
|
import { keysForDoor, keysHome } from '../keysfile.js';
|
|
@@ -34,6 +35,12 @@ export function mintToken() {
|
|
|
34
35
|
return randomBytes(24).toString('hex');
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The ONE spelling of this panel's own bind address (`host:port`), shared by the Host and Origin checks.
|
|
40
|
+
* @param {number} port
|
|
41
|
+
*/
|
|
42
|
+
const bindAddress = (port) => `127.0.0.1:${port}`;
|
|
43
|
+
|
|
37
44
|
/**
|
|
38
45
|
* The ONE Host check: the request names this panel's own bind address. A page on another
|
|
39
46
|
* origin reaching the panel through a rebound DNS name sends that name as Host, so it fails here.
|
|
@@ -42,7 +49,7 @@ export function mintToken() {
|
|
|
42
49
|
* @returns {{ok: true}|{ok: false, reason: string}}
|
|
43
50
|
*/
|
|
44
51
|
export function checkHostGuard(req, { port }) {
|
|
45
|
-
return req.headers.host ===
|
|
52
|
+
return req.headers.host === bindAddress(port) ? { ok: true } : { ok: false, reason: 'wrong Host' };
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
/**
|
|
@@ -57,7 +64,7 @@ export function checkHostGuard(req, { port }) {
|
|
|
57
64
|
export function checkHumanGuard(req, { token, port }) {
|
|
58
65
|
const got = req.headers['x-bareloop-token'];
|
|
59
66
|
if (got !== token) return { ok: false, reason: 'missing or wrong token' };
|
|
60
|
-
const want =
|
|
67
|
+
const want = bindAddress(port);
|
|
61
68
|
const origin = req.headers.origin;
|
|
62
69
|
if (typeof origin === 'string' && origin.length > 0) {
|
|
63
70
|
if (origin !== `http://${want}` && origin !== `https://${want}`) return { ok: false, reason: 'wrong Origin' };
|
|
@@ -83,7 +90,8 @@ const TERMINAL_PHASES = new Set(['refused', 'abandoned', 'error', 'signed', 'sig
|
|
|
83
90
|
* deliberately has none of).
|
|
84
91
|
* @param {{ port: number, token: string, env?: Record<string,string|undefined>,
|
|
85
92
|
* sessionsRoot?: string, spawnFn?: typeof realSpawn, bareloopBin?: string,
|
|
86
|
-
* jobsDir?: string, fetchImpl?: typeof fetch, home?: string
|
|
93
|
+
* jobsDir?: string, fetchImpl?: typeof fetch, home?: string,
|
|
94
|
+
* startFrom?: {get: (runid: string, o?: {model?: string}) => any, getImport: (id: string, o?: {model?: string}) => any} }} opts
|
|
87
95
|
*/
|
|
88
96
|
export function createAuthorRoutes(opts) {
|
|
89
97
|
// the RAW env with the keys file re-merged on every use, so an edited file takes effect
|
|
@@ -180,7 +188,7 @@ export function createAuthorRoutes(opts) {
|
|
|
180
188
|
const cap = Number(q.get('cap'));
|
|
181
189
|
if (!Number.isFinite(cap) || cap <= 0) { send(200, { ok: true, refusal: null }); return true; }
|
|
182
190
|
try {
|
|
183
|
-
const room = checkMonthlyRoom({ capUsd: cap, home: opts.home });
|
|
191
|
+
const room = checkMonthlyRoom({ capUsd: cap, home: opts.home, sessionsRoot: opts.sessionsRoot });
|
|
184
192
|
send(200, { ok: true, refusal: monthlyRefusalText(room), leftUsd: room.leftUsd, atLeast: room.atLeast });
|
|
185
193
|
} catch (e) {
|
|
186
194
|
if (!(e instanceof ConfigError)) throw e;
|
|
@@ -189,19 +197,74 @@ export function createAuthorRoutes(opts) {
|
|
|
189
197
|
return true;
|
|
190
198
|
}
|
|
191
199
|
|
|
200
|
+
// ── REUSE WORKFLOW (replaces P5 item 3's Start from this). `GET /api/author/start-from?runid=` (or `?import=`) —
|
|
201
|
+
// the prefill from the SIGNED job, which boxes are locked, the track record. $0, read-only. The route keeps its name.
|
|
202
|
+
if (pathname === '/api/author/start-from') {
|
|
203
|
+
if (!opts.startFrom) { send(404, { ok: false, error: 'not found' }); return true; }
|
|
204
|
+
if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
|
|
205
|
+
const sp = new URL(/** @type {string} */ (req.url), 'http://127.0.0.1').searchParams;
|
|
206
|
+
const runid = sp.get('runid') ?? '';
|
|
207
|
+
// `model` = the card's Model box right now: the estimate line counts only runs on that worker
|
|
208
|
+
const asModel = sp.has('model') ? { model: sp.get('model') ?? '' } : {};
|
|
209
|
+
const pre = sp.has('import') ? opts.startFrom.getImport(sp.get('import') ?? '', asModel) : opts.startFrom.get(runid, asModel);
|
|
210
|
+
if (pre === null) { send(404, { ok: false, error: 'no such run' }); return true; }
|
|
211
|
+
if (!pre.ok) { send(409, pre); return true; }
|
|
212
|
+
send(200, {
|
|
213
|
+
ok: true, origin: pre.origin, card: pre.card, from: pre.from, note: pre.note,
|
|
214
|
+
locked: pre.locked, open: pre.open, specHash: pre.specHash, workflowKey: pre.workflowKey, trackRecord: pre.trackRecord,
|
|
215
|
+
line: pre.line,
|
|
216
|
+
});
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// A refreshed page re-attaches to the one live (non-terminal) session: its id and state (card, messages, steps),
|
|
221
|
+
// or `null`. The server's session map is the only truth; nothing is stored page-side.
|
|
222
|
+
if (pathname === '/api/author/live') {
|
|
223
|
+
if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
|
|
224
|
+
const live = [...sessions.values()].find((s) => !TERMINAL_PHASES.has(s.state.phase));
|
|
225
|
+
send(200, { ok: true, sessionId: live ? live.state.id : null, state: live ? live.state : null });
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
192
229
|
if (pathname === '/api/author/start') {
|
|
193
230
|
if (req.method !== 'POST') { send(405, { ok: false, error: 'POST only' }); return true; }
|
|
194
231
|
if (hasLiveSession()) { send(409, { ok: false, error: 'an authoring session is already live — one at a time' }); return true; }
|
|
195
232
|
const card = body ?? {};
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
233
|
+
const rows = rowsForHome(keysHome(opts.home));
|
|
234
|
+
// Reuse workflow — started from a green run or an imported job: only Source, Destination and the two caps may
|
|
235
|
+
// differ from the signed workflow. The SERVER refuses any other change (the page greys those boxes, but is
|
|
236
|
+
// never trusted); the spec it signs is a copy of the origin's with just those four set. Nothing is drafted.
|
|
237
|
+
/** @type {{spec: any, workflowKey: string}|null} */
|
|
238
|
+
let reuse = null;
|
|
239
|
+
const from = card.startFrom;
|
|
240
|
+
const fromRunid = typeof from === 'string' ? from : (typeof from?.runid === 'string' ? from.runid : null);
|
|
241
|
+
const fromImport = typeof from?.importId === 'string' ? from.importId : null;
|
|
242
|
+
if (fromRunid !== null || fromImport !== null) {
|
|
243
|
+
const pre = opts.startFrom ? (fromImport !== null ? opts.startFrom.getImport(fromImport) : opts.startFrom.get(fromRunid)) : null;
|
|
244
|
+
if (pre === null || pre === undefined) { send(404, { ok: false, error: 'no such run to reuse' }); return true; }
|
|
245
|
+
if (!pre.ok) { send(409, pre); return true; }
|
|
246
|
+
const locked = lockedFieldChanged(card, pre.card);
|
|
247
|
+
if (locked !== null) { send(400, { ok: false, error: `${locked} is locked on a reused workflow — Clear the card to change it` }); return true; }
|
|
248
|
+
const rv = validateReuseCard(card, { rows });
|
|
249
|
+
if (!rv.ok) { send(400, { ok: false, error: rv.error }); return true; }
|
|
250
|
+
const spec = buildReuseSpec(pre.spec, card, rows);
|
|
251
|
+
const jv = validateJob(spec, { shellCapUsd: spec.budgetUsd });
|
|
252
|
+
if (!jv.ok) { send(400, { ok: false, error: `the spec this reuse would sign is not valid (${jv.reds.map((r) => `${r.code} at ${r.path}`).join('; ')})` }); return true; }
|
|
253
|
+
reuse = { spec, workflowKey: pre.workflowKey };
|
|
254
|
+
}
|
|
255
|
+
if (reuse === null) {
|
|
256
|
+
const v = validateJobCard(card, { jobsDir: opts.jobsDir, rows });
|
|
257
|
+
if (!v.ok) { send(400, { ok: false, error: v.error }); return true; }
|
|
258
|
+
}
|
|
259
|
+
const session = createSession(cardFields(card), {
|
|
260
|
+
env: envNow(), sessionsRoot: opts.sessionsRoot, ...(opts.home !== undefined ? { home: opts.home } : {}), ...(reuse ? { reuse } : {}),
|
|
261
|
+
});
|
|
199
262
|
sessions.set(session.id, session);
|
|
200
|
-
send(200, { ok: true, sessionId: session.id, state: session.state });
|
|
263
|
+
send(200, { ok: true, sessionId: session.id, reuse: reuse !== null, state: session.state });
|
|
201
264
|
return true;
|
|
202
265
|
}
|
|
203
266
|
|
|
204
|
-
const m = /^\/api\/author\/([A-Za-z0-9]+)(\/(send|revise|sign-prepare|sign|check-deps))?$/.exec(pathname);
|
|
267
|
+
const m = /^\/api\/author\/([A-Za-z0-9]+)(\/(send|revise|sign-prepare|sign|check-deps|abandon))?$/.exec(pathname);
|
|
205
268
|
if (!m) { send(404, { ok: false, error: 'not found' }); return true; }
|
|
206
269
|
const session = sessions.get(m[1]);
|
|
207
270
|
if (!session) { send(404, { ok: false, error: 'no such session' }); return true; }
|
|
@@ -223,6 +286,11 @@ export function createAuthorRoutes(opts) {
|
|
|
223
286
|
session.revise(String(body?.text ?? '')).then((r) => send(r.ok ? 200 : 400, { ...r, state: session.state }));
|
|
224
287
|
return true;
|
|
225
288
|
}
|
|
289
|
+
if (sub === 'abandon') {
|
|
290
|
+
const r = session.abandon();
|
|
291
|
+
send(r.ok ? 200 : 400, { ...r, state: session.state });
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
226
294
|
if (sub === 'check-deps') {
|
|
227
295
|
const r = session.checkDeps();
|
|
228
296
|
send(r.ok ? 200 : 400, { ...r, state: session.state });
|
|
@@ -258,7 +326,7 @@ export function createAuthorRoutes(opts) {
|
|
|
258
326
|
* @param {number} n a non-negative dollar amount
|
|
259
327
|
* @returns {string}
|
|
260
328
|
*/
|
|
261
|
-
function panelMoney2(n) {
|
|
329
|
+
export function panelMoney2(n) {
|
|
262
330
|
if (n > 0 && n < 0.01) return '<$0.01';
|
|
263
331
|
// two-step rounding (matching index.html's own panelMoney): clean to
|
|
264
332
|
// 6-decimal precision first (the same precision src/text.js's tallyCalls
|
|
@@ -290,7 +358,7 @@ export function signRun(session, claimedHash, o) {
|
|
|
290
358
|
// lower nothing (the cap is signed in) but may raise the limit in Settings and sign again.
|
|
291
359
|
try {
|
|
292
360
|
const spec = JSON.parse(readFileSync(session.state.resolvedSpecPath, 'utf8'));
|
|
293
|
-
const refusal = monthlyRefusalText(checkMonthlyRoom({ capUsd: Number(spec.budgetUsd), home: o.home }));
|
|
361
|
+
const refusal = monthlyRefusalText(checkMonthlyRoom({ capUsd: Number(spec.budgetUsd), home: o.home, sessionsRoot: o.sessionsRoot }));
|
|
294
362
|
if (refusal !== null) return { ok: false, error: refusal };
|
|
295
363
|
} catch (e) {
|
|
296
364
|
if (e instanceof ConfigError) return { ok: false, error: `${e.message} — refusing rather than guess the monthly limit` };
|
|
@@ -330,9 +398,11 @@ export function signRun(session, claimedHash, o) {
|
|
|
330
398
|
});
|
|
331
399
|
if (typeof child?.unref === 'function') child.unref();
|
|
332
400
|
session.state.phase = 'signed';
|
|
401
|
+
// hamr 2026-10-05: the sign shows as the last step of the one progress list, never as a thread bubble
|
|
402
|
+
// hamr 2026-10-05 (option A): the drafting-spend line is that step's detail — nothing when there was no drafting spend (a Reuse)
|
|
333
403
|
const draftLine = draftSpentUsd !== null
|
|
334
|
-
? `
|
|
404
|
+
? `drafting spent ${draftIncomplete ? 'at least ' : ''}${panelMoney2(draftSpentUsd)} (folds out of the run's own cap)`
|
|
335
405
|
: '';
|
|
336
|
-
session.state.
|
|
406
|
+
if (Array.isArray(session.state.steps)) session.state.steps.push({ id: 'signed', label: STEP_LABELS.signed, status: 'done', detail: draftLine });
|
|
337
407
|
return { ok: true, job: session.state.resolvedSpecPath };
|
|
338
408
|
}
|