bareloop 0.33.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 +47 -0
- package/bareloop.context.md +263 -27
- 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 +10 -9
- package/src/job.js +39 -2
- package/src/judged.js +25 -5
- package/src/kinds.js +2 -0
- package/src/ledger.js +104 -9
- package/src/legs.js +171 -0
- package/src/monthly.js +133 -87
- package/src/panel/authorroutes.js +95 -15
- 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 +855 -81
- package/src/panel/status.js +51 -0
- package/src/planrun.js +63 -31
- package/src/providerrows.js +21 -2
- package/src/providers.js +4 -4
- 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 +90 -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 +166 -65
- package/types/draftspend.d.ts +53 -0
- package/types/job.d.ts +25 -0
- package/types/judged.d.ts +5 -2
- package/types/ledger.d.ts +44 -0
- package/types/legs.d.ts +103 -0
- package/types/monthly.d.ts +39 -22
- package/types/panel/authorroutes.d.ts +38 -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 +14 -1
- package/types/providers.d.ts +1 -2
- 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 +66 -2
- package/types/text.d.ts +19 -0
package/src/monthly.js
CHANGED
|
@@ -14,19 +14,24 @@
|
|
|
14
14
|
// run is counted at max(spend so far, capUsd) however quiet its spine; a run with a `job-end` counts
|
|
15
15
|
// its real spend; a claim whose process is gone counts its floor and is settled — with an attributed
|
|
16
16
|
// `settled` entry — by whichever later run finds it (the first note wins). An older row with no `pid` keeps the spine
|
|
17
|
-
// mtime rule (a fresh spine, written to within DIED_MTIME_MS, is in flight)
|
|
17
|
+
// mtime rule (a fresh spine, written to within DIED_MTIME_MS, is in flight) — `runIsAlive`, src/runlist.js, the
|
|
18
|
+
// one "is this run alive" rule the panel's glyph shares. Only the refusal check
|
|
18
19
|
// reserves; the Money tab keeps showing real spend (`usd`).
|
|
19
20
|
//
|
|
20
21
|
// Honesty: a run whose spend is not fully known (a died/still-running spine, an unpriced
|
|
21
22
|
// round, a missing spine file) makes the month total an "at least" figure — never a clean
|
|
22
|
-
// number. The refusal text
|
|
23
|
-
|
|
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.
|
|
25
|
+
import { existsSync } from 'node:fs';
|
|
24
26
|
import { readConfig, configPath, ConfigError } from './config.js';
|
|
25
|
-
import { readRunList, appendRun, appendRunEvent, isLiveRunner,
|
|
26
|
-
|
|
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,27 +136,26 @@ 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
|
/**
|
|
143
143
|
* What the refusal check counts for one run: its spend, unless it is IN FLIGHT (`job-start`, no
|
|
144
|
-
* `job-end`,
|
|
144
|
+
* `job-end`, `runIsAlive` for a row with no pid — the pid-bearing rows are held by `readLegs`), when it is the larger of that
|
|
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
|
|
149
|
-
* @param {
|
|
148
|
+
* @param {any[]} records the LATEST leg's records
|
|
149
|
+
* @param {import('./runlist.js').RunRow} row
|
|
150
150
|
* @param {number} nowMs
|
|
151
151
|
* @param {number} usd the leg's spend so far
|
|
152
152
|
* @returns {number}
|
|
153
153
|
*/
|
|
154
|
-
function inFlightCapUsd(records,
|
|
154
|
+
function inFlightCapUsd(records, row, nowMs, usd) {
|
|
155
155
|
if (records.some((r) => r && r.type === 'job-end')) return usd;
|
|
156
156
|
const start = records.find((r) => r && r.type === 'job-start') ?? null;
|
|
157
157
|
if (!start || !(typeof start.budgetUsd === 'number' && Number.isFinite(start.budgetUsd))) return usd;
|
|
158
|
-
|
|
159
|
-
try { mtimeMs = statSync(spinePath).mtimeMs; } catch { return usd; }
|
|
160
|
-
if (nowMs - mtimeMs > DIED_MTIME_MS) return usd; // stale — died, counts its floor
|
|
158
|
+
if (!runIsAlive(row, nowMs)) return usd; // stale — died, counts its floor
|
|
161
159
|
const fold = typeof start.priorSpentUsd === 'number' && Number.isFinite(start.priorSpentUsd) ? start.priorSpentUsd : 0;
|
|
162
160
|
return Math.max(usd, Math.max(0, start.budgetUsd - fold));
|
|
163
161
|
}
|
|
@@ -172,20 +170,22 @@ function inFlightCapUsd(records, spinePath, nowMs, usd) {
|
|
|
172
170
|
function rowsAbove(rows, runid) {
|
|
173
171
|
if (runid === undefined) return rows;
|
|
174
172
|
const i = rows.findIndex((r) => r.runid === runid);
|
|
175
|
-
|
|
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);
|
|
176
177
|
}
|
|
177
178
|
|
|
178
179
|
/**
|
|
179
180
|
* Every listed run as one {@link Leg}. The ONE reader the month total, the all-time total
|
|
180
181
|
* and the per-provider figures share.
|
|
181
|
-
* @param {{ home?: string, now?: () => number, aboveRunid?: string }} [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)
|
|
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
|
|
182
183
|
* @returns {Leg[]}
|
|
183
184
|
*/
|
|
184
185
|
export function readLegs(opts = {}) {
|
|
185
186
|
const nowMs = (opts.now ?? Date.now)();
|
|
186
187
|
const { rows: allRows, events } = readRunList({ home: opts.home });
|
|
187
188
|
const rows = rowsAbove(allRows, opts.aboveRunid);
|
|
188
|
-
const settled = new Set(events.filter((e) => e.type === 'settled').map((e) => e.runid));
|
|
189
189
|
/** @type {Leg[]} */
|
|
190
190
|
const legs = [];
|
|
191
191
|
for (const row of rows) {
|
|
@@ -195,30 +195,60 @@ export function readLegs(opts = {}) {
|
|
|
195
195
|
// pid is a live bareloop runner, however quiet its spine — killed = process gone, not silence.
|
|
196
196
|
// An older row with no `pid` keeps the spine-mtime rule (`inFlightCapUsd`), never guessed alive.
|
|
197
197
|
const hasPid = Number.isInteger(row.pid);
|
|
198
|
-
|
|
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;
|
|
199
203
|
if (Number.isNaN(at.getTime()) || !existsSync(row.spine)) { legs.push({ ...base, reservedUsd: heldCap ?? 0 }); continue; }
|
|
200
204
|
/** @type {any[]} */
|
|
201
205
|
let records;
|
|
202
206
|
try { records = parseJsonl(row.spine).records; } catch { legs.push({ ...base, reservedUsd: heldCap ?? 0 }); continue; }
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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 })) {
|
|
207
248
|
legs.push({
|
|
208
|
-
at,
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
wallComplete: wall.complete,
|
|
212
|
-
provider: typeof start?.provider === 'string' ? start.provider : null,
|
|
213
|
-
baseUrl: typeof start?.baseUrl === 'string' ? start.baseUrl : null,
|
|
214
|
-
usd: leg.usd,
|
|
215
|
-
complete: leg.complete,
|
|
216
|
-
tokens: legTokens(records),
|
|
217
|
-
vouchedRounds: prov.vouched.rounds,
|
|
218
|
-
otherRounds: prov.guessed.rounds + prov.unpriced.rounds + prov.unknown.rounds,
|
|
219
|
-
reservedUsd: hasPid ? (records.some((r) => r && r.type === 'job-end') || heldCap === null ? leg.usd : Math.max(leg.usd, heldCap))
|
|
220
|
-
: inFlightCapUsd(records, row.spine, nowMs, leg.usd),
|
|
221
|
-
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,
|
|
222
252
|
});
|
|
223
253
|
}
|
|
224
254
|
return legs;
|
|
@@ -232,8 +262,8 @@ export function readLegs(opts = {}) {
|
|
|
232
262
|
const sameLocalMonth = (at, now) => at.getFullYear() === now.getFullYear() && at.getMonth() === now.getMonth();
|
|
233
263
|
|
|
234
264
|
/**
|
|
235
|
-
* @param {{ home?: string, now?: () => number, aboveRunid?: string }} [opts] `aboveRunid`: see {@link readLegs}
|
|
236
|
-
* @returns {{ usd: number, atLeast: boolean, runs: number, reservedUsd: number }} `usd` = real spend (the Money tab's figure); `reservedUsd` = the same with in-flight runs at their cap (the refusal check's)
|
|
265
|
+
* @param {{ home?: string, sessionsRoot?: string, now?: () => number, aboveRunid?: string }} [opts] `aboveRunid`, `sessionsRoot`: see {@link readLegs}
|
|
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
|
|
237
267
|
*/
|
|
238
268
|
export function monthSpend(opts = {}) {
|
|
239
269
|
const nowDate = new Date((opts.now ?? Date.now)());
|
|
@@ -241,17 +271,20 @@ export function monthSpend(opts = {}) {
|
|
|
241
271
|
let reservedUsd = 0;
|
|
242
272
|
let atLeast = false;
|
|
243
273
|
let runs = 0;
|
|
244
|
-
|
|
274
|
+
let heldUsd = 0;
|
|
275
|
+
let heldRuns = 0;
|
|
276
|
+
for (const leg of readLegs({ home: opts.home, sessionsRoot: opts.sessionsRoot, now: opts.now, aboveRunid: opts.aboveRunid, thisMonthOnly: true })) {
|
|
245
277
|
// an unreadable date could belong to this month — unknown, never dropped
|
|
246
278
|
if (Number.isNaN(leg.at.getTime())) { atLeast = true; continue; }
|
|
247
279
|
if (!sameLocalMonth(leg.at, nowDate)) continue;
|
|
248
|
-
runs += 1;
|
|
249
|
-
if (leg.unreadable) { atLeast = true; reservedUsd += leg.reservedUsd; continue; }
|
|
280
|
+
if (!leg.draft) runs += 1;
|
|
281
|
+
if (leg.unreadable) { atLeast = true; reservedUsd += leg.reservedUsd; if (leg.reservedUsd > 0) { heldUsd += leg.reservedUsd; heldRuns += 1; } continue; }
|
|
250
282
|
usd += leg.usd;
|
|
251
283
|
reservedUsd += leg.reservedUsd;
|
|
284
|
+
if (leg.reservedUsd > leg.usd) { heldUsd += leg.reservedUsd - leg.usd; heldRuns += 1; }
|
|
252
285
|
if (!leg.complete) atLeast = true;
|
|
253
286
|
}
|
|
254
|
-
return { usd, atLeast, runs, reservedUsd };
|
|
287
|
+
return { usd, atLeast, runs, reservedUsd, heldUsd, heldRuns };
|
|
255
288
|
}
|
|
256
289
|
|
|
257
290
|
/**
|
|
@@ -259,7 +292,7 @@ export function monthSpend(opts = {}) {
|
|
|
259
292
|
* (rows from `rowIdFor`; a run on no known row lands under its own provider name, never
|
|
260
293
|
* pooled into another row). Any leg with unknown spend makes the figure it belongs to
|
|
261
294
|
* an "at least".
|
|
262
|
-
* @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
|
|
263
296
|
* @returns {{ tokensByRow: Record<string, number>, total: {usd: number, atLeast: boolean, tokens: number}, month: {usd: number, atLeast: boolean, tokens: number},
|
|
264
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}> }}
|
|
265
298
|
*/
|
|
@@ -274,7 +307,7 @@ export function spendSummary(opts = {}) {
|
|
|
274
307
|
const wallSeen = {};
|
|
275
308
|
/** total tokens of the runs each Providers row served (matched by shape + endpoint + model) @type {Record<string, number>} */
|
|
276
309
|
const tokensByRow = {};
|
|
277
|
-
for (const leg of readLegs({ home: opts.home })) {
|
|
310
|
+
for (const leg of readLegs({ home: opts.home, sessionsRoot: opts.sessionsRoot })) {
|
|
278
311
|
let served = opts.rows ? findRow(opts.rows, { provider: leg.provider, baseUrl: leg.baseUrl, model: leg.model }) : null;
|
|
279
312
|
// an older spine records a model but no provider: it counts toward the row whose Name EXACTLY
|
|
280
313
|
// equals that model. Two rows sharing the Name make it ambiguous, so it counts toward neither.
|
|
@@ -293,9 +326,11 @@ export function spendSummary(opts = {}) {
|
|
|
293
326
|
: (notRecorded ? (leg.model ? `not recorded (model ${leg.model})` : 'not recorded') : key.slice('other:'.length));
|
|
294
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 });
|
|
295
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;
|
|
296
331
|
const unknown = leg.unreadable || !leg.complete;
|
|
297
332
|
const wallKnown = !leg.unreadable && leg.wallMs !== null;
|
|
298
|
-
const wallAtLeast = !wallKnown || !leg.wallComplete;
|
|
333
|
+
const wallAtLeast = !noWall && (!wallKnown || !leg.wallComplete);
|
|
299
334
|
total.usd += leg.usd; total.tokens += leg.tokens; if (unknown) total.atLeast = true;
|
|
300
335
|
p.totalUsd += leg.usd; p.tokens += leg.tokens; p.vouchedRounds += leg.vouchedRounds; p.otherRounds += leg.otherRounds; if (unknown) p.totalAtLeast = true;
|
|
301
336
|
if (wallKnown) { p.totalWallMs = /** @type {number} */ (p.totalWallMs) + /** @type {number} */ (leg.wallMs); w.totalKnown += 1; }
|
|
@@ -303,7 +338,7 @@ export function spendSummary(opts = {}) {
|
|
|
303
338
|
if (inMonth) {
|
|
304
339
|
month.usd += leg.usd; month.tokens += leg.tokens; if (unknown) month.atLeast = true;
|
|
305
340
|
p.monthUsd += leg.usd; if (unknown) p.monthAtLeast = true;
|
|
306
|
-
w.monthRuns += 1;
|
|
341
|
+
if (!noWall) w.monthRuns += 1;
|
|
307
342
|
if (wallKnown) { p.monthWallMs = /** @type {number} */ (p.monthWallMs) + /** @type {number} */ (leg.wallMs); w.monthKnown += 1; }
|
|
308
343
|
if (wallAtLeast) p.monthWallAtLeast = true;
|
|
309
344
|
}
|
|
@@ -323,6 +358,8 @@ export function spendSummary(opts = {}) {
|
|
|
323
358
|
* @property {number|null} leftUsd what is left this month (null = no limit set)
|
|
324
359
|
* @property {number|null} limitUsd the person's monthly limit (null = none)
|
|
325
360
|
* @property {boolean} atLeast the month's spend is a floor, so `leftUsd` is a ceiling
|
|
361
|
+
* @property {number} [heldUsd] what run(s) in progress have set aside beyond their spend so far (present when a limit is set)
|
|
362
|
+
* @property {number} [heldRuns] how many runs hold it
|
|
326
363
|
*/
|
|
327
364
|
|
|
328
365
|
/**
|
|
@@ -346,30 +383,33 @@ export function monthlyLimitOf(config, home) {
|
|
|
346
383
|
* number above 0 throws `ConfigError`.
|
|
347
384
|
* Compared in whole cents. Also throws `ConfigError` when config.json is unreadable — a gate
|
|
348
385
|
* whose own instrument is broken refuses; it never silently runs with no limit.
|
|
349
|
-
* @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)
|
|
350
387
|
* @returns {MonthlyRoom}
|
|
351
388
|
*/
|
|
352
|
-
export function checkMonthlyRoom({ capUsd, home, now, aboveRunid }) {
|
|
389
|
+
export function checkMonthlyRoom({ capUsd, home, now, aboveRunid, sessionsRoot }) {
|
|
353
390
|
const cfg = readConfig({ home });
|
|
354
391
|
if (cfg.problem) throw new ConfigError(cfg.problem);
|
|
355
392
|
const limit = monthlyLimitOf(cfg.config, home);
|
|
356
393
|
if (limit === null) {
|
|
357
394
|
return { ok: true, leftUsd: null, limitUsd: null, atLeast: false };
|
|
358
395
|
}
|
|
359
|
-
const spent = monthSpend({ home, now, aboveRunid });
|
|
396
|
+
const spent = monthSpend({ home, sessionsRoot, now, aboveRunid });
|
|
360
397
|
const leftCents = Math.max(0, Math.floor((limit - spent.reservedUsd) * 100 + 1e-6));
|
|
361
|
-
const capCents = Math.ceil(
|
|
362
|
-
return { ok: capCents <= leftCents, leftUsd: leftCents / 100, limitUsd: limit, atLeast: spent.atLeast };
|
|
398
|
+
const capCents = Math.ceil(capUsd * 100 - 1e-6); // a non-finite cap never fits (NaN/Infinity compare false) — never read as $0
|
|
399
|
+
return { ok: capCents <= leftCents, leftUsd: leftCents / 100, limitUsd: limit, atLeast: spent.atLeast, heldUsd: spent.heldUsd, heldRuns: spent.heldRuns };
|
|
363
400
|
}
|
|
364
401
|
|
|
365
402
|
/**
|
|
366
|
-
* The ONE refusal text, everywhere (CLI, panel cap note, panel Sign refusal).
|
|
403
|
+
* The ONE refusal text, everywhere (CLI, panel cap note, panel Sign refusal). When run(s) in progress hold
|
|
404
|
+
* part of the limit, the same line says how much.
|
|
367
405
|
* @param {MonthlyRoom} room
|
|
368
406
|
* @returns {string|null} null when the run fits
|
|
369
407
|
*/
|
|
370
408
|
export function monthlyRefusalText(room) {
|
|
371
409
|
if (room.ok || room.leftUsd === null) return null;
|
|
372
|
-
|
|
410
|
+
const max = `Max $${room.leftUsd.toFixed(2)}`;
|
|
411
|
+
if (!room.heldRuns || !room.heldUsd || room.heldUsd <= 0) return `${max} (monthly limit)`;
|
|
412
|
+
return `${max} (monthly limit, $${room.heldUsd.toFixed(2)} held by ${room.heldRuns > 1 ? 'runs' : 'a run'} in progress)`;
|
|
373
413
|
}
|
|
374
414
|
|
|
375
415
|
/**
|
|
@@ -382,13 +422,14 @@ export function monthlyRefusalText(room) {
|
|
|
382
422
|
*/
|
|
383
423
|
export function settleDeadClaims({ home, by, aboveRunid }) {
|
|
384
424
|
const { rows, events } = readRunList({ home });
|
|
385
|
-
const settled = new Set(events.filter((e) => e.type === 'settled').map((e) => e.runid));
|
|
386
425
|
let n = 0;
|
|
387
426
|
for (const row of rowsAbove(rows, aboveRunid)) {
|
|
388
|
-
|
|
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;
|
|
389
430
|
let spentUsd = 0;
|
|
390
|
-
try { spentUsd = legSpend(parseJsonl(row.spine).records).usd; } catch { /* no spine: nothing was recorded spent */ }
|
|
391
|
-
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 });
|
|
392
433
|
n += 1;
|
|
393
434
|
}
|
|
394
435
|
return n;
|
|
@@ -402,20 +443,25 @@ export function settleDeadClaims({ home, by, aboveRunid }) {
|
|
|
402
443
|
* it appends `{type:'released', reason:'refused'}` and reports the refusal, nothing spent. No limit set =
|
|
403
444
|
* no claim (`claimed: false`; the caller lists the run as it always did). An unreadable config, or a row
|
|
404
445
|
* that cannot be written, throws `ConfigError` — the gate refuses, never reads "no limit".
|
|
405
|
-
*
|
|
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
|
|
406
449
|
* @returns {{ room: MonthlyRoom, claimed: boolean }}
|
|
407
450
|
*/
|
|
408
|
-
export function claimRun({ row, capUsd, home, now }) {
|
|
451
|
+
export function claimRun({ row, capUsd, home, now, leg = 1 }) {
|
|
409
452
|
const cfg = readConfig({ home });
|
|
410
453
|
if (cfg.problem) throw new ConfigError(cfg.problem);
|
|
411
454
|
if (monthlyLimitOf(cfg.config, home) === null) return { room: { ok: true, leftUsd: null, limitUsd: null, atLeast: false }, claimed: false };
|
|
412
|
-
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) {
|
|
413
459
|
throw new ConfigError(`cannot write this run's claim to the run list (${e.message})`);
|
|
414
460
|
}
|
|
415
461
|
settleDeadClaims({ home, by: row.runid, aboveRunid: row.runid });
|
|
416
462
|
const room = checkMonthlyRoom({ capUsd, home, now, aboveRunid: row.runid });
|
|
417
463
|
if (!room.ok) {
|
|
418
|
-
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 */ }
|
|
419
465
|
return { room, claimed: false };
|
|
420
466
|
}
|
|
421
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,23 @@ 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
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The ONE Host check: the request names this panel's own bind address. A page on another
|
|
46
|
+
* origin reaching the panel through a rebound DNS name sends that name as Host, so it fails here.
|
|
47
|
+
* @param {import('node:http').IncomingMessage} req
|
|
48
|
+
* @param {{port: number}} o
|
|
49
|
+
* @returns {{ok: true}|{ok: false, reason: string}}
|
|
50
|
+
*/
|
|
51
|
+
export function checkHostGuard(req, { port }) {
|
|
52
|
+
return req.headers.host === bindAddress(port) ? { ok: true } : { ok: false, reason: 'wrong Host' };
|
|
53
|
+
}
|
|
54
|
+
|
|
37
55
|
/**
|
|
38
56
|
* `true` when a request may reach ANY route in this file — the token header
|
|
39
57
|
* plus an Origin/Host that names this exact bind address. `req.headers`
|
|
@@ -46,12 +64,11 @@ export function mintToken() {
|
|
|
46
64
|
export function checkHumanGuard(req, { token, port }) {
|
|
47
65
|
const got = req.headers['x-bareloop-token'];
|
|
48
66
|
if (got !== token) return { ok: false, reason: 'missing or wrong token' };
|
|
49
|
-
const want =
|
|
67
|
+
const want = bindAddress(port);
|
|
50
68
|
const origin = req.headers.origin;
|
|
51
|
-
const host = req.headers.host;
|
|
52
69
|
if (typeof origin === 'string' && origin.length > 0) {
|
|
53
70
|
if (origin !== `http://${want}` && origin !== `https://${want}`) return { ok: false, reason: 'wrong Origin' };
|
|
54
|
-
} else if (
|
|
71
|
+
} else if (!checkHostGuard(req, { port }).ok) {
|
|
55
72
|
return { ok: false, reason: 'wrong Host (and no Origin header)' };
|
|
56
73
|
}
|
|
57
74
|
return { ok: true };
|
|
@@ -73,7 +90,8 @@ const TERMINAL_PHASES = new Set(['refused', 'abandoned', 'error', 'signed', 'sig
|
|
|
73
90
|
* deliberately has none of).
|
|
74
91
|
* @param {{ port: number, token: string, env?: Record<string,string|undefined>,
|
|
75
92
|
* sessionsRoot?: string, spawnFn?: typeof realSpawn, bareloopBin?: string,
|
|
76
|
-
* 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
|
|
77
95
|
*/
|
|
78
96
|
export function createAuthorRoutes(opts) {
|
|
79
97
|
// the RAW env with the keys file re-merged on every use, so an edited file takes effect
|
|
@@ -170,7 +188,7 @@ export function createAuthorRoutes(opts) {
|
|
|
170
188
|
const cap = Number(q.get('cap'));
|
|
171
189
|
if (!Number.isFinite(cap) || cap <= 0) { send(200, { ok: true, refusal: null }); return true; }
|
|
172
190
|
try {
|
|
173
|
-
const room = checkMonthlyRoom({ capUsd: cap, home: opts.home });
|
|
191
|
+
const room = checkMonthlyRoom({ capUsd: cap, home: opts.home, sessionsRoot: opts.sessionsRoot });
|
|
174
192
|
send(200, { ok: true, refusal: monthlyRefusalText(room), leftUsd: room.leftUsd, atLeast: room.atLeast });
|
|
175
193
|
} catch (e) {
|
|
176
194
|
if (!(e instanceof ConfigError)) throw e;
|
|
@@ -179,19 +197,74 @@ export function createAuthorRoutes(opts) {
|
|
|
179
197
|
return true;
|
|
180
198
|
}
|
|
181
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
|
+
|
|
182
229
|
if (pathname === '/api/author/start') {
|
|
183
230
|
if (req.method !== 'POST') { send(405, { ok: false, error: 'POST only' }); return true; }
|
|
184
231
|
if (hasLiveSession()) { send(409, { ok: false, error: 'an authoring session is already live — one at a time' }); return true; }
|
|
185
232
|
const card = body ?? {};
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
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
|
+
});
|
|
189
262
|
sessions.set(session.id, session);
|
|
190
|
-
send(200, { ok: true, sessionId: session.id, state: session.state });
|
|
263
|
+
send(200, { ok: true, sessionId: session.id, reuse: reuse !== null, state: session.state });
|
|
191
264
|
return true;
|
|
192
265
|
}
|
|
193
266
|
|
|
194
|
-
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);
|
|
195
268
|
if (!m) { send(404, { ok: false, error: 'not found' }); return true; }
|
|
196
269
|
const session = sessions.get(m[1]);
|
|
197
270
|
if (!session) { send(404, { ok: false, error: 'no such session' }); return true; }
|
|
@@ -213,6 +286,11 @@ export function createAuthorRoutes(opts) {
|
|
|
213
286
|
session.revise(String(body?.text ?? '')).then((r) => send(r.ok ? 200 : 400, { ...r, state: session.state }));
|
|
214
287
|
return true;
|
|
215
288
|
}
|
|
289
|
+
if (sub === 'abandon') {
|
|
290
|
+
const r = session.abandon();
|
|
291
|
+
send(r.ok ? 200 : 400, { ...r, state: session.state });
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
216
294
|
if (sub === 'check-deps') {
|
|
217
295
|
const r = session.checkDeps();
|
|
218
296
|
send(r.ok ? 200 : 400, { ...r, state: session.state });
|
|
@@ -248,7 +326,7 @@ export function createAuthorRoutes(opts) {
|
|
|
248
326
|
* @param {number} n a non-negative dollar amount
|
|
249
327
|
* @returns {string}
|
|
250
328
|
*/
|
|
251
|
-
function panelMoney2(n) {
|
|
329
|
+
export function panelMoney2(n) {
|
|
252
330
|
if (n > 0 && n < 0.01) return '<$0.01';
|
|
253
331
|
// two-step rounding (matching index.html's own panelMoney): clean to
|
|
254
332
|
// 6-decimal precision first (the same precision src/text.js's tallyCalls
|
|
@@ -280,7 +358,7 @@ export function signRun(session, claimedHash, o) {
|
|
|
280
358
|
// lower nothing (the cap is signed in) but may raise the limit in Settings and sign again.
|
|
281
359
|
try {
|
|
282
360
|
const spec = JSON.parse(readFileSync(session.state.resolvedSpecPath, 'utf8'));
|
|
283
|
-
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 }));
|
|
284
362
|
if (refusal !== null) return { ok: false, error: refusal };
|
|
285
363
|
} catch (e) {
|
|
286
364
|
if (e instanceof ConfigError) return { ok: false, error: `${e.message} — refusing rather than guess the monthly limit` };
|
|
@@ -320,9 +398,11 @@ export function signRun(session, claimedHash, o) {
|
|
|
320
398
|
});
|
|
321
399
|
if (typeof child?.unref === 'function') child.unref();
|
|
322
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)
|
|
323
403
|
const draftLine = draftSpentUsd !== null
|
|
324
|
-
? `
|
|
404
|
+
? `drafting spent ${draftIncomplete ? 'at least ' : ''}${panelMoney2(draftSpentUsd)} (folds out of the run's own cap)`
|
|
325
405
|
: '';
|
|
326
|
-
session.state.
|
|
406
|
+
if (Array.isArray(session.state.steps)) session.state.steps.push({ id: 'signed', label: STEP_LABELS.signed, status: 'done', detail: draftLine });
|
|
327
407
|
return { ok: true, job: session.state.resolvedSpecPath };
|
|
328
408
|
}
|