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/userrun.js
CHANGED
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
// green ONE job end to end. That green GRADUATES the bridge: the plan the agent
|
|
25
25
|
// authored is preserved from the spine as a reusable artifact, and the next run of
|
|
26
26
|
// this shape reuses and fine-tunes it rather than starting cold.
|
|
27
|
-
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, renameSync, rmSync, readdirSync } from 'node:fs';
|
|
27
|
+
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, renameSync, rmSync, readdirSync, openSync, readSync, closeSync, fstatSync, unlinkSync } from 'node:fs';
|
|
28
28
|
import { createRequire } from 'node:module';
|
|
29
29
|
import { fileURLToPath } from 'node:url';
|
|
30
30
|
import { execFileSync, spawn } from 'node:child_process';
|
|
31
|
-
import { join, resolve, dirname } from 'node:path';
|
|
31
|
+
import { join, resolve, dirname, basename } from 'node:path';
|
|
32
32
|
import { runJob } from './run.js';
|
|
33
33
|
import { moneyWithDraft } from './replay.js';
|
|
34
34
|
import { jobSpecHash, resolveWorkerModel, validateJob } from './job.js';
|
|
@@ -72,10 +72,13 @@ import { answerReviewDoor, doorRecordOf, doorAgeGate } from './reviewdoor.js';
|
|
|
72
72
|
// the cold reset, shared with the battery drivers so "cold" has one spelling
|
|
73
73
|
import { coldReset, moveStaleGateAudit } from './u-patient.js';
|
|
74
74
|
// PANEL-BUILD.md P1 — the one run list (`~/.config/bareloop/runs.jsonl`).
|
|
75
|
-
import { appendRun, appendRunEvent, isLiveRunner } from './runlist.js';
|
|
75
|
+
import { appendRun, appendLegStart, appendRunEvent, isLiveRunner, readRunList } from './runlist.js';
|
|
76
|
+
// P5-R — a resumed run is the SAME run: one id, one spine file, a `leg-resume` marker inside. `legsOf` is the
|
|
77
|
+
// one owner of where a leg starts and ends; every reader below asks it.
|
|
78
|
+
import { legsOf, parseSpineText, LEG_RESUME, watchdogNotePath, stopFilePath } from './legs.js';
|
|
76
79
|
import { keysForDoor } from './keysfile.js';
|
|
77
80
|
import { parseJsonl } from './replayio.js';
|
|
78
|
-
import { claimRun, monthlyRefusalText, legSpend } from './monthly.js';
|
|
81
|
+
import { claimRun, monthlyRefusalText, legSpend, chainSpend } from './monthly.js';
|
|
79
82
|
import { ConfigError } from './config.js';
|
|
80
83
|
import { commandFor } from './invoke.js';
|
|
81
84
|
import { applyConfiguredKey, judgeRatesFor, keyNameFor, priceReadout, ratesFor, rowsForHome } from './providerrows.js';
|
|
@@ -360,8 +363,18 @@ async function execute(ctx) {
|
|
|
360
363
|
// owner of "a claimed run that never began" — every exit between the claim and the spine's first
|
|
361
364
|
// record (a refused resume patient, a throwing prepareTree, a git/reset failure...) passes there.
|
|
362
365
|
// Set on the no-limit path too, once the row is appended there (the same "listed, no spine yet" state).
|
|
363
|
-
/** @type {{ runid: string, spine: string, home: string|undefined }|null} */
|
|
366
|
+
/** @type {{ runid: string, spine: string, home: string|undefined, leg: number }|null} */
|
|
364
367
|
let claimedRow = null;
|
|
368
|
+
// P5-R: has this leg written its first record (the `leg-resume` marker on a resume)? A resumed leg's spine
|
|
369
|
+
// file already exists, so "the file exists" no longer says the leg began.
|
|
370
|
+
let legBegun = false;
|
|
371
|
+
// Release the claimed row (a `released` entry folds it out of the list, like a monthly refusal): the run
|
|
372
|
+
// spent nothing and must not stay a ghost that keeps the month reading "at least". A failed release write
|
|
373
|
+
// never masks the original exit.
|
|
374
|
+
const releaseClaimedRow = () => {
|
|
375
|
+
if (!claimedRow) return;
|
|
376
|
+
try { appendRunEvent({ runid: claimedRow.runid, type: 'released', by: claimedRow.runid, reason: 'not started', at: new Date().toISOString(), ...(claimedRow.leg > 1 ? { leg: claimedRow.leg } : {}) }, { home: claimedRow.home }); } catch { /* the exit stands; a stranded row of a dead process is closed by the next run */ }
|
|
377
|
+
};
|
|
365
378
|
try {
|
|
366
379
|
const spec = ctx.spec;
|
|
367
380
|
const specPath = ctx.specPath;
|
|
@@ -655,6 +668,13 @@ async function execute(ctx) {
|
|
|
655
668
|
: (RESUME.includes('/') || RESUME.endsWith('.jsonl') ? resolve(RESUME) : join(spineDir, `u-${RESUME}.jsonl`));
|
|
656
669
|
/** @type {any} */
|
|
657
670
|
let dead = null;
|
|
671
|
+
/** P5-R — the legs the halted run's file already holds, the leg a resume continues from, and how that leg ended @type {import('./legs.js').Leg[]} */
|
|
672
|
+
let deadLegs = [];
|
|
673
|
+
/** every record of the halted run's one file (all its legs) @type {any[]} */
|
|
674
|
+
let deadAllEvents = [];
|
|
675
|
+
let deadFromLegNo = 1;
|
|
676
|
+
/** @type {string} */
|
|
677
|
+
let deadAfter = 'died';
|
|
658
678
|
/** F102 — the decision this checkpoint HOLDS: an answer the person already gave, on
|
|
659
679
|
* a leg that stopped before a single round was bought for it. Null on every ordinary
|
|
660
680
|
* resume. It is applied directly and the ask is never re-rendered — the whole of the
|
|
@@ -669,31 +689,49 @@ async function execute(ctx) {
|
|
|
669
689
|
if (!existsSync(deadSpineFile)) die(`--resume: no spine at ${deadSpineFile} — a resume continues a run that happened, and this one left no log`);
|
|
670
690
|
let raw = '';
|
|
671
691
|
try { raw = readFileSync(deadSpineFile, 'utf8'); } catch (e) { die(`--resume: cannot read ${deadSpineFile}: ${e.message}`); }
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
692
|
+
// ONE parser for the spine's text (src/legs.js `parseSpineText`): a kill can land mid-append, so a broken
|
|
693
|
+
// LAST line is the failure being modelled; and a resumed run's own file carries exactly one torn line
|
|
694
|
+
// directly before each `leg-resume` marker (the resume never edits the torn bytes, it starts a fresh
|
|
695
|
+
// line). Both are tolerated and NAMED. A broken line anywhere else is a corrupt log, and reconstructing
|
|
696
|
+
// from one would invent a history.
|
|
697
|
+
const parsedDead = parseSpineText(raw);
|
|
698
|
+
for (const t of parsedDead.tolerated) {
|
|
699
|
+
err(t.why === 'tail'
|
|
700
|
+
? `--resume: ignoring a truncated final line in ${deadSpineFile} (a kill mid-append — the expected shape)`
|
|
701
|
+
: `--resume: ignoring a truncated line ${t.line} in ${deadSpineFile} (a kill mid-append, left isolated by the next leg's marker)`);
|
|
702
|
+
}
|
|
703
|
+
if (parsedDead.corrupt) die(`--resume: ${deadSpineFile} is corrupt at line ${parsedDead.corrupt.line} (${parsedDead.corrupt.message}) — a reconstruction from a damaged log would invent a history`);
|
|
704
|
+
/** every record of the run's one file (all legs) */
|
|
705
|
+
deadAllEvents = parsedDead.records;
|
|
706
|
+
const allDeadEvents = deadAllEvents;
|
|
707
|
+
/** the legs already on the file, and the leg THIS resume will be (P5-R) */
|
|
708
|
+
deadLegs = legsOf(allDeadEvents);
|
|
709
|
+
// the leg a resume continues FROM is the latest leg that got as far as a `job-start`: a leg that died or
|
|
710
|
+
// was refused before runJob wrote one (a $0 stop) left no checkpoint, and the one behind it still stands
|
|
711
|
+
const fromLeg = deadLegs.findLast((l) => l.jobStart !== null) ?? deadLegs.at(-1);
|
|
712
|
+
/** the records `readResume` and every gate below read: that leg's own window, which carries its own declared fold */
|
|
713
|
+
const deadEvents = fromLeg ? fromLeg.records : allDeadEvents;
|
|
714
|
+
deadFromLegNo = fromLeg ? fromLeg.leg : 1;
|
|
685
715
|
// is the dead run actually dead? Two processes on one patient is unrecoverable; a
|
|
686
716
|
// false refusal costs one sentence. run-u's own pid is not on its spine, so the
|
|
687
|
-
// watchdog's report is the pid there is — and its absence is not evidence of life.
|
|
688
|
-
|
|
717
|
+
// watchdog's report or the run-list row is the pid there is — and its absence is not evidence of life.
|
|
718
|
+
// the watchdog's note for the leg being continued from (a resumed leg has its own note file)
|
|
719
|
+
const wdFile = watchdogNotePath(deadSpineFile, deadFromLegNo);
|
|
689
720
|
/** @type {any} */
|
|
690
721
|
let watchdog = null;
|
|
691
722
|
if (existsSync(wdFile)) { try { watchdog = JSON.parse(readFileSync(wdFile, 'utf8')); } catch { /* an unreadable report is not evidence of life */ } }
|
|
692
723
|
// ONE owner of "is that pid a live bareloop runner" (src/runlist.js `isLiveRunner`, shared with the
|
|
693
724
|
// monthly limit's holds): alive AND a bareloop runner refuses; a recycled pid that now runs some
|
|
694
725
|
// other program does not. Where /proc is unreadable an existing pid is assumed to be ours.
|
|
695
|
-
|
|
696
|
-
|
|
726
|
+
// The pid is the watchdog's kill record's, or the predecessor's OWN run-list row's (every row carries
|
|
727
|
+
// its runner's pid): a run never killed by the watchdog has no record but still has its row.
|
|
728
|
+
/** @type {number|undefined} */
|
|
729
|
+
let rowPid;
|
|
730
|
+
if (cfgOn) { try { rowPid = readRunList({ home: cfgHome }).rows.findLast((r) => r.spine === deadSpineFile)?.pid; } catch { /* an unreadable list is not evidence of life */ } }
|
|
731
|
+
// a run's row carries its LATEST leg's pid (the run list folds `leg-start`); any earlier leg's note pid is checked too
|
|
732
|
+
const alivePid = [watchdog?.pid, rowPid].find((p) => Number.isInteger(p) && isLiveRunner(p));
|
|
733
|
+
if (alivePid !== undefined) {
|
|
734
|
+
die(`--resume: pid ${alivePid} from ${deadSpineFile} is still alive (a bareloop runner). Two processes on one patient is unrecoverable — stop it first.`);
|
|
697
735
|
}
|
|
698
736
|
// WHEN did the dead leg stop? The watchdog's kill record is later, better evidence
|
|
699
737
|
// than the last spine event for a run that was KILLED — and worse evidence for one
|
|
@@ -702,6 +740,8 @@ async function execute(ctx) {
|
|
|
702
740
|
// closes was measured, not imagined (a report dated after a pause bills the human's
|
|
703
741
|
// deciding time to the run's wall and can zero the remainder outright).
|
|
704
742
|
const deathAt = deathAtOf({ watchdogAt: watchdog?.at, events: deadEvents });
|
|
743
|
+
// how the leg being continued from ended, for the marker the new leg opens with (`'died'`: no terminal recorded)
|
|
744
|
+
deadAfter = fromLeg?.outcome ?? 'died';
|
|
705
745
|
dead = readResume(deadEvents, {
|
|
706
746
|
direct: true,
|
|
707
747
|
resumableOutcomes: RESUMABLE_HALTS,
|
|
@@ -779,13 +819,10 @@ async function execute(ctx) {
|
|
|
779
819
|
if (!existsSync(doorSpineFile)) die(`--door: no spine at ${doorSpineFile} — a door belongs to a run that happened, and this one left no log`);
|
|
780
820
|
let raw = '';
|
|
781
821
|
try { raw = readFileSync(doorSpineFile, 'utf8'); } catch (e) { die(`--door: cannot read ${doorSpineFile}: ${e.message}`); }
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
die(`--door: ${doorSpineFile} is corrupt at line ${i + 1} (${e.message}) — a decision made on a damaged record is a decision about a run nobody can read`);
|
|
787
|
-
}
|
|
788
|
-
});
|
|
822
|
+
// the same parser as the resume reader: a resumed run's file carries one torn line before each marker
|
|
823
|
+
const parsedDoor = parseSpineText(raw);
|
|
824
|
+
if (parsedDoor.corrupt) die(`--door: ${doorSpineFile} is corrupt at line ${parsedDoor.corrupt.line} (${parsedDoor.corrupt.message}) — a decision made on a damaged record is a decision about a run nobody can read`);
|
|
825
|
+
doorEvents = parsedDoor.records;
|
|
789
826
|
const start = doorEvents.find((/** @type {any} */ e) => e?.type === 'job-start');
|
|
790
827
|
if (!start) die(`--door: ${doorSpineFile} carries no job-start — that is not a bareloop run's spine`);
|
|
791
828
|
if (start.job !== spec.job) die(`--door: that spine is job "${start.job}", not "${spec.job}" — a door is answered against the spec that was signed for it`);
|
|
@@ -807,14 +844,11 @@ async function execute(ctx) {
|
|
|
807
844
|
err(` - or revise the goal/spec in ${SPEC_DESC} — a spec edit, so the hash changes and you sign the new one.`);
|
|
808
845
|
throw new ExitSignal(2);
|
|
809
846
|
}
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
// at $0" — completeness is only trusted when the number it completes is known
|
|
816
|
-
spendComplete: spentKnown && je?.spendComplete !== false,
|
|
817
|
-
};
|
|
847
|
+
// the chain's spend, on ONE basis per shape (`chainSpend`, src/monthly.js): a resumed run's several legs are
|
|
848
|
+
// summed by their own spend, never a later terminal's chain total on top of the earlier legs. A FLOOR stays a
|
|
849
|
+
// floor across the door (F6): an absent figure is never "complete at $0".
|
|
850
|
+
const chain = chainSpend(doorEvents);
|
|
851
|
+
doorPrior = { spentUsd: chain.usd, spendComplete: chain.complete };
|
|
818
852
|
}
|
|
819
853
|
|
|
820
854
|
/** is this resume the one a PERSON has to answer? Read off the recorded terminal,
|
|
@@ -1290,10 +1324,10 @@ async function execute(ctx) {
|
|
|
1290
1324
|
// ── THE PAUSE DOOR (2026-08-17) — answered HERE, and it launches nothing.
|
|
1291
1325
|
//
|
|
1292
1326
|
// hamr's ruling replaced cancel with a pause that can resume, and the honest way to
|
|
1293
|
-
// keep a checkpoint is to leave it alone. This runner
|
|
1327
|
+
// keep a checkpoint is to leave it alone. This runner appends one LEG per resume to the run's one spine file,
|
|
1294
1328
|
// and a leg that returns before drafting emits no `plan-accepted` and no `step-end` —
|
|
1295
1329
|
// which is exactly what `readStepCheckpoint` reads. So launching a run to say "not
|
|
1296
|
-
// now" would
|
|
1330
|
+
// now" would add a LEG whose own checkpoint is empty, and an operator who
|
|
1297
1331
|
// later resumed that runid would re-draft and re-pay for every step the paused leg
|
|
1298
1332
|
// already finished. The checkpoint that matters is the one already on disk.
|
|
1299
1333
|
//
|
|
@@ -1475,14 +1509,23 @@ async function execute(ctx) {
|
|
|
1475
1509
|
// closed at its floor, with a note, once its process is gone). No limit set = no claim (the row is listed as
|
|
1476
1510
|
// ever). An unreadable config.json, or a claim row that cannot be written, refuses too — a broken instrument
|
|
1477
1511
|
// never silently reads "no limit".
|
|
1478
|
-
|
|
1479
|
-
|
|
1512
|
+
// P5-R (hamr 2026-10-02: "one run, one id, one file"): a RESUME is the same run — the runid and the spine file
|
|
1513
|
+
// are the halted run's own, and the new leg appends to it. Never a new id, never a second file.
|
|
1514
|
+
const resuming = dead !== null && deadSpineFile !== null;
|
|
1515
|
+
const runid = resuming
|
|
1516
|
+
? (ctx.bundle?.runid ?? basename(/** @type {string} */ (deadSpineFile)).replace(/\.jsonl$/, '').replace(/^u-/, ''))
|
|
1517
|
+
: (ctx.bundle?.runid ?? Date.now().toString(36));
|
|
1518
|
+
const spineFile = resuming ? /** @type {string} */ (deadSpineFile)
|
|
1519
|
+
: (ctx.bundle ? join(spineDir, 'spine.jsonl') : join(spineDir, `u-${runid}.jsonl`));
|
|
1520
|
+
/** which leg of the run this process is (1 = a fresh run) */
|
|
1521
|
+
const LEG = resuming ? deadLegs.length + 1 : 1;
|
|
1480
1522
|
const foldedUsd = dead ? dead.restart.priorSpentUsd : (doorPrior?.spentUsd ?? 0);
|
|
1481
1523
|
const legCapUsd = Math.max(0, spec.budgetUsd - (typeof foldedUsd === 'number' && Number.isFinite(foldedUsd) ? foldedUsd : 0));
|
|
1482
1524
|
// every row carries its runner's pid and the leg cap, limit set or not: a limit set LATER still holds it
|
|
1483
1525
|
/** @type {import('./runlist.js').RunRow} */
|
|
1484
1526
|
const runRow = {
|
|
1485
|
-
|
|
1527
|
+
// a RESUMED run keeps its own start stamp (the row is listed only if the list has none; it is the run's, not the leg's)
|
|
1528
|
+
at: resuming ? (deadLegs[0]?.start?.ts ?? new Date().toISOString()) : new Date().toISOString(), runid, job: spec.job, spine: spineFile, patient: wd, via: ctx.bundle ? 'bundle' : 'run-u', pid: process.pid, capUsd: legCapUsd,
|
|
1486
1529
|
};
|
|
1487
1530
|
let claimed = false;
|
|
1488
1531
|
{
|
|
@@ -1490,9 +1533,9 @@ async function execute(ctx) {
|
|
|
1490
1533
|
let refusal = null;
|
|
1491
1534
|
try {
|
|
1492
1535
|
if (cfgOn) {
|
|
1493
|
-
const got = claimRun({ row: runRow, capUsd: legCapUsd, home: cfgHome });
|
|
1536
|
+
const got = claimRun({ row: runRow, capUsd: legCapUsd, home: cfgHome, leg: LEG });
|
|
1494
1537
|
claimed = got.claimed;
|
|
1495
|
-
if (claimed) claimedRow = { runid, spine: spineFile, home: cfgHome };
|
|
1538
|
+
if (claimed) claimedRow = { runid, spine: spineFile, home: cfgHome, leg: LEG };
|
|
1496
1539
|
refusal = monthlyRefusalText(got.room);
|
|
1497
1540
|
}
|
|
1498
1541
|
} catch (e) {
|
|
@@ -1518,11 +1561,10 @@ async function execute(ctx) {
|
|
|
1518
1561
|
|
|
1519
1562
|
// PANEL-BUILD.md P1 — one row in the run list, BEFORE any paid call (this
|
|
1520
1563
|
// leg's own `runJob` further down, and BEFORE the provider-key checks that
|
|
1521
|
-
// preceded this point already gated on). `runid` is fresh
|
|
1522
|
-
//
|
|
1523
|
-
// runid-dedup
|
|
1524
|
-
//
|
|
1525
|
-
// in-process test). A list-append failure must never block a real,
|
|
1564
|
+
// preceded this point already gated on). `runid` is fresh on a cold start
|
|
1565
|
+
// and the HALTED run's own on a resume (P5-R: a resume is the same run), so on a resume
|
|
1566
|
+
// `appendRun`'s runid-dedup is what keeps it ONE row and the leg's claim is the
|
|
1567
|
+
// `leg-start` beside it; on a cold start it never fires (a retried in-process test). A list-append failure must never block a real,
|
|
1526
1568
|
// already-signed run: caught and named loudly, never rethrown.
|
|
1527
1569
|
// `deps.runlistHome` — the same injectable test seam `ctx.deps` already
|
|
1528
1570
|
// carries everything else through (env/out/err/provider/…): a caller that
|
|
@@ -1531,10 +1573,13 @@ async function execute(ctx) {
|
|
|
1531
1573
|
// When the monthly claim above already appended this run's row, there is nothing to add.
|
|
1532
1574
|
try {
|
|
1533
1575
|
if (!claimed) {
|
|
1576
|
+
// a RESUMED run is already listed: its row stays ONE row and this leg's claim is a `leg-start` beside it
|
|
1577
|
+
// (the row is added only when the list has none)
|
|
1534
1578
|
appendRun(runRow, { home: deps.runlistHome });
|
|
1579
|
+
if (LEG > 1) appendLegStart({ type: 'leg-start', runid, leg: LEG, pid: process.pid, capUsd: legCapUsd, at: new Date().toISOString() }, { home: deps.runlistHome });
|
|
1535
1580
|
// no limit set: the row is appended here, not by a claim — mark it so the execute catch releases it
|
|
1536
1581
|
// the same way if the run exits before its spine has a first record (it lives in `deps.runlistHome`)
|
|
1537
|
-
claimedRow = { runid, spine: spineFile, home: deps.runlistHome };
|
|
1582
|
+
claimedRow = { runid, spine: spineFile, home: deps.runlistHome, leg: LEG };
|
|
1538
1583
|
}
|
|
1539
1584
|
} catch (/** @type {any} */ e) {
|
|
1540
1585
|
err(`WARNING: could not add this run to ~/.config/bareloop/runs.jsonl (${e.message}) — the run continues; the panel's list will be missing this row.`);
|
|
@@ -1689,7 +1734,30 @@ async function execute(ctx) {
|
|
|
1689
1734
|
// here (not inline at the `runJob` call below) so this pre-run reading lands
|
|
1690
1735
|
// on the run's own spine in the SAME seq order everything else does, rather
|
|
1691
1736
|
// than a second, disconnected spine writer.
|
|
1692
|
-
|
|
1737
|
+
// P5-R: a RESUME continues the halted run's own file. `seq` is monotonic per SPINE, so the emitter starts
|
|
1738
|
+
// from the highest `seq` already in it; a kill can have left a torn line with no trailing newline, so ONE
|
|
1739
|
+
// `\n` is written first and the torn bytes stay exactly as they are, alone on their own line (the spine is
|
|
1740
|
+
// append-only forever: nothing here edits or truncates an existing byte).
|
|
1741
|
+
let startSeq = 0;
|
|
1742
|
+
if (resuming) {
|
|
1743
|
+
for (const e of deadAllEvents) if (typeof e?.seq === 'number' && Number.isFinite(e.seq) && e.seq > startSeq) startSeq = e.seq;
|
|
1744
|
+
const fd = openSync(spineFile, 'r');
|
|
1745
|
+
try {
|
|
1746
|
+
const size = fstatSync(fd).size;
|
|
1747
|
+
if (size > 0) {
|
|
1748
|
+
const last = Buffer.alloc(1);
|
|
1749
|
+
readSync(fd, last, 0, 1, size - 1);
|
|
1750
|
+
if (last[0] !== 0x0a) appendFileSync(spineFile, '\n');
|
|
1751
|
+
}
|
|
1752
|
+
} finally { closeSync(fd); }
|
|
1753
|
+
}
|
|
1754
|
+
const emit = makeSpine(spineFile, { startSeq });
|
|
1755
|
+
// …and the leg's FIRST record is its marker, written BEFORE the outside watchdog is spawned: the watchdog
|
|
1756
|
+
// judges a run dead by its spine file going quiet, and a file untouched since the last leg would read as stale.
|
|
1757
|
+
if (resuming) {
|
|
1758
|
+
emit(LEG_RESUME, { leg: LEG, after: deadAfter, at: new Date().toISOString() });
|
|
1759
|
+
legBegun = true;
|
|
1760
|
+
}
|
|
1693
1761
|
|
|
1694
1762
|
// PRD item 33/M2, call site 3(a) — a source-door manifest, when this patient
|
|
1695
1763
|
// was PREPARED through `prepareSource` (`scripts/prep-source.mjs`), sits
|
|
@@ -1704,6 +1772,7 @@ async function execute(ctx) {
|
|
|
1704
1772
|
emit('destination-refused', { code: sourceManifest.code, detail: sourceManifest.stop });
|
|
1705
1773
|
emit('run-end', { outcome: 'escalated' });
|
|
1706
1774
|
err(`SOURCE-MANIFEST-RED — ${sourceManifest.stop}. See ${spineFile}`);
|
|
1775
|
+
releaseClaimedRow(); // a $0 refusal after the spine exists: no ghost row, no "at least" month
|
|
1707
1776
|
throw new ExitSignal(1);
|
|
1708
1777
|
}
|
|
1709
1778
|
/** the front door's per-run instance, or null when there is none (no
|
|
@@ -1722,6 +1791,7 @@ async function execute(ctx) {
|
|
|
1722
1791
|
emit('destination-refused', { code: dp.code, detail: dp.stop });
|
|
1723
1792
|
emit('run-end', { outcome: 'escalated' });
|
|
1724
1793
|
err(`DESTINATION-RED — ${dp.stop}. See ${spineFile}`);
|
|
1794
|
+
releaseClaimedRow(); // a $0 refusal after the spine exists: no ghost row, no "at least" month
|
|
1725
1795
|
throw new ExitSignal(1);
|
|
1726
1796
|
}
|
|
1727
1797
|
}
|
|
@@ -1740,6 +1810,7 @@ async function execute(ctx) {
|
|
|
1740
1810
|
});
|
|
1741
1811
|
emit('run-end', { outcome: 'escalated' });
|
|
1742
1812
|
err(`CLOSE-TIMING-RED — stage(s) never finished the timing preflight: ${names}. See ${spineFile}`);
|
|
1813
|
+
releaseClaimedRow(); // a $0 refusal after the spine exists: no ghost row, no "at least" month
|
|
1743
1814
|
throw new ExitSignal(1);
|
|
1744
1815
|
}
|
|
1745
1816
|
const RESOLVED_CLOSE_TIMEOUT_MS = /** @type {number} */ (closeTimingResolved.closeTimeoutMs);
|
|
@@ -1776,6 +1847,8 @@ async function execute(ctx) {
|
|
|
1776
1847
|
// time on exactly the run it is watching (F70's shape).
|
|
1777
1848
|
...(RESUME_WALL_MS === null ? [] : ['--wall-ms', String(RESUME_WALL_MS)]),
|
|
1778
1849
|
'--grace-ms', String(worstCloseSilenceMs),
|
|
1850
|
+
// this leg's own note file (leg 1 keeps the name every run has always had)
|
|
1851
|
+
'--report', watchdogNotePath(spineFile, LEG),
|
|
1779
1852
|
], { stdio: ['ignore', 'ignore', 'inherit'] });
|
|
1780
1853
|
// A spawn failure arrives as an EVENT, and an unhandled 'error' on a ChildProcess is
|
|
1781
1854
|
// an uncaught exception — i.e. a guard that could not start would END the paid run it
|
|
@@ -1812,9 +1885,16 @@ async function execute(ctx) {
|
|
|
1812
1885
|
lagDue = now + LAG_POLL_MS;
|
|
1813
1886
|
}, LAG_POLL_MS);
|
|
1814
1887
|
|
|
1888
|
+
// P5 item 5 — the STOP request file (`<spine>.stop`, src/legs.js). A request belongs to ONE leg: one left on
|
|
1889
|
+
// disk by an earlier leg (a click that raced that leg's end) is cleared HERE, before this leg's first step, so
|
|
1890
|
+
// a resume is never stopped at once by a request it did not receive. The engine also consumes the file when it
|
|
1891
|
+
// honours it, and the `finally` below clears whatever is left when the leg ends.
|
|
1892
|
+
const stopFile = stopFilePath(spineFile);
|
|
1893
|
+
try { rmSync(stopFile, { force: true }); } catch { /* best-effort — a stop surface must never kill the run */ }
|
|
1815
1894
|
let outcome;
|
|
1816
1895
|
try {
|
|
1817
1896
|
outcome = await runJob(spec, {
|
|
1897
|
+
stopFile,
|
|
1818
1898
|
// PRD item 27/M3 — the SAME `emit` the pre-run close-timing reading above
|
|
1819
1899
|
// used (never a second `makeSpine(spineFile)` here: two independent
|
|
1820
1900
|
// emitters against one file would both start their seq counter at 0 and
|
|
@@ -1903,6 +1983,7 @@ async function execute(ctx) {
|
|
|
1903
1983
|
// the guard outlives the run only by accident, never by design
|
|
1904
1984
|
try { watchdog.kill('SIGKILL'); } catch { /* already gone */ }
|
|
1905
1985
|
clearInterval(lagTimer);
|
|
1986
|
+
try { rmSync(stopFile, { force: true }); } catch { /* best-effort */ }
|
|
1906
1987
|
}
|
|
1907
1988
|
const legMs = Date.now() - started;
|
|
1908
1989
|
|
|
@@ -1910,7 +1991,11 @@ async function execute(ctx) {
|
|
|
1910
1991
|
// honest. No classification into pass/fail buckets — one run classifies nothing.
|
|
1911
1992
|
const raw = readFileSync(spineFile, 'utf8');
|
|
1912
1993
|
// tolerant: a torn/garbled line is skipped, not a crash (the shipped bundle-run fix, now the one engine read)
|
|
1913
|
-
const
|
|
1994
|
+
const allEvents = parseJsonl(spineFile).records;
|
|
1995
|
+
// P5-R: the readout below is about THIS leg — its own rounds, its own halts, its own terminal. A resumed run's
|
|
1996
|
+
// earlier legs are on the same file, and reading them here would count their rounds, tokens and halts again
|
|
1997
|
+
// (the run's chain-wide figures are the folded ones the lines below already print).
|
|
1998
|
+
const events = legsOf(allEvents).at(-1)?.records ?? allEvents;
|
|
1914
1999
|
const je = events.findLast((e) => e.type === 'job-end');
|
|
1915
2000
|
// The claim's WRITE-BACK (hamr 2026-09-30): when the run ends it settles its own claim on the monthly
|
|
1916
2001
|
// limit — its final spend, attributed to itself. Best-effort: a run that dies before here is settled
|
|
@@ -1918,7 +2003,7 @@ async function execute(ctx) {
|
|
|
1918
2003
|
if (je) {
|
|
1919
2004
|
try {
|
|
1920
2005
|
const fin = legSpend(events);
|
|
1921
|
-
appendRunEvent({ runid, type: 'settled', by: runid, spentUsd: fin.usd, spendComplete: fin.complete, at: new Date().toISOString() }, { home: cfgHome });
|
|
2006
|
+
appendRunEvent({ runid, type: 'settled', by: runid, spentUsd: fin.usd, spendComplete: fin.complete, at: new Date().toISOString(), ...(LEG > 1 ? { leg: LEG } : {}) }, { home: cfgHome });
|
|
1922
2007
|
} catch (/** @type {any} */ e) { err(`WARNING: could not settle this run's claim in ~/.config/bareloop/runs.jsonl (${e.message}) — the next run settles it once this process is gone.`); }
|
|
1923
2008
|
}
|
|
1924
2009
|
// the ONE spelling of the text-side scan (src/validate.js) — a hand-rolled copy
|
|
@@ -1930,8 +2015,21 @@ async function execute(ctx) {
|
|
|
1930
2015
|
|
|
1931
2016
|
const auditSrc = join(wd, 'gate-audit.jsonl');
|
|
1932
2017
|
let auditFile = null;
|
|
1933
|
-
|
|
1934
|
-
|
|
2018
|
+
// THIS leg's own audit rows — the readout's `writes` and BEHAVIOUR lines are about this leg, read from the
|
|
2019
|
+
// leg's own tree file before it is filed
|
|
2020
|
+
/** @type {any[]} */
|
|
2021
|
+
let audit = [];
|
|
2022
|
+
if (existsSync(auditSrc)) {
|
|
2023
|
+
const legAuditText = readFileSync(auditSrc, 'utf8');
|
|
2024
|
+
audit = legAuditText.trimEnd().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
2025
|
+
auditFile = ctx.bundle ? join(spineDir, 'gate-audit.jsonl') : join(spineDir, `u-${runid}-gate-audit.jsonl`);
|
|
2026
|
+
// P5-R: a resumed leg ADDS to the run's one audit file (a rename would overwrite the earlier legs' rows)
|
|
2027
|
+
if (LEG > 1 && existsSync(auditFile)) {
|
|
2028
|
+
const prior = readFileSync(auditFile, 'utf8');
|
|
2029
|
+
appendFileSync(auditFile, `${prior.length > 0 && !prior.endsWith('\n') ? '\n' : ''}${legAuditText}`);
|
|
2030
|
+
unlinkSync(auditSrc);
|
|
2031
|
+
} else renameSync(auditSrc, auditFile);
|
|
2032
|
+
}
|
|
1935
2033
|
const writes = audit.filter((e) => e.decision === 'allow' && (e.action?.type === 'write' || e.action?.type === 'edit'));
|
|
1936
2034
|
|
|
1937
2035
|
out(`\noutcome ${outcome}`);
|
|
@@ -1991,10 +2089,16 @@ async function execute(ctx) {
|
|
|
1991
2089
|
: mh.trend === 'flat' ? 'more money is unlikely to help; this bundle\'s goal is signed and cannot be revised here'
|
|
1992
2090
|
: 'read the last close output before deciding whether more money is worth it';
|
|
1993
2091
|
out(` lever ${lever}`);
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
2092
|
+
if (spentUsd !== null && spentUsd >= signed) {
|
|
2093
|
+
// the whole signed ceiling is spent: "more than spent, at most signed" is unsatisfiable, so no resume line is printed
|
|
2094
|
+
out(` · no room left under the signed ceiling: this run spent all of the signed $${signed}, and a bundle's ceiling cannot be raised`);
|
|
2095
|
+
out(' · abandon the task');
|
|
2096
|
+
} else {
|
|
2097
|
+
out(` · choose a larger --budget on the resume line below (the room left is the signed $${signed}; it cannot be raised past that)`);
|
|
2098
|
+
out(' · abandon the task');
|
|
2099
|
+
out(` resume ${ctx.bundle.invoke}${WALL_FLAG} --resume ${runid}${SHIM_TAIL}${SCOUT_TAIL}${DRAFT_TAIL}${REGISTRY_TAIL} --budget <more than ${spentTxt}, at most $${signed}> --approve ${PRINT_APPROVE}`);
|
|
2100
|
+
out(' (type the --budget number yourself — nothing here fills it in, so this line does not run as printed; nothing in the run may widen its own budget)');
|
|
2101
|
+
}
|
|
1998
2102
|
} else {
|
|
1999
2103
|
out(` lever ${mh.lever}`);
|
|
2000
2104
|
for (const o of mh.options ?? []) out(` · ${o}`);
|
|
@@ -2128,9 +2232,8 @@ async function execute(ctx) {
|
|
|
2128
2232
|
// FOLDED across a resume, like every other money and time number this readout
|
|
2129
2233
|
// prints: the row records what the RUN cost, never what this leg cost
|
|
2130
2234
|
wallMs: legMs + (dead ? dead.restart.priorWallMs : 0),
|
|
2131
|
-
// the ROUND count
|
|
2132
|
-
//
|
|
2133
|
-
// this is a FLOOR, and it says so the way the money above does (F6: a floor that
|
|
2235
|
+
// the ROUND count is not part of the declared fold — a resumed leg's `job-start` declares
|
|
2236
|
+
// money and wall but never rounds — so on a resume this is THIS LEG's count, a FLOOR, and it says so the way the money above does (F6: a floor that
|
|
2134
2237
|
// reads as exact is dishonest; report the floor WITH its completeness)
|
|
2135
2238
|
rounds: legRounds,
|
|
2136
2239
|
roundsComplete: !dead,
|
|
@@ -2270,9 +2373,7 @@ async function execute(ctx) {
|
|
|
2270
2373
|
// owns no spine, so its row is released (folded out of the list, like a monthly refusal) rather than
|
|
2271
2374
|
// left as a ghost that keeps the month reading "at least". Once the spine exists the run is a real,
|
|
2272
2375
|
// listed run and ends by its own job-end. A failed release write never masks the original exit.
|
|
2273
|
-
if (claimedRow && !existsSync(claimedRow.spine))
|
|
2274
|
-
try { appendRunEvent({ runid: claimedRow.runid, type: 'released', by: claimedRow.runid, reason: 'not started', at: new Date().toISOString() }, { home: claimedRow.home }); } catch { /* the exit stands; a stranded row of a dead process is closed by the next run */ }
|
|
2275
|
-
}
|
|
2376
|
+
if (claimedRow && (claimedRow.leg > 1 ? !legBegun : !existsSync(claimedRow.spine))) releaseClaimedRow();
|
|
2276
2377
|
if (e instanceof ExitSignal) return e.code;
|
|
2277
2378
|
throw e;
|
|
2278
2379
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {object} DraftSpend
|
|
3
|
+
* @property {string} sessionId
|
|
4
|
+
* @property {number} spentUsd the known floor of the session's drafting spend
|
|
5
|
+
* @property {boolean} spendComplete false = `spentUsd` is a floor ("at least")
|
|
6
|
+
* @property {string|null} provider the worker's provider shape (`anthropic-api` | `openai-api` | ...)
|
|
7
|
+
* @property {string|null} baseUrl
|
|
8
|
+
* @property {string|null} model
|
|
9
|
+
* @property {string} startedAt ISO, the first metered call
|
|
10
|
+
* @property {string} updatedAt ISO, the latest metered call
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* (Re)write `<dir>/draft-spend.json` atomically (tmp + rename).
|
|
14
|
+
* @param {string} dir the session's folder
|
|
15
|
+
* @param {DraftSpend} rec
|
|
16
|
+
* @returns {void}
|
|
17
|
+
*/
|
|
18
|
+
export function writeDraftSpend(dir: string, rec: DraftSpend): void;
|
|
19
|
+
/**
|
|
20
|
+
* Every session folder's drafting spend that never became a run.
|
|
21
|
+
* @param {{ home?: string, sessionsRoot?: string }} [opts] `home` = the bareloop config dir (as everywhere in src/monthly.js); the sessions live in `<home>/panel-sessions` unless `sessionsRoot` names them
|
|
22
|
+
* @returns {DraftSpend[]}
|
|
23
|
+
*/
|
|
24
|
+
export function readOrphanDraftSpends(opts?: {
|
|
25
|
+
home?: string;
|
|
26
|
+
sessionsRoot?: string;
|
|
27
|
+
}): DraftSpend[];
|
|
28
|
+
export const DRAFT_SPEND_FILE: "draft-spend.json";
|
|
29
|
+
export type DraftSpend = {
|
|
30
|
+
sessionId: string;
|
|
31
|
+
/**
|
|
32
|
+
* the known floor of the session's drafting spend
|
|
33
|
+
*/
|
|
34
|
+
spentUsd: number;
|
|
35
|
+
/**
|
|
36
|
+
* false = `spentUsd` is a floor ("at least")
|
|
37
|
+
*/
|
|
38
|
+
spendComplete: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* the worker's provider shape (`anthropic-api` | `openai-api` | ...)
|
|
41
|
+
*/
|
|
42
|
+
provider: string | null;
|
|
43
|
+
baseUrl: string | null;
|
|
44
|
+
model: string | null;
|
|
45
|
+
/**
|
|
46
|
+
* ISO, the first metered call
|
|
47
|
+
*/
|
|
48
|
+
startedAt: string;
|
|
49
|
+
/**
|
|
50
|
+
* ISO, the latest metered call
|
|
51
|
+
*/
|
|
52
|
+
updatedAt: string;
|
|
53
|
+
};
|
package/types/job.d.ts
CHANGED
|
@@ -93,6 +93,17 @@ export function checkMenu(close: any[]): {
|
|
|
93
93
|
* @returns {string} sha256 hex
|
|
94
94
|
*/
|
|
95
95
|
export function jobSpecHash(job: object): string;
|
|
96
|
+
/**
|
|
97
|
+
* The identity of a WORKFLOW (Reuse workflow, hamr 2026-10-03): sha256 over the signed spec WITHOUT the fields a
|
|
98
|
+
* reuse may change ({@link REUSE_OPEN_SPEC_FIELDS}) and the worker identity ({@link WORKFLOW_KEY_IDENTITY_FIELDS}). Two runs
|
|
99
|
+
* share a workflow key iff they ran the same goal, checks, guardrails and close — whatever source they ran on, wherever they wrote, whatever caps they had.
|
|
100
|
+
* It is NOT a signature: {@link jobSpecHash} (which covers the caps and the fence) stays what the person signs, and
|
|
101
|
+
* a reuse lands on a NEW jobSpecHash with the SAME workflowKey. Same never-throws contract as `jobSpecHash`, and
|
|
102
|
+
* the same resolved-`tools` form, so an omitted-`tools` spec and its spelled-out menu share a key.
|
|
103
|
+
* @param {object} job
|
|
104
|
+
* @returns {string} sha256 hex
|
|
105
|
+
*/
|
|
106
|
+
export function workflowKey(job: object): string;
|
|
96
107
|
/**
|
|
97
108
|
* Does an approval record cover this exact spec version? Pure predicate — the
|
|
98
109
|
* N2 runner refuses to run without it; nothing here writes or prompts. The
|
|
@@ -259,6 +270,20 @@ export const PROVIDERS: readonly string[];
|
|
|
259
270
|
* candidate at N3. `provider` is part of the key by definition (top-level,
|
|
260
271
|
* not duplicated here). */
|
|
261
272
|
export const CONDITION_KEYS: readonly string[];
|
|
273
|
+
/**
|
|
274
|
+
* The fields a "Reuse workflow" run may change: where the work lands (the write fence, `writeScope`, and a
|
|
275
|
+
* `destination`/`source` where a spec ever carries them) and the two ceilings. Everything else in a signed spec is
|
|
276
|
+
* the WORKFLOW — goal, checks, guardrails, close (the worker model is the third kind, {@link WORKFLOW_KEY_IDENTITY_FIELDS}). ONE list, shared by {@link workflowKey} and by the panel's
|
|
277
|
+
* reuse (so what the key ignores is exactly what the person may edit, never two spellings).
|
|
278
|
+
*/
|
|
279
|
+
export const REUSE_OPEN_SPEC_FIELDS: readonly string[];
|
|
280
|
+
/**
|
|
281
|
+
* The worker-identity fields {@link workflowKey} ALSO ignores (hamr 2026-10-04: Model is open on a reuse; the Name
|
|
282
|
+
* chosen in Settings carries its provider and baseUrl with it). They stay inside {@link jobSpecHash}, so what the person
|
|
283
|
+
* signs still covers the model the run will use. Kept apart from {@link REUSE_OPEN_SPEC_FIELDS} on purpose: those are the
|
|
284
|
+
* fields the page's boxes edit directly; these three change together, as one Settings row.
|
|
285
|
+
*/
|
|
286
|
+
export const WORKFLOW_KEY_IDENTITY_FIELDS: readonly string[];
|
|
262
287
|
/**
|
|
263
288
|
* — `verb` and `lib` ride request-reds as structured data (the ledger keys on them, never on prose).
|
|
264
289
|
* `lib` is the TERRITORY the demand lands against, stamped HERE at the emit site
|
package/types/judged.d.ts
CHANGED
|
@@ -229,7 +229,7 @@ export function locatePrompt(card: any): string;
|
|
|
229
229
|
* On every route out, red or clean, `onCost` fires exactly once with the honest
|
|
230
230
|
* read: a paid call that leaves no meter record is F12 wearing a judge's coat.
|
|
231
231
|
* @param {{artifactText: string, card: any, loopFactory: (o: {provider?: any, system: string}) => any,
|
|
232
|
-
* maxTokens?: number, attempt?: number, onCost?: (c: {costUsd: number|null, unpricedRounds: number}) => void,
|
|
232
|
+
* maxTokens?: number, attempt?: number, onCost?: (c: {costUsd: number|null, unpricedRounds: number, rateSource?: string|null}) => void,
|
|
233
233
|
* callBounds?: {timeoutMs?: number, deadlineMs?: number}}} o
|
|
234
234
|
* @returns {Promise<{ok: boolean, facts: any|null, red: {axis: string, detail: string}|null,
|
|
235
235
|
* costUsd: number|null, unpricedRounds: number, truncated: boolean, parseError: boolean,
|
|
@@ -247,6 +247,7 @@ export function runLocate({ artifactText, card, loopFactory, maxTokens, attempt,
|
|
|
247
247
|
onCost?: (c: {
|
|
248
248
|
costUsd: number | null;
|
|
249
249
|
unpricedRounds: number;
|
|
250
|
+
rateSource?: string | null;
|
|
250
251
|
}) => void;
|
|
251
252
|
callBounds?: {
|
|
252
253
|
timeoutMs?: number;
|
|
@@ -389,7 +390,9 @@ export function defaultJudgeLoop({ provider, system, rates }: {
|
|
|
389
390
|
in: number;
|
|
390
391
|
out: number;
|
|
391
392
|
} | null;
|
|
392
|
-
}):
|
|
393
|
+
}): {
|
|
394
|
+
run: (msgs: any[], tools: any[], opts?: any) => Promise<any>;
|
|
395
|
+
};
|
|
393
396
|
export type RuleRed = {
|
|
394
397
|
fn: string;
|
|
395
398
|
why: string;
|
package/types/ledger.d.ts
CHANGED
|
@@ -95,6 +95,50 @@ export function floorsFromRecords(records: any[]): {
|
|
|
95
95
|
spendFloorUsd: number | null;
|
|
96
96
|
wallFloorMs: number | null;
|
|
97
97
|
};
|
|
98
|
+
/**
|
|
99
|
+
* One run's own spend, off its spine records: this LEG's figure (never the chain fold),
|
|
100
|
+
* plus the drafting spend the run was signed with (counted once — on the first leg only).
|
|
101
|
+
* @param {any[]} records ONE leg's records
|
|
102
|
+
* @param {{ withDraft?: boolean }} [o] `withDraft: false` leaves the drafting spend out (a caller folding the CHAIN's own spend, which never held it)
|
|
103
|
+
* @returns {{ usd: number, complete: boolean }}
|
|
104
|
+
*/
|
|
105
|
+
export function legSpend(records: any[], { withDraft }?: {
|
|
106
|
+
withDraft?: boolean;
|
|
107
|
+
}): {
|
|
108
|
+
usd: number;
|
|
109
|
+
complete: boolean;
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* A WHOLE RUN's own spend (P5-R: a resumed run is one spine of several legs): the sum of each leg's own
|
|
113
|
+
* figure ({@link legSpend}), never the declared chain fold. ONE basis — every leg's own rounds — so a leg is
|
|
114
|
+
* counted once: the last leg's `job-end.spentUsd` also carries the earlier legs, and adding it to the
|
|
115
|
+
* earlier legs' own figures would count them twice. A leg with no terminal (it died) is a floor, so the run
|
|
116
|
+
* is `complete: false`. A spine with no marker is one leg and reads exactly as {@link legSpend} always did.
|
|
117
|
+
* @param {any[]} records one spine's records
|
|
118
|
+
* @param {{ withDraft?: boolean }} [o]
|
|
119
|
+
* @returns {{ usd: number, complete: boolean, lastLegUsd: number }} `lastLegUsd`: the latest leg's own share (what a claim still held for it is measured against)
|
|
120
|
+
*/
|
|
121
|
+
export function runSpend(records: any[], o?: {
|
|
122
|
+
withDraft?: boolean;
|
|
123
|
+
}): {
|
|
124
|
+
usd: number;
|
|
125
|
+
complete: boolean;
|
|
126
|
+
lastLegUsd: number;
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* What a FINISHED run's whole chain spent, for a caller that folds it forward (the review door's rerun opens a
|
|
130
|
+
* new run under the same signed ceiling). One basis per shape: a spine of ONE leg is read by its terminal's own
|
|
131
|
+
* chain total (`job-end.spentUsd`, which already carries whatever fold that leg inherited — what every door has
|
|
132
|
+
* always read); a resumed run's several legs are the first leg's own inherited fold (spend the file does not
|
|
133
|
+
* hold) plus each leg's OWN spend, so a later leg's terminal total — which also carries the earlier legs — is
|
|
134
|
+
* never added on top of them. Not finite / no terminal = `usd: 0, complete: false` (never an exact-looking 0).
|
|
135
|
+
* @param {any[]} records one spine's records
|
|
136
|
+
* @returns {{ usd: number, complete: boolean }}
|
|
137
|
+
*/
|
|
138
|
+
export function chainSpend(records: any[]): {
|
|
139
|
+
usd: number;
|
|
140
|
+
complete: boolean;
|
|
141
|
+
};
|
|
98
142
|
/** severity order, worst-first (the fold's display order; frequency ranks within) */
|
|
99
143
|
export const LEDGER_CLASSES: readonly string[];
|
|
100
144
|
/**
|