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.
Files changed (53) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/bareloop.context.md +241 -13
  3. package/package.json +1 -1
  4. package/src/bundlerun.js +13 -4
  5. package/src/draftspend.js +82 -0
  6. package/src/exits.js +10 -1
  7. package/src/interviewrun.js +7 -7
  8. package/src/job.js +37 -0
  9. package/src/judged.js +2 -2
  10. package/src/kinds.js +2 -1
  11. package/src/ledger.js +101 -5
  12. package/src/legs.js +171 -0
  13. package/src/monthly.js +111 -72
  14. package/src/panel/authorroutes.js +84 -14
  15. package/src/panel/authorsession.js +408 -113
  16. package/src/panel/importroutes.js +359 -0
  17. package/src/panel/importrun.js +203 -0
  18. package/src/panel/index.html +1071 -184
  19. package/src/panel/runroutes.js +219 -0
  20. package/src/panel/server.js +839 -70
  21. package/src/panel/status.js +51 -0
  22. package/src/planrun.js +60 -26
  23. package/src/providerrows.js +17 -0
  24. package/src/ralph.js +3 -0
  25. package/src/replay.js +139 -19
  26. package/src/replayio.js +11 -4
  27. package/src/reuse.js +20 -3
  28. package/src/run.js +6 -3
  29. package/src/runlist.js +75 -7
  30. package/src/text.js +20 -0
  31. package/src/u-readout.js +9 -1
  32. package/src/u-watchdog.mjs +4 -2
  33. package/src/userrun.js +138 -57
  34. package/types/draftspend.d.ts +53 -0
  35. package/types/job.d.ts +25 -0
  36. package/types/ledger.d.ts +44 -0
  37. package/types/legs.d.ts +103 -0
  38. package/types/monthly.d.ts +23 -18
  39. package/types/panel/authorroutes.d.ts +23 -1
  40. package/types/panel/authorsession.d.ts +109 -2
  41. package/types/panel/importroutes.d.ts +129 -0
  42. package/types/panel/importrun.d.ts +77 -0
  43. package/types/panel/runroutes.d.ts +32 -0
  44. package/types/panel/server.d.ts +255 -2
  45. package/types/panel/status.d.ts +51 -0
  46. package/types/planrun.d.ts +13 -21
  47. package/types/providerrows.d.ts +12 -0
  48. package/types/replay.d.ts +25 -1
  49. package/types/replayio.d.ts +2 -2
  50. package/types/reuse.d.ts +5 -0
  51. package/types/run.d.ts +6 -2
  52. package/types/runlist.d.ts +52 -2
  53. 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 stays exactly `Max $X (monthly limit)`; `atLeast` travels beside it.
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 own spend, off its spine records: this LEG's figure (never the chain fold),
65
- * plus the drafting spend the run was signed with (counted once — on the first leg only).
66
- * @param {any[]} records
67
- * @returns {{ usd: number, complete: boolean }}
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 legSpend(records) {
70
- const jobStart = records.find((r) => r && r.type === 'job-start') ?? null;
71
- const jobEnd = records.findLast((r) => r && r.type === 'job-end') ?? null;
72
- const rounds = records.filter((r) => r && SPEND_RECORD_TYPES.includes(r.type));
73
- const pricedSum = rounds.reduce((a, r) => (typeof r.costUsd === 'number' && Number.isFinite(r.costUsd) ? a + r.costUsd : a), 0);
74
- const unpriced = rounds.filter((r) => !(typeof r.costUsd === 'number' && Number.isFinite(r.costUsd))).length;
75
- let usd = pricedSum;
76
- let complete = false;
77
- if (jobEnd) {
78
- const eng = jobEnd.engagementSpentUsd;
79
- usd = typeof eng === 'number' && Number.isFinite(eng) ? eng : pricedSum;
80
- complete = jobEnd.spendComplete === true && unpriced === 0;
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 { usd, complete };
89
+ return legWallOne(records);
89
90
  }
90
91
 
91
92
  /**
92
- * One run's wall time off its spine: job-start -> job-end when the run ended (exact), else the
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
- export function legWall(records) {
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
- return i < 0 ? rows : rows.slice(0, i);
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
- const heldCap = hasPid && !settled.has(row.runid) && typeof row.capUsd === 'number' && Number.isFinite(row.capUsd) && runIsAlive(row, nowMs) ? row.capUsd : null;
197
- if (opts.thisMonthOnly && heldCap === null && !Number.isNaN(at.getTime()) && !sameLocalMonth(at, new Date(nowMs))) continue;
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
- const start = records.find((r) => r && r.type === 'job-start') ?? null;
203
- const leg = legSpend(records);
204
- const prov = spendProvenance(records);
205
- const wall = legWall(records);
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
- model: typeof start?.model === 'string' ? start.model : null,
209
- wallMs: wall.ms,
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
- if (!Number.isInteger(row.pid) || settled.has(row.runid) || isLiveRunner(/** @type {number} */ (row.pid))) continue;
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
- * @param {{ row: import('./runlist.js').RunRow, capUsd: number, home?: string, now?: () => number }} args
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 { appendRun(row, { home }); } catch (/** @type {any} */ e) {
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 === `127.0.0.1:${port}` ? { ok: true } : { ok: false, reason: 'wrong 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 = `127.0.0.1:${port}`;
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 }} opts
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 v = validateJobCard(card, { jobsDir: opts.jobsDir, rows: rowsForHome(keysHome(opts.home)) });
197
- if (!v.ok) { send(400, { ok: false, error: v.error }); return true; }
198
- const session = createSession(card, { env: envNow(), sessionsRoot: opts.sessionsRoot, ...(opts.home !== undefined ? { home: opts.home } : {}) });
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
- ? ` — drafting spent ${draftIncomplete ? 'at least ' : ''}${panelMoney2(draftSpentUsd)} (folds out of the run's own cap)`
404
+ ? `drafting spent ${draftIncomplete ? 'at least ' : ''}${panelMoney2(draftSpentUsd)} (folds out of the run's own cap)`
335
405
  : '';
336
- session.state.messages.push({ role: 'system', text: `signed — spec hash ${session.state.specHash}${draftLine} — run starting detached, own log at ${logFile}` });
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
  }