bareloop 0.34.0 → 0.36.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 +58 -0
- package/bareloop.context.md +276 -20
- package/package.json +1 -1
- package/src/authorrun.js +3 -2
- package/src/bundlerun.js +17 -12
- package/src/draftspend.js +197 -0
- package/src/exits.js +10 -1
- package/src/index.js +3 -3
- package/src/interviewrun.js +7 -7
- package/src/job.js +37 -0
- package/src/judged.js +2 -2
- package/src/kinds.js +2 -1
- package/src/ledger.js +101 -5
- package/src/legs.js +171 -0
- package/src/monthly.js +111 -72
- package/src/movefile.js +22 -0
- package/src/npminstall.js +55 -0
- package/src/panel/authorroutes.js +84 -14
- package/src/panel/authorsession.js +490 -125
- package/src/panel/importroutes.js +359 -0
- package/src/panel/importrun.js +203 -0
- package/src/panel/index.html +1194 -213
- package/src/panel/runroutes.js +219 -0
- package/src/panel/server.js +964 -79
- package/src/panel/settingsroutes.js +52 -2
- package/src/panel/status.js +51 -0
- package/src/planrun.js +60 -26
- package/src/providerrows.js +17 -0
- package/src/ralph.js +4 -1
- package/src/replay.js +139 -19
- package/src/replayio.js +11 -4
- package/src/reuse.js +20 -3
- package/src/run.js +6 -3
- package/src/runlist.js +75 -7
- package/src/source.js +43 -4
- package/src/text.js +20 -0
- package/src/u-patient.js +3 -2
- package/src/u-readout.js +9 -1
- package/src/u-watchdog.mjs +4 -2
- package/src/userrun.js +192 -65
- package/src/worktree.js +120 -0
- package/types/draftspend.d.ts +141 -0
- package/types/job.d.ts +25 -0
- package/types/ledger.d.ts +44 -0
- package/types/legs.d.ts +103 -0
- package/types/monthly.d.ts +23 -18
- package/types/movefile.d.ts +9 -0
- package/types/npminstall.d.ts +30 -0
- package/types/panel/authorroutes.d.ts +23 -1
- package/types/panel/authorsession.d.ts +110 -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 +266 -2
- package/types/panel/settingsroutes.d.ts +6 -1
- package/types/panel/status.d.ts +51 -0
- package/types/planrun.d.ts +13 -21
- package/types/providerrows.d.ts +12 -0
- package/types/ralph.d.ts +19 -0
- package/types/replay.d.ts +25 -1
- package/types/replayio.d.ts +2 -2
- package/types/reuse.d.ts +5 -0
- package/types/run.d.ts +6 -2
- package/types/runlist.d.ts +52 -2
- package/types/source.d.ts +13 -5
- package/types/text.d.ts +19 -0
- package/types/userrun.d.ts +10 -0
- package/types/worktree.d.ts +48 -0
package/src/replay.js
CHANGED
|
@@ -103,7 +103,8 @@
|
|
|
103
103
|
|
|
104
104
|
import { runBehaviour, formatBehaviour } from './behaviour.js';
|
|
105
105
|
import { shortSha } from './codeversion.js';
|
|
106
|
-
import { SPEND_RECORD_TYPES } from './ledger.js';
|
|
106
|
+
import { SPEND_RECORD_TYPES, chainSpend } from './ledger.js';
|
|
107
|
+
import { legsOf } from './legs.js';
|
|
107
108
|
|
|
108
109
|
/**
|
|
109
110
|
* @param {unknown} e
|
|
@@ -167,6 +168,24 @@ export function auditWindow(jobStart, jobEnd) {
|
|
|
167
168
|
};
|
|
168
169
|
}
|
|
169
170
|
|
|
171
|
+
/**
|
|
172
|
+
* One window PER LEG of a (possibly resumed) run (P5-R) — the ONE owner the panel and {@link replayRun} share. A
|
|
173
|
+
* run with no `leg-resume` marker is one leg and this is `[auditWindow(jobStart, jobEnd)]`, exactly as before. A
|
|
174
|
+
* resumed run's quiet time between two legs is nobody's, so a row another run wrote into a shared sidecar during it
|
|
175
|
+
* is outside every window; a leg that recorded no terminal (it died) ends where the next leg begins.
|
|
176
|
+
* @param {any[]} spineRecords
|
|
177
|
+
* @returns {{startTs: number, endTs: number}[]}
|
|
178
|
+
*/
|
|
179
|
+
export function auditWindowsOf(spineRecords) {
|
|
180
|
+
const legs = legsOf(spineRecords);
|
|
181
|
+
if (legs.length <= 1) {
|
|
182
|
+
const jobStart = spineRecords.find((e) => e && e.type === 'job-start') ?? null;
|
|
183
|
+
const jobEnd = legs.at(-1)?.jobEnd ?? null;
|
|
184
|
+
return [auditWindow(jobStart, jobEnd)];
|
|
185
|
+
}
|
|
186
|
+
return legs.map((l, i) => auditWindow(l.jobStart ?? l.start, l.jobEnd ?? (legs[i + 1]?.start ? { ts: legs[i + 1].start.ts } : null)));
|
|
187
|
+
}
|
|
188
|
+
|
|
170
189
|
/**
|
|
171
190
|
* Trim a string to `max` chars with an explicit `…[+N chars]` marker —
|
|
172
191
|
* NEVER a silent truncation, and never a trailing ellipsis alone
|
|
@@ -422,8 +441,14 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
|
|
|
422
441
|
else skipped += 1;
|
|
423
442
|
}
|
|
424
443
|
|
|
444
|
+
// P5-R: a resumed run is ONE spine of several legs (src/legs.js `legsOf`, the one owner of the boundaries).
|
|
445
|
+
// `jobStart` is the run's FIRST (its start, its drafting fold); `latestStart` the latest leg's (the caps it ran
|
|
446
|
+
// under now); `jobEnd` the run's terminal — the LAST leg's, never an earlier leg's. A spine with no marker is
|
|
447
|
+
// one leg and every figure below reads exactly as it always did.
|
|
448
|
+
const legs = legsOf(spine);
|
|
425
449
|
const jobStart = spine.find((e) => e.type === 'job-start') ?? null;
|
|
426
|
-
const
|
|
450
|
+
const latestStart = legs.at(-1)?.jobStart ?? jobStart;
|
|
451
|
+
const jobEnd = legs.at(-1)?.jobEnd ?? null;
|
|
427
452
|
|
|
428
453
|
// F195 fix: a gate-audit sidecar CAN be shared across several runs whose
|
|
429
454
|
// spines happen to reuse the same filename (measured on a real archived
|
|
@@ -441,10 +466,10 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
|
|
|
441
466
|
// (`src/panel/server.js`) calls this same {@link auditWindow} rather than
|
|
442
467
|
// keeping its own copy.
|
|
443
468
|
{
|
|
444
|
-
const
|
|
469
|
+
const windows = auditWindowsOf(spine);
|
|
445
470
|
audit = audit.filter((a) => {
|
|
446
471
|
const t = parseTs(a.ts);
|
|
447
|
-
return t !== null && t >= startTs && t <= endTs;
|
|
472
|
+
return t !== null && windows.some((w) => t >= w.startTs && t <= w.endTs);
|
|
448
473
|
});
|
|
449
474
|
}
|
|
450
475
|
|
|
@@ -460,8 +485,12 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
|
|
|
460
485
|
// and the wall-halt sites (src/planrun.js:2990,3208-3209).
|
|
461
486
|
const stopReason = isGreen ? null : buildStopReason(lastEscalation, outcome);
|
|
462
487
|
|
|
463
|
-
|
|
464
|
-
|
|
488
|
+
// MONEY, one basis (P5-R): a one-leg spine reads its terminal's own total as it always did; a resumed run's
|
|
489
|
+
// legs are summed by each leg's own spend (`chainSpend`), never the last terminal's chain total on top of them
|
|
490
|
+
const chain = legs.length > 1 && jobEnd ? chainSpend(spine) : null;
|
|
491
|
+
const spentUsd = chain ? chain.usd
|
|
492
|
+
: (jobEnd && typeof jobEnd.spentUsd === 'number' && Number.isFinite(jobEnd.spentUsd) ? jobEnd.spentUsd : null);
|
|
493
|
+
const spendComplete = chain ? chain.complete : (jobEnd ? jobEnd.spendComplete === true && spentUsd !== null : false);
|
|
465
494
|
|
|
466
495
|
// draftSpentUsd (hamr's ruling 2026-09-28, "one cap covers drafting +
|
|
467
496
|
// run") — the AUTHORING pipeline's own spend on this job before it was
|
|
@@ -480,9 +509,28 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
|
|
|
480
509
|
// exact then, the field simply didn't exist yet to say so.
|
|
481
510
|
const draftSpendComplete = draftSpentUsd === null ? null
|
|
482
511
|
: (jobStart.draftSpendComplete !== false);
|
|
483
|
-
const floorReasonList = floorReasons(spine, jobStart, spendComplete);
|
|
484
|
-
|
|
485
|
-
|
|
512
|
+
const floorReasonList = floorReasons(spine, legs.map((l) => l.jobStart).find((j) => j?.priorSpendComplete === false) ?? jobStart, spendComplete);
|
|
513
|
+
|
|
514
|
+
// WALL: the sum of each leg's own working window, the gap between legs excluded (hamr 2026-10-02, 2A). A
|
|
515
|
+
// leg that died has no terminal, so its window ends at its last record (a floor); the LATEST leg without a
|
|
516
|
+
// terminal is unknown, as a one-leg run without one always was.
|
|
517
|
+
/** @param {import('./legs.js').Leg} l @param {boolean} isLast */
|
|
518
|
+
const legWallMs = (l, isLast) => {
|
|
519
|
+
const a = parseTs((l.jobStart ?? l.start)?.ts);
|
|
520
|
+
const b = l.jobEnd ? parseTs(l.jobEnd.ts) : (isLast ? null : parseTs(l.end?.ts));
|
|
521
|
+
return windowWallMs(a, b);
|
|
522
|
+
};
|
|
523
|
+
const wallMs = legs.length > 1
|
|
524
|
+
? (() => {
|
|
525
|
+
let sum = 0;
|
|
526
|
+
for (let i = 0; i < legs.length; i += 1) {
|
|
527
|
+
const w = legWallMs(legs[i], i === legs.length - 1);
|
|
528
|
+
if (w === null) return null;
|
|
529
|
+
sum += w;
|
|
530
|
+
}
|
|
531
|
+
return sum;
|
|
532
|
+
})()
|
|
533
|
+
: windowWallMs(parseTs(jobStart?.ts), parseTs(jobEnd?.ts));
|
|
486
534
|
|
|
487
535
|
// chainClock: the SIGNED wall, read off the run's own clock record —
|
|
488
536
|
// src/planrun.js:1203 (`wall-clock`) or `emitWallHalt` (src/planrun.js:1269)
|
|
@@ -556,7 +604,7 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
|
|
|
556
604
|
// record at all (resumed before any plan reload was needed); keying
|
|
557
605
|
// "resumed" on resume-seed alone would have printed `resumed: no` on
|
|
558
606
|
// genuinely resumed runs and mis-explained their spend.
|
|
559
|
-
const resumed = !!jobStart && 'priorSpentUsd' in jobStart;
|
|
607
|
+
const resumed = legs.length > 1 || (!!jobStart && 'priorSpentUsd' in jobStart);
|
|
560
608
|
|
|
561
609
|
// resumeSeed: the DETAIL fields (phase/completed/skipping/divergence) —
|
|
562
610
|
// present only on the narrower resume-seed record; `resumed` above stays
|
|
@@ -582,7 +630,8 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
|
|
|
582
630
|
const totalRoundsInFile = spendRecords.length;
|
|
583
631
|
const unpricedRoundsInFile = spendRecords.filter((r) => !(typeof r.costUsd === 'number' && Number.isFinite(r.costUsd))).length;
|
|
584
632
|
const summedRounds = spendRecords.reduce((acc, r) => (typeof r.costUsd === 'number' && Number.isFinite(r.costUsd) ? acc + r.costUsd : acc), 0);
|
|
585
|
-
|
|
633
|
+
// (a resumed run's terminal carries only its LATEST leg's engagement — its file's figure is the sum of the rounds)
|
|
634
|
+
const hasEngagementField = legs.length <= 1 && jobEnd && typeof jobEnd.engagementSpentUsd === 'number' && Number.isFinite(jobEnd.engagementSpentUsd);
|
|
586
635
|
const thisFileSpend = {
|
|
587
636
|
value: unpricedRoundsInFile > 0 ? null : (hasEngagementField ? jobEnd.engagementSpentUsd : summedRounds),
|
|
588
637
|
source: /** @type {'engagementSpentUsd'|'summed rounds'} */ (hasEngagementField ? 'engagementSpentUsd' : 'summed rounds'),
|
|
@@ -664,18 +713,38 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
|
|
|
664
713
|
|
|
665
714
|
const usedEndSeqs = new Set();
|
|
666
715
|
const idOccurrence = new Map();
|
|
716
|
+
/** @type {Map<string, {stopReason: string|null, legIdx: number, tryNumber: number}>} */
|
|
717
|
+
const prevOccurrence = new Map();
|
|
667
718
|
|
|
668
719
|
const steps = stepStarts.map((ss) => {
|
|
669
720
|
const id = ss.step;
|
|
670
721
|
const startSeq = typeof ss.seq === 'number' ? ss.seq : -Infinity;
|
|
671
722
|
const startTs = parseTs(ss.ts);
|
|
672
723
|
|
|
724
|
+
// P5-R: a step belongs to ONE leg. Its `step-end` is the first one inside ITS OWN leg (never a later leg's
|
|
725
|
+
// `step-end` for the same id), and a step its leg's halt cut off ends where that leg ends — otherwise its
|
|
726
|
+
// window runs across the idle gap to the next leg and counts the gap as its time.
|
|
727
|
+
const myLegIdx = legs.length > 1 ? legs.reduce((acc, l, i) => (typeof l.start?.seq === 'number' && startSeq >= l.start.seq ? i : acc), 0) : 0;
|
|
728
|
+
const myLeg = legs.length > 1 ? legs[myLegIdx] : null;
|
|
729
|
+
const legEndSeq = legs.length > 1 && myLegIdx < legs.length - 1 && typeof legs[myLegIdx + 1].start?.seq === 'number' ? legs[myLegIdx + 1].start.seq : Infinity;
|
|
673
730
|
const end = stepEnds
|
|
674
|
-
.filter((se) => se.step === id && typeof se.seq === 'number' && se.seq >= startSeq && !usedEndSeqs.has(se.seq))
|
|
731
|
+
.filter((se) => se.step === id && typeof se.seq === 'number' && se.seq >= startSeq && se.seq < legEndSeq && !usedEndSeqs.has(se.seq))
|
|
675
732
|
.sort((a, b) => a.seq - b.seq)[0] ?? null;
|
|
676
733
|
if (end) usedEndSeqs.add(end.seq);
|
|
677
|
-
const
|
|
678
|
-
const
|
|
734
|
+
const cutByLeg = !end && myLeg !== null && Number.isFinite(legEndSeq);
|
|
735
|
+
const endSeq = end && typeof end.seq === 'number' ? end.seq : (cutByLeg ? legEndSeq : Infinity);
|
|
736
|
+
const endTs = end ? parseTs(end.ts) : (cutByLeg ? parseTs(myLeg?.end?.ts) : null);
|
|
737
|
+
// why this leg stopped the step (code-owned words); null for a step that ended itself or is still running
|
|
738
|
+
/** @type {string|null} */
|
|
739
|
+
let stopReason = null;
|
|
740
|
+
if (!end && myLeg !== null) {
|
|
741
|
+
const o = myLeg.outcome;
|
|
742
|
+
const isLastLeg = myLegIdx === legs.length - 1;
|
|
743
|
+
if (cutByLeg || (isLastLeg && o !== null && o !== 'green' && o !== 'already-green')) {
|
|
744
|
+
stopReason = o === 'cap-halt' ? 'money cap' : o === 'wall-halt' ? 'time cap' : o === 'provider-red' ? 'provider failed'
|
|
745
|
+
: o === 'step-stalled' ? 'step stalled' : o === 'stopped' ? 'you stopped it' : o === null ? 'died' : o;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
679
748
|
|
|
680
749
|
const phaseTag = `step:${id}`;
|
|
681
750
|
// `judge-round` (a close's paid seam) carries no `.phase` at all
|
|
@@ -753,10 +822,17 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
|
|
|
753
822
|
}
|
|
754
823
|
|
|
755
824
|
idOccurrence.set(id, (idOccurrence.get(id) ?? 0) + 1);
|
|
825
|
+
// the SAME step continuing across a leg boundary (the previous occurrence of this id was cut off by its leg's
|
|
826
|
+
// halt, and this one starts in a later leg) is "continued", never a retry; `tryNumber` counts real retries only
|
|
827
|
+
const prevOcc = prevOccurrence.get(id) ?? null;
|
|
828
|
+
const continued = myLeg !== null && prevOcc !== null && prevOcc.stopReason !== null && prevOcc.legIdx < myLegIdx;
|
|
829
|
+
const tryNumber = prevOcc === null ? 1 : (continued ? prevOcc.tryNumber : prevOcc.tryNumber + 1);
|
|
830
|
+
prevOccurrence.set(id, { stopReason, legIdx: myLegIdx, tryNumber });
|
|
756
831
|
|
|
757
832
|
return {
|
|
758
833
|
id,
|
|
759
834
|
occurrence: idOccurrence.get(id),
|
|
835
|
+
...(myLeg === null ? {} : { stopReason, continued, tryNumber }),
|
|
760
836
|
outcome: end ? (end.outcome ?? null) : null,
|
|
761
837
|
...buildOccurrenceMetrics(startSeq, startTs, endSeq, endTs, roundsInWindow),
|
|
762
838
|
checks: { passed, failed },
|
|
@@ -1241,7 +1317,10 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
|
|
|
1241
1317
|
spentUsd: step.spentUsd,
|
|
1242
1318
|
unpricedRounds: step.unpricedRounds,
|
|
1243
1319
|
outcome: step.outcome,
|
|
1244
|
-
|
|
1320
|
+
// P5-R (resumed runs only): why its leg's halt cut this step off, whether it is the same step continuing
|
|
1321
|
+
// from the leg before, and its retry count not counting continuations
|
|
1322
|
+
...(step.stopReason === undefined ? {} : { stopReason: step.stopReason, continued: step.continued, tryNumber: step.tryNumber }),
|
|
1323
|
+
attempts: step.stopReason ? stepAttempts.map((a) => (a.outcome === null || a.outcome === undefined ? { ...a, outcome: 'stopped' } : a)) : stepAttempts,
|
|
1245
1324
|
});
|
|
1246
1325
|
}
|
|
1247
1326
|
|
|
@@ -1321,6 +1400,32 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
|
|
|
1321
1400
|
}
|
|
1322
1401
|
}
|
|
1323
1402
|
|
|
1403
|
+
// P5-R: which LEG each part belongs to, and where a resume picked up — only for a resumed run (a spine with
|
|
1404
|
+
// no marker keeps its parts byte-for-byte). A step part is dated by its own step-start; a replan by its
|
|
1405
|
+
// marker; every other part by the end of its last attempt (its own window's far edge), falling back to the
|
|
1406
|
+
// near edge, then to leg 1. `resumedNext` marks the part a leg ENDED at: the next part is the leg after.
|
|
1407
|
+
if (legs.length > 1) {
|
|
1408
|
+
/** @param {number|null|undefined} seq */
|
|
1409
|
+
const legOfSeq = (seq) => {
|
|
1410
|
+
let n = 1;
|
|
1411
|
+
if (typeof seq !== 'number' || !Number.isFinite(seq)) return n;
|
|
1412
|
+
for (const l of legs) if (typeof l.start?.seq === 'number' && seq >= l.start.seq) n = l.leg;
|
|
1413
|
+
return n;
|
|
1414
|
+
};
|
|
1415
|
+
for (const p of parts) {
|
|
1416
|
+
const atts = Array.isArray(p.attempts) ? p.attempts : [];
|
|
1417
|
+
const finiteEnds = atts.map((a) => a.endSeq).filter((v) => typeof v === 'number' && Number.isFinite(v));
|
|
1418
|
+
const firstStart = atts.length ? atts[0].startSeq : null;
|
|
1419
|
+
if (p.kind === 'step') p.leg = legOfSeq(firstStart);
|
|
1420
|
+
else if (p.kind === 'replan' && typeof p.markerSeq === 'number') p.leg = legOfSeq(p.markerSeq);
|
|
1421
|
+
else if (finiteEnds.length) p.leg = legOfSeq(Math.max(...finiteEnds));
|
|
1422
|
+
else p.leg = legOfSeq(firstStart);
|
|
1423
|
+
}
|
|
1424
|
+
// legs never go backwards along the part list (a part is dated by file order)
|
|
1425
|
+
for (let i = 1; i < parts.length; i += 1) if (parts[i].leg < parts[i - 1].leg) parts[i].leg = parts[i - 1].leg;
|
|
1426
|
+
for (let i = 0; i < parts.length - 1; i += 1) if (parts[i + 1].leg > parts[i].leg) parts[i].resumedNext = true;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1324
1429
|
return {
|
|
1325
1430
|
runId,
|
|
1326
1431
|
job: jobStart?.job ?? null,
|
|
@@ -1359,8 +1464,9 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
|
|
|
1359
1464
|
// escalation happen at all"). `summarizeForAllLine`'s `reason` column
|
|
1360
1465
|
// needs the latter question answered unconditionally.
|
|
1361
1466
|
lastEscalation,
|
|
1362
|
-
|
|
1363
|
-
|
|
1467
|
+
// the caps and the spec the run's LATEST leg ran under (a resume may run under a re-signed hash)
|
|
1468
|
+
budgetUsd: latestStart && typeof latestStart.budgetUsd === 'number' && Number.isFinite(latestStart.budgetUsd) ? latestStart.budgetUsd : null,
|
|
1469
|
+
specHash: typeof latestStart?.specHash === 'string' ? latestStart.specHash : null,
|
|
1364
1470
|
branch,
|
|
1365
1471
|
outcome,
|
|
1366
1472
|
stopReason,
|
|
@@ -1375,6 +1481,17 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
|
|
|
1375
1481
|
chainClock,
|
|
1376
1482
|
resumed,
|
|
1377
1483
|
resumeSeed,
|
|
1484
|
+
// P5-R — the run's legs in order (always at least one for a spine with records): the panel's Audit divider and
|
|
1485
|
+
// map connector read these, never re-derive a boundary. `startSeq`/`endSeq` bound the leg on the one `seq` series.
|
|
1486
|
+
legs: legs.map((l) => ({
|
|
1487
|
+
leg: l.leg,
|
|
1488
|
+
after: l.after,
|
|
1489
|
+
outcome: l.outcome,
|
|
1490
|
+
startTs: typeof l.start?.ts === 'string' ? l.start.ts : null,
|
|
1491
|
+
endTs: typeof l.end?.ts === 'string' ? l.end.ts : null,
|
|
1492
|
+
startSeq: typeof l.start?.seq === 'number' ? l.start.seq : null,
|
|
1493
|
+
endSeq: typeof l.end?.seq === 'number' ? l.end.seq : null,
|
|
1494
|
+
})),
|
|
1378
1495
|
thisFileSpend,
|
|
1379
1496
|
// spendMismatch: a NON-resumed run's own `job-end.spentUsd` IS this
|
|
1380
1497
|
// file's whole spend (no fold to explain a gap) — so on a cold run,
|
|
@@ -1684,7 +1801,10 @@ export function formatReplay(summary) {
|
|
|
1684
1801
|
? `this file unknown (${tf.unpricedRounds} of ${tf.totalRounds} rounds unpriced; ${tf.source})`
|
|
1685
1802
|
: `this file ${money(tf.value)} (${tf.totalRounds} round${tf.totalRounds === 1 ? '' : 's'}, all priced; ${tf.source})`;
|
|
1686
1803
|
let spentLine;
|
|
1687
|
-
if (summary.
|
|
1804
|
+
if (summary.legs.length > 1) {
|
|
1805
|
+
// one run, several legs on ONE file: one figure (each leg's own spend, summed), never two bases side by side
|
|
1806
|
+
spentLine = `spent: ${money(summary.spentUsd)}${summary.spentUsd !== null && !summary.spendComplete ? ' (floor, not exact)' : ''} over ${summary.legs.length} legs of one run · of ${money2(summary.budgetUsd)} signed`;
|
|
1807
|
+
} else if (summary.resumed) {
|
|
1688
1808
|
spentLine = `spent: ${money(summary.spentUsd)} chain total (job-end; prior legs folded) · ${thisFileText} · of ${money2(summary.budgetUsd)} signed`;
|
|
1689
1809
|
} else {
|
|
1690
1810
|
spentLine = `spent: ${money(summary.spentUsd)}${summary.spentUsd !== null && !summary.spendComplete ? ' (floor, not exact)' : ''} of ${money2(summary.budgetUsd)} signed`;
|
|
@@ -1705,7 +1825,7 @@ export function formatReplay(summary) {
|
|
|
1705
1825
|
? ` · floor because: ${summary.floorReasons.join(', ')}`
|
|
1706
1826
|
: ' · floor (reason not in spine)';
|
|
1707
1827
|
}
|
|
1708
|
-
spentLine += ` · wall ${duration(summary.wallMs)} (this file)`;
|
|
1828
|
+
spentLine += ` · wall ${duration(summary.wallMs)} (${summary.legs.length > 1 ? 'working time, gaps between legs excluded' : 'this file'})`;
|
|
1709
1829
|
if (summary.chainClock) {
|
|
1710
1830
|
const { elapsedMs, requestedMs, source } = summary.chainClock;
|
|
1711
1831
|
const signed = typeof requestedMs === 'number' ? `${duration(requestedMs)} signed` : 'no wall signed';
|
package/src/replayio.js
CHANGED
|
@@ -87,7 +87,10 @@ export function resolveSiblings(spinePath) {
|
|
|
87
87
|
const base = basename(spinePath);
|
|
88
88
|
const stem = base.replace(/\.jsonl$/, '');
|
|
89
89
|
const runId = stem.startsWith('u-') ? stem.slice(2) : stem;
|
|
90
|
-
|
|
90
|
+
let auditPath = join(dir, `${stem}-gate-audit.jsonl`);
|
|
91
|
+
// a bundle run (`<bundle>/runs/<runid>/spine.jsonl`) keeps its tool log beside the spine as plain `gate-audit.jsonl`
|
|
92
|
+
const bundleAudit = join(dir, 'gate-audit.jsonl');
|
|
93
|
+
if (base === 'spine.jsonl' && !existsSync(auditPath) && existsSync(bundleAudit)) auditPath = bundleAudit;
|
|
91
94
|
return { runId, auditPath: existsSync(auditPath) ? auditPath : null };
|
|
92
95
|
}
|
|
93
96
|
|
|
@@ -96,7 +99,7 @@ export function resolveSiblings(spinePath) {
|
|
|
96
99
|
* hand both to {@link replayRun}. Read-only, $0, mints no verdict.
|
|
97
100
|
* @param {string} spinePath absolute or cwd-relative path to a `.jsonl` spine
|
|
98
101
|
* @param {{preParsedSpine?: {records: any[], skipped: number}, skipAudit?: boolean,
|
|
99
|
-
* auditPathOverride?: string|null}} [opts]
|
|
102
|
+
* auditPathOverride?: string|string[]|null}} [opts]
|
|
100
103
|
* `preParsedSpine`: avoids a second parse of the same file when the caller
|
|
101
104
|
* ({@link listSpines}) already read it once to run {@link looksLikeSpine}.
|
|
102
105
|
* `skipAudit` (PR #23 review item 5, 2026-08-26, carried over): a
|
|
@@ -120,13 +123,17 @@ export function replayOne(spinePath, opts = {}) {
|
|
|
120
123
|
const { runId, auditPath: resolvedAuditPath } = resolveSiblings(spinePath);
|
|
121
124
|
const auditPath = auditPathOverride !== undefined ? auditPathOverride : resolvedAuditPath;
|
|
122
125
|
const spine = preParsedSpine ?? parseJsonl(spinePath);
|
|
123
|
-
|
|
126
|
+
// P5-R: a RESUMED run still being written has TWO homes for its tool log — the finished legs' file and the
|
|
127
|
+
// live leg's file in the patient tree — so a caller may hand every path the run's log is in
|
|
128
|
+
const auditPaths = Array.isArray(auditPath) ? auditPath : (auditPath ? [auditPath] : []);
|
|
129
|
+
const audit = { records: /** @type {any[]} */ ([]), skipped: 0 };
|
|
130
|
+
if (!skipAudit) for (const ap of auditPaths) { const part = parseJsonl(ap); audit.records.push(...part.records); audit.skipped += part.skipped; }
|
|
124
131
|
// `auditAvailable`: true only when a sidecar was actually FOUND AND READ.
|
|
125
132
|
// `skipAudit` (a deliberate cheap directory listing) and "no sidecar on
|
|
126
133
|
// disk" both mean this call never learned the real tool-call count, so
|
|
127
134
|
// both report unknown, never a coincidental 0 (doctrine: unknown reported
|
|
128
135
|
// as unknown, never rendered as zero — see `replayRun`'s own doc).
|
|
129
|
-
const auditAvailable = !skipAudit &&
|
|
136
|
+
const auditAvailable = !skipAudit && auditPaths.length > 0;
|
|
130
137
|
const summary = replayRun(spine.records, audit.records, { runId, auditAvailable });
|
|
131
138
|
summary.skipped += spine.skipped + audit.skipped;
|
|
132
139
|
return summary;
|
package/src/reuse.js
CHANGED
|
@@ -52,6 +52,7 @@ import { closeStagesOf } from './plan.js';
|
|
|
52
52
|
import { MIN_WALL_MS, jobSpecHash } from './job.js';
|
|
53
53
|
import { isObj, isNonEmptyString } from './validate.js';
|
|
54
54
|
import { readGrade } from './trend.js';
|
|
55
|
+
import { legsOf } from './legs.js';
|
|
55
56
|
|
|
56
57
|
/** the paused-run TERMINAL and its spine record share one name (`runPlan` returns
|
|
57
58
|
* it and `runJob` writes it onto `job-end`), spelled once in `declaredclose.js`
|
|
@@ -117,6 +118,11 @@ export const REUSE_GRADED_RED = Object.freeze(['escalated']);
|
|
|
117
118
|
* allowances the loop's remaining tries are meant to spend against, and the human
|
|
118
119
|
* ones are the stop no further try can change (see `hardStop`).
|
|
119
120
|
*
|
|
121
|
+
* `stopped` (PANEL-BUILD.md P5 item 5, hamr 2026-10-02): the PERSON ended the leg at a round
|
|
122
|
+
* boundary (a `<spine>.stop` request). Nothing was judged and nothing failed — finished steps stand on
|
|
123
|
+
* disk, the allowance is unspent, so it is a checkpoint exactly like a cap-halt. Arbiter-adjacent (it
|
|
124
|
+
* widens what resume may re-enter); ruled by hamr, not chosen by a builder.
|
|
125
|
+
*
|
|
120
126
|
* `provider-red` folded in 2026-08-25 (PRD v1.80 TODO #4, F115 "Ruled: one transport
|
|
121
127
|
* retry"; hamr's ruling verbatim: *"joins resume anyways and you decide but you are
|
|
122
128
|
* given an honest readout and with 1 retry"*). It is a MACHINE halt, not a human one
|
|
@@ -139,7 +145,7 @@ export const REUSE_GRADED_RED = Object.freeze(['escalated']);
|
|
|
139
145
|
* does not end up with two lists that can drift, which is the exact failure this
|
|
140
146
|
* constant exists to end (see the paragraph above).
|
|
141
147
|
*/
|
|
142
|
-
export const CHECKPOINT_OUTCOMES = Object.freeze(['cap-halt', 'wall-halt', 'step-stalled', 'provider-red', ...HUMAN_CHECKPOINTS]);
|
|
148
|
+
export const CHECKPOINT_OUTCOMES = Object.freeze(['cap-halt', 'wall-halt', 'step-stalled', 'provider-red', 'stopped', ...HUMAN_CHECKPOINTS]);
|
|
143
149
|
|
|
144
150
|
/**
|
|
145
151
|
* v1's definition of "the same KIND of recipe" — and it is the LOAD GATE's own: the
|
|
@@ -815,7 +821,16 @@ function readGradeSeed(seen) {
|
|
|
815
821
|
* spentUsd, spendComplete, carrySpentUsd, carrySpendComplete }`
|
|
816
822
|
*/
|
|
817
823
|
export function readResume(events, { deathAt = null, direct = false, resumableOutcomes = [] } = {}) {
|
|
818
|
-
|
|
824
|
+
let list = Array.isArray(events) ? events.filter(isObj) : [];
|
|
825
|
+
// P5-R: a DIRECT spine may be a resumed run's one file of several legs (src/legs.js). The checkpoint a resume
|
|
826
|
+
// continues from is the latest leg that reached a `job-start`, and that leg's own `job-start` DECLARES the fold
|
|
827
|
+
// it inherited (`priorSpentUsd`/`priorWallMs`/…) — so reading that one window and adding its rounds is the whole
|
|
828
|
+
// chain exactly once, never the earlier legs' rounds on top of a fold that already carries them. A spine with
|
|
829
|
+
// no marker is one leg and reads as it always did.
|
|
830
|
+
if (direct) {
|
|
831
|
+
const legs = legsOf(list);
|
|
832
|
+
if (legs.length > 1) list = (legs.findLast((l) => l.jobStart !== null) ?? legs[legs.length - 1]).records;
|
|
833
|
+
}
|
|
819
834
|
/** which landed terminals are a CHECKPOINT rather than a graded row */
|
|
820
835
|
const resumableHalt = new Set(Array.isArray(resumableOutcomes) ? resumableOutcomes : []);
|
|
821
836
|
// the FIRST reuse-start is the envelope this spine was opened under. A resumed spine
|
|
@@ -1228,7 +1243,9 @@ export const PAUSE_TTL_MS = 60 * 24 * 60 * 60_000;
|
|
|
1228
1243
|
* @returns {{ok: boolean, applies: boolean, ageMs: number|null, ttlMs: number, pausedAt: string|null, detail: string|null}}
|
|
1229
1244
|
*/
|
|
1230
1245
|
export function checkpointAgeGate(events, { now = Date.now, ttlMs = PAUSE_TTL_MS } = {}) {
|
|
1231
|
-
const
|
|
1246
|
+
const all = Array.isArray(events) ? events.filter(isObj) : [];
|
|
1247
|
+
// P5-R: only the run's LATEST leg can be the one waiting — an earlier leg's pause was answered by the leg after it
|
|
1248
|
+
const list = legsOf(all).at(-1)?.records ?? all;
|
|
1232
1249
|
const end = list.filter((e) => e.type === 'job-end').at(-1) ?? null;
|
|
1233
1250
|
const base = { applies: false, ageMs: null, ttlMs, pausedAt: null, detail: null };
|
|
1234
1251
|
if (end?.outcome !== HITL_PAUSE) return { ...base, ok: true };
|
package/src/run.js
CHANGED
|
@@ -256,10 +256,13 @@ async function primitiveSmoke(workdir) {
|
|
|
256
256
|
* passes `false` so the escalation tail's option strings say "resume is
|
|
257
257
|
* `run-u`-only in v1" instead of naming a flag that would fail if typed. Forwarded
|
|
258
258
|
* verbatim to the plan flow, which owns the three `--resume`-naming readouts.
|
|
259
|
+
* @param {string|null} [opts.stopFile=null] P5 item 5 — the run's STOP REQUEST file
|
|
260
|
+
* (`stopFilePath(spine)`, src/legs.js), forwarded verbatim to the plan flow, which reads it at the
|
|
261
|
+
* round-boundary seam (where the money cap binds) and ends the leg `stopped`. `null` = no stop surface.
|
|
259
262
|
* @returns {Promise<string>} outcome: 'green' | 'already-green' | 'escalated' |
|
|
260
263
|
* 'unapproved-spec' | 'job-red' | 'smoke-red' | 'plan-red' | 'check-red' |
|
|
261
264
|
* 'close-red' | 'close-unsupported' | 'recipe-stale' | 'branch-red' | 'pricing-red' | 'provider-red' |
|
|
262
|
-
* 'interpreter-red' | 'cap-halt' | 'wall-halt' | 'step-stalled' |
|
|
265
|
+
* 'interpreter-red' | 'cap-halt' | 'wall-halt' | 'stopped' | 'step-stalled' |
|
|
263
266
|
* 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
|
|
264
267
|
* NOTE: `job-end` can ALSO land with outcome 'runner-drained' (F140/PRD item
|
|
265
268
|
* 28(c)) — the `beforeExit` backstop's own terminal, emitted from OUTSIDE this
|
|
@@ -270,7 +273,7 @@ async function primitiveSmoke(workdir) {
|
|
|
270
273
|
* deliberately NOT in the resumable/checkpoint set (`src/reuse.js`) — an
|
|
271
274
|
* unknown in-flight state is not a known-safe resume point.
|
|
272
275
|
*/
|
|
273
|
-
export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, rates = null, judgeRates = null, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, closeDir = null, layerRoot = false, readShim = false, scout = true, bridge = null, draftSpentUsd = 0, draftSpendComplete = true, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, reviewDoor = null, doorRerun = null, resumable = true }) {
|
|
276
|
+
export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, rates = null, judgeRates = null, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, closeDir = null, layerRoot = false, readShim = false, scout = true, bridge = null, draftSpentUsd = 0, draftSpendComplete = true, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, reviewDoor = null, doorRerun = null, resumable = true, stopFile = null }) {
|
|
274
277
|
// THE READ SHIM's ARM, resolved at the door — the FIRST thing this entry does,
|
|
275
278
|
// before the ledger, before the approval gate, before a byte of the spec is read.
|
|
276
279
|
// An unrecognised spelling throws here at zero cost instead of being coerced by
|
|
@@ -537,7 +540,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
|
|
|
537
540
|
// accounts it natively (F12) and the job-end money contract is unchanged.
|
|
538
541
|
{
|
|
539
542
|
const outcome = await runPlan(job, {
|
|
540
|
-
workdir, provider, nativeProvider, providerFor, judgeProvider, judgeModel, rates, judgeRates, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, closeDir, layerRoot, readShim, scout, bridge, priorWallMs: chainWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, reviewDoor, doorRerun, priorSpentUsd: chainFoldUsd, resumable,
|
|
543
|
+
workdir, provider, nativeProvider, providerFor, judgeProvider, judgeModel, rates, judgeRates, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, closeDir, layerRoot, readShim, scout, bridge, priorWallMs: chainWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, reviewDoor, doorRerun, priorSpentUsd: chainFoldUsd, resumable, stopFile,
|
|
541
544
|
// hamr's ruling 2026-09-28 — ONE cap covers drafting + run: the run's
|
|
542
545
|
// enforced ceiling is the signed `budgetUsd` (unchanged, still in the
|
|
543
546
|
// spec hash) MINUS whatever drafting already spent on this job before
|
package/src/runlist.js
CHANGED
|
@@ -58,9 +58,12 @@ export function runlistPath(home) {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
|
-
* @typedef {{ at: string, runid: string, job: string, spine: string, patient: string|null, via: 'run-u'|'bundle'|'backfill', pid?: number, capUsd?: number }} RunRow
|
|
61
|
+
* @typedef {{ at: string, runid: string, job: string, spine: string, patient: string|null, via: 'run-u'|'bundle'|'backfill', pid?: number, capUsd?: number, leg?: number }} RunRow
|
|
62
62
|
* `pid`/`capUsd` (optional; older and backfilled rows carry neither): the runner's process id and the
|
|
63
63
|
* leg's $ cap — what the monthly limit holds while that pid is a live bareloop runner (src/monthly.js).
|
|
64
|
+
* `leg` (P5-R; only on a row {@link readRunList} FOLDED from `leg-start` entries, i.e. a RESUMED run):
|
|
65
|
+
* the number of the run's latest leg — a resumed run is the same run, one row, and its `pid`/`capUsd`
|
|
66
|
+
* are that latest leg's.
|
|
64
67
|
*/
|
|
65
68
|
|
|
66
69
|
/**
|
|
@@ -101,7 +104,10 @@ export function appendRun(row, opts = {}) {
|
|
|
101
104
|
}
|
|
102
105
|
|
|
103
106
|
/**
|
|
104
|
-
* @typedef {{ runid: string, type: 'settled'|'released', by: string, at: string, reason?: string, spentUsd?: number, spendComplete?: boolean }} RunEvent
|
|
107
|
+
* @typedef {{ runid: string, type: 'settled'|'released', by: string, at: string, reason?: string, spentUsd?: number, spendComplete?: boolean, leg?: number }} RunEvent
|
|
108
|
+
* `leg` (P5-R): which leg of the run the write-back is about; absent = leg 1 (every entry written before P5-R).
|
|
109
|
+
* A resumed run is ONE row, so `settled`/`released` are per leg: a later leg's `released` gives back THAT leg's
|
|
110
|
+
* claim and never removes the run.
|
|
105
111
|
* A claim's write-back, appended (never rewritten) beside the run rows. `settled` = the run's claim on the
|
|
106
112
|
* monthly limit is over (`spentUsd`/`spendComplete` are its final figure, `by` is WHO wrote it: the run itself
|
|
107
113
|
* at its `job-end`, or the next run that found its process gone, `reason: 'process gone'`). `released` = the
|
|
@@ -122,6 +128,43 @@ export function appendRunEvent(event, opts = {}) {
|
|
|
122
128
|
try { chmodSync(path, 0o600); } catch { /* best-effort perms */ }
|
|
123
129
|
}
|
|
124
130
|
|
|
131
|
+
/**
|
|
132
|
+
* @typedef {{ type: 'leg-start', runid: string, leg: number, pid?: number, capUsd?: number, at: string }} LegStart
|
|
133
|
+
* A resumed run's NEW claim (P5-R, hamr 2026-10-02: one run, one id, one row): appended beside the run's row
|
|
134
|
+
* instead of a second row. {@link readRunList} folds it into the row.
|
|
135
|
+
*/
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Append one {@link LegStart}: a resumed run's leg claim. Not locked: one short line, one append. THROWS on IO failure.
|
|
139
|
+
* @param {LegStart} entry
|
|
140
|
+
* @param {{ home?: string }} [opts]
|
|
141
|
+
* @returns {void}
|
|
142
|
+
*/
|
|
143
|
+
export function appendLegStart(entry, opts = {}) {
|
|
144
|
+
const path = runlistPath(opts.home);
|
|
145
|
+
mkdirSync(runlistHome(opts.home), { recursive: true, mode: 0o700 });
|
|
146
|
+
appendFileSync(path, `${JSON.stringify({ ...entry, type: 'leg-start' })}\n`);
|
|
147
|
+
try { chmodSync(path, 0o600); } catch { /* best-effort perms */ }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The leg a write-back names (absent = 1).
|
|
152
|
+
* @param {{ leg?: number }} e
|
|
153
|
+
* @returns {number}
|
|
154
|
+
*/
|
|
155
|
+
const legOf = (e) => (Number.isInteger(e.leg) && /** @type {number} */ (e.leg) >= 1 ? /** @type {number} */ (e.leg) : 1);
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Has this row's LATEST leg been settled? The ONE spelling (the monthly limit and the settle writer share it).
|
|
159
|
+
* @param {RunEvent[]} events {@link readRunList}'s `events`
|
|
160
|
+
* @param {{ runid: string, leg?: number }} row
|
|
161
|
+
* @returns {boolean}
|
|
162
|
+
*/
|
|
163
|
+
export function isSettled(events, row) {
|
|
164
|
+
const leg = legOf(row);
|
|
165
|
+
return events.some((e) => e.type === 'settled' && e.runid === row.runid && legOf(e) === leg);
|
|
166
|
+
}
|
|
167
|
+
|
|
125
168
|
/**
|
|
126
169
|
* Tolerant reader — reuses {@link parseJsonl} (never a second hand-rolled
|
|
127
170
|
* parser). An absent file reads as an empty list, not an error: a fresh
|
|
@@ -136,20 +179,45 @@ export function readRunList(opts = {}) {
|
|
|
136
179
|
const path = runlistPath(opts.home);
|
|
137
180
|
if (!existsSync(path)) return { rows: [], skipped: 0, events: [] };
|
|
138
181
|
const { records, skipped } = parseJsonl(path);
|
|
139
|
-
// the FIRST settle for a runid is authoritative: two runs may both close the same dead claim, and the
|
|
182
|
+
// the FIRST settle for a (runid, leg) is authoritative: two runs may both close the same dead claim, and the
|
|
140
183
|
// second note is a duplicate (a `released` line likewise); file order decides, later copies are ignored
|
|
141
184
|
const seen = new Set();
|
|
142
185
|
/** @type {RunEvent[]} */
|
|
143
186
|
const events = records.filter((r) => {
|
|
144
187
|
if (!r || (r.type !== 'settled' && r.type !== 'released')) return false;
|
|
145
|
-
const key = `${r.type}:${r.runid}`;
|
|
188
|
+
const key = `${r.type}:${r.runid}:${legOf(r)}`;
|
|
146
189
|
if (seen.has(key)) return false;
|
|
147
190
|
seen.add(key);
|
|
148
191
|
return true;
|
|
149
192
|
});
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
193
|
+
// a leg-1 `released` = the run never started: not listed. A LATER leg's `released` only gives that leg's claim back.
|
|
194
|
+
const released = new Set(events.filter((e) => e.type === 'released' && legOf(e) === 1).map((e) => e.runid));
|
|
195
|
+
const voided = new Set(events.filter((e) => e.type === 'released' && legOf(e) > 1).map((e) => `${e.runid}:${legOf(e)}`));
|
|
196
|
+
/** @type {Map<string, RunRow & {claimAt: number}>} */
|
|
197
|
+
const byRunid = new Map();
|
|
198
|
+
/** @type {(RunRow & {claimAt: number})[]} */
|
|
199
|
+
const rows = [];
|
|
200
|
+
records.forEach((r, i) => {
|
|
201
|
+
if (!r) return;
|
|
202
|
+
if (!r.type) {
|
|
203
|
+
if (released.has(r.runid)) return;
|
|
204
|
+
const row = { ...r, claimAt: i };
|
|
205
|
+
rows.push(row);
|
|
206
|
+
if (!byRunid.has(r.runid)) byRunid.set(r.runid, row);
|
|
207
|
+
} else if (r.type === 'leg-start') {
|
|
208
|
+
const row = byRunid.get(r.runid);
|
|
209
|
+
if (!row || !Number.isInteger(r.leg) || r.leg < 2 || voided.has(`${r.runid}:${r.leg}`) || r.leg <= legOf(row)) return;
|
|
210
|
+
// the run's pid/cap are its LATEST leg's; the claim's place in the file (the monthly limit's "who is above
|
|
211
|
+
// whom") is that leg-start line's, since that is where the leg claimed
|
|
212
|
+
row.leg = r.leg;
|
|
213
|
+
row.pid = r.pid;
|
|
214
|
+
row.capUsd = r.capUsd;
|
|
215
|
+
row.claimAt = i;
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
// file order = claim order: a resumed run's latest claim sits where its latest leg-start line is
|
|
219
|
+
const ordered = rows.some((r) => r.leg !== undefined) ? [...rows].sort((a, b) => a.claimAt - b.claimAt) : rows;
|
|
220
|
+
return { rows: ordered.map(({ claimAt: _c, ...row }) => row), skipped, events };
|
|
153
221
|
}
|
|
154
222
|
|
|
155
223
|
/**
|
package/src/source.js
CHANGED
|
@@ -27,6 +27,7 @@ import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
|
27
27
|
import { mkdir, readdir, readFile, writeFile, lstat, stat, chmod, access, copyFile, cp, open, rm, realpath, readlink, symlink, constants as fsConstants } from 'node:fs/promises';
|
|
28
28
|
import { dirname, join, relative, resolve, sep, basename } from 'node:path';
|
|
29
29
|
import { git } from './kinds.js';
|
|
30
|
+
import { addWorktree, hideBareloopDir } from './worktree.js';
|
|
30
31
|
import { MAX_BUFFER } from './kinds.js';
|
|
31
32
|
import { PROVIDER_TIMEOUT_MS } from './clock.js';
|
|
32
33
|
import { scanSecrets, SECRET_PATTERNS, SECRET_PATTERN_NAMES } from './validate.js';
|
|
@@ -440,16 +441,21 @@ async function fetchOnce(url, timeoutMs) {
|
|
|
440
441
|
* as declared, unvalidated by `proveDestination`, and `frontDoorFromManifest`
|
|
441
442
|
* never hands a repo source's destination to `copyOut` — wiring it into
|
|
442
443
|
* `writeScope` is M3/M4's job, not this one.
|
|
443
|
-
*
|
|
444
|
+
* `worktree` (P6 item 1, repo sources ONLY — ignored for every other kind): instead of COPYING the repo, make the
|
|
445
|
+
* run's tree a detached git worktree of the person's own repo at that directory (the shared
|
|
446
|
+
* `src/worktree.js`, the bundle door's own spelling), hide `.bareloop/` from their `git status`, and record the
|
|
447
|
+
* worktree and the repo root in the manifest. `into` then holds only `source.json`; the tree is `worktree`, at the
|
|
448
|
+
* repo's current commit (uncommitted edits are NOT in it). No seed commit is made — the seed IS the repo's HEAD.
|
|
449
|
+
* @param {{source: string, into: string, destination?: string, worktree?: string,
|
|
444
450
|
* fetchTimeoutMs?: number, afterScan?: () => (void|Promise<void>)}} args
|
|
445
451
|
* `fetchTimeoutMs` is test-only — production callers omit it and get
|
|
446
452
|
* `PROVIDER_TIMEOUT_MS`. `afterScan` is a test seam too: called exactly once,
|
|
447
453
|
* after the secret scan finished and before the freeze loop re-reads any
|
|
448
454
|
* file's bytes, so a test can swap a file inside that window
|
|
449
455
|
* deterministically. Production never sets it; absent, behaviour is unchanged.
|
|
450
|
-
* @returns {Promise<{stop: null, into: string, tree: string, manifestPath: string, manifest:
|
|
456
|
+
* @returns {Promise<{stop: null, into: string, tree: string, manifestPath: string, manifest: Record<string, any>}|SourceRefusal>}
|
|
451
457
|
*/
|
|
452
|
-
export async function prepareSource({ source, into, destination, fetchTimeoutMs = PROVIDER_TIMEOUT_MS, afterScan }) {
|
|
458
|
+
export async function prepareSource({ source, into, destination, worktree, fetchTimeoutMs = PROVIDER_TIMEOUT_MS, afterScan }) {
|
|
453
459
|
const intoAbs = resolve(into);
|
|
454
460
|
if (existsSync(intoAbs)) {
|
|
455
461
|
return refuse('into-exists', `${intoAbs} already exists — a source door writes a FRESH tree, never reuses one (the export worktree rule)`);
|
|
@@ -634,6 +640,9 @@ export async function prepareSource({ source, into, destination, fetchTimeoutMs
|
|
|
634
640
|
const buf = f.buf ?? (f.symlinkTarget !== undefined ? Buffer.from(f.symlinkTarget, 'utf8') : await readFile(/** @type {string} */ (f.abs)));
|
|
635
641
|
fileMeta.push({ path: f.rel, bytes: buf.length, sha256: sha256Hex(buf) });
|
|
636
642
|
const isBinary = f.symlinkTarget === undefined && hasNulByte(buf.subarray(0, 8192));
|
|
643
|
+
// P6 item 2 (hamr 2026-10-05 Q2=A): a repo going onto a WORKTREE is the person's OWN repo, left in place — a secret
|
|
644
|
+
// already in it is MASKED wherever bareloop records it (`redactSecrets`), not a refusal. Every other kind keeps the scan.
|
|
645
|
+
if (worktree !== undefined && frozen.kind === 'repo') continue;
|
|
637
646
|
const names = secretPatternNames(buf.toString(isBinary ? 'latin1' : 'utf8'));
|
|
638
647
|
if (names.length) secretHits.push({ rel: f.rel, names });
|
|
639
648
|
}
|
|
@@ -642,6 +651,34 @@ export async function prepareSource({ source, into, destination, fetchTimeoutMs
|
|
|
642
651
|
return refuse('source-carries-secret', `${secretHits.length} file(s) carry a known secret shape — refused before anything was written (hard line #3, secrets never enter the tree): ${detail}`);
|
|
643
652
|
}
|
|
644
653
|
|
|
654
|
+
if (worktree !== undefined && frozen.kind === 'repo') {
|
|
655
|
+
const repoRoot = /** @type {string} */ (frozen.root);
|
|
656
|
+
const wt = resolve(worktree);
|
|
657
|
+
try {
|
|
658
|
+
hideBareloopDir(repoRoot);
|
|
659
|
+
addWorktree(repoRoot, wt);
|
|
660
|
+
} catch (e) {
|
|
661
|
+
return refuse('source-worktree-failed', `could not make the job's worktree in ${repoRoot}: ${/** @type {Error} */ (e).message} (the repo needs at least one commit)`);
|
|
662
|
+
}
|
|
663
|
+
const headAt = await git(wt, ['rev-parse', 'HEAD']);
|
|
664
|
+
if (!headAt.ok) return refuse('source-git-failed', `git rev-parse HEAD failed in ${wt}: ${headAt.err}`);
|
|
665
|
+
const wtManifest = {
|
|
666
|
+
kind: 'repo',
|
|
667
|
+
source,
|
|
668
|
+
...(frozen.sourceSubdir === undefined ? {} : { sourceSubdir: frozen.sourceSubdir }),
|
|
669
|
+
fetchedAt: new Date().toISOString(),
|
|
670
|
+
files: fileMeta,
|
|
671
|
+
seed: headAt.out.trim(),
|
|
672
|
+
destination: destination ?? null,
|
|
673
|
+
worktree: wt,
|
|
674
|
+
repo: repoRoot,
|
|
675
|
+
};
|
|
676
|
+
await mkdir(intoAbs, { recursive: true });
|
|
677
|
+
const wtManifestPath = join(intoAbs, 'source.json');
|
|
678
|
+
await writeFile(wtManifestPath, `${JSON.stringify(wtManifest, null, 2)}\n`);
|
|
679
|
+
return { stop: null, into: intoAbs, tree: wt, manifestPath: wtManifestPath, manifest: wtManifest };
|
|
680
|
+
}
|
|
681
|
+
|
|
645
682
|
const treeDir = join(intoAbs, 'tree');
|
|
646
683
|
const outputDir = join(treeDir, 'output');
|
|
647
684
|
await mkdir(treeDir, { recursive: true });
|
|
@@ -809,7 +846,7 @@ const LOCKFILE_COMMANDS = Object.freeze([
|
|
|
809
846
|
* manifest)
|
|
810
847
|
* @param {string} [sourceSubdir] the manifest's own `sourceSubdir` ('' or
|
|
811
848
|
* undefined when Source IS the repo root)
|
|
812
|
-
* @returns {{manager: string, command: string, reason: string}|null} `null`
|
|
849
|
+
* @returns {{dir: string, lockFile: string|null, manager: string, command: string, reason: string}|null} `null`
|
|
813
850
|
* when there is nothing to report: no JS/TS manifest found walking up from
|
|
814
851
|
* `sourceSubdir` to `treeDir`, the nearest one found has no dependencies,
|
|
815
852
|
* `node_modules` is already there, or the manifest cannot even be read as
|
|
@@ -846,6 +883,8 @@ export function missingDependencies(treeDir, sourceSubdir = '') {
|
|
|
846
883
|
const installCmd = lock ? lock.command : 'npm install';
|
|
847
884
|
const rel = relative(root, d).split(sep).join('/');
|
|
848
885
|
return {
|
|
886
|
+
dir: d,
|
|
887
|
+
lockFile: lock ? lock.file : null,
|
|
849
888
|
manager: installCmd.split(' ')[0],
|
|
850
889
|
command: rel === '' ? installCmd : `cd ${rel} && ${installCmd}`,
|
|
851
890
|
reason: `${rel === '' ? 'package.json' : `${rel}/package.json`} lists dependencies but the copy has no node_modules`,
|