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/legs.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// P5-R (hamr 2026-10-02: "one run, one id, one file ... never two") — a RESUMED run is the SAME run.
|
|
2
|
+
// Its spine is one append-only file; each resume appends a `leg-resume` marker and then the new
|
|
3
|
+
// leg's own records. This module is the ONE owner of "where does a leg start and end": every reader
|
|
4
|
+
// that needs a leg (the resume reader, replay, the monthly limit, the panel, the readout) asks
|
|
5
|
+
// `legsOf` and never re-derives a boundary on its own — two spellings of one boundary is how a
|
|
6
|
+
// money figure counts leg 1 twice.
|
|
7
|
+
//
|
|
8
|
+
// Pure: no imports, no file IO. The text-level torn-line rule lives in `parseSpineText` below, beside
|
|
9
|
+
// the boundary rule it depends on (a torn line is tolerated exactly where the writer leaves one).
|
|
10
|
+
//
|
|
11
|
+
// A spine with NO marker (every run written before P5-R, and every run nobody resumed) is ONE leg:
|
|
12
|
+
// `legsOf` over it returns that file's records, unchanged.
|
|
13
|
+
|
|
14
|
+
/** The marker record's `type`. */
|
|
15
|
+
export const LEG_RESUME = 'leg-resume';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {object} Leg
|
|
19
|
+
* @property {number} leg 1-based position in file order (the marker's own `leg` field is never trusted over the order)
|
|
20
|
+
* @property {any|null} start the leg's first record (the `leg-resume` marker for leg 2+; the file's first record for leg 1)
|
|
21
|
+
* @property {any|null} end the leg's last record
|
|
22
|
+
* @property {any[]} records every record of the leg, in file order, marker included
|
|
23
|
+
* @property {any|null} jobStart the leg's own `job-start` (null: it ended before runJob wrote one)
|
|
24
|
+
* @property {any|null} jobEnd the leg's own LAST `job-end` (null: it died, or it is still running)
|
|
25
|
+
* @property {string|null} outcome the leg's terminal: its `job-end` outcome, else a `run-end` outcome (a $0 refusal), else null
|
|
26
|
+
* @property {string|null} after leg 1: null. Leg 2+: how the PREVIOUS leg ended, as the marker declares it
|
|
27
|
+
* (else derived from that leg's own terminal), `'died'` when it recorded none
|
|
28
|
+
* @property {number|null} startMs `Date.parse` of the start record's `ts`
|
|
29
|
+
* @property {number|null} endMs `Date.parse` of the end record's `ts`
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** @param {any} r @returns {number|null} */
|
|
33
|
+
function tsOf(r) {
|
|
34
|
+
const t = typeof r?.ts === 'string' ? Date.parse(r.ts) : NaN;
|
|
35
|
+
return Number.isFinite(t) ? t : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Split one spine's parsed records into its legs. Pure.
|
|
40
|
+
* @param {any[]} events one spine's parsed records, in file order
|
|
41
|
+
* @returns {Leg[]} always at least one leg for a non-empty list; `[]` for an empty one
|
|
42
|
+
*/
|
|
43
|
+
export function legsOf(events) {
|
|
44
|
+
const list = Array.isArray(events) ? events.filter((e) => e && typeof e === 'object') : [];
|
|
45
|
+
if (list.length === 0) return [];
|
|
46
|
+
/** @type {any[][]} */
|
|
47
|
+
const groups = [[]];
|
|
48
|
+
for (const e of list) {
|
|
49
|
+
// a marker opens a new leg — but never an EMPTY leg 1: a file whose first record IS a marker
|
|
50
|
+
// (a spine somebody truncated) still reads as leg 1 = the marker, not an invented empty leg
|
|
51
|
+
if (e.type === LEG_RESUME && groups[groups.length - 1].length > 0) groups.push([]);
|
|
52
|
+
groups[groups.length - 1].push(e);
|
|
53
|
+
}
|
|
54
|
+
/** @type {Leg[]} */
|
|
55
|
+
const legs = [];
|
|
56
|
+
groups.forEach((records, i) => {
|
|
57
|
+
const jobStart = records.find((r) => r.type === 'job-start') ?? null;
|
|
58
|
+
const jobEnd = records.findLast((r) => r.type === 'job-end') ?? null;
|
|
59
|
+
const runEnd = records.findLast((r) => r.type === 'run-end') ?? null;
|
|
60
|
+
const outcome = typeof jobEnd?.outcome === 'string' ? jobEnd.outcome
|
|
61
|
+
: (typeof runEnd?.outcome === 'string' ? runEnd.outcome : null);
|
|
62
|
+
const marker = records[0].type === LEG_RESUME ? records[0] : null;
|
|
63
|
+
const prev = i > 0 ? legs[i - 1] : null;
|
|
64
|
+
/** @type {string|null} */
|
|
65
|
+
let after = null;
|
|
66
|
+
if (i > 0) after = typeof marker?.after === 'string' && marker.after !== '' ? marker.after : (prev?.outcome ?? 'died');
|
|
67
|
+
legs.push({
|
|
68
|
+
leg: i + 1,
|
|
69
|
+
start: records[0],
|
|
70
|
+
end: records[records.length - 1],
|
|
71
|
+
records,
|
|
72
|
+
jobStart,
|
|
73
|
+
jobEnd,
|
|
74
|
+
outcome,
|
|
75
|
+
after,
|
|
76
|
+
startMs: tsOf(records[0]),
|
|
77
|
+
endMs: tsOf(records[records.length - 1]),
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
return legs;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The legs' WORKING time, gap excluded (hamr 2026-10-02, 2A: only working time charges the wall). Each leg
|
|
85
|
+
* counts first record → last record; the quiet time between one leg's end and the next leg's marker is
|
|
86
|
+
* nobody's. `null` = unknown (no leg carries two parseable stamps) — never 0. A leg whose stamps are
|
|
87
|
+
* unreadable adds nothing, so the figure is a FLOOR, `complete: false`.
|
|
88
|
+
* @param {Leg[]} legs
|
|
89
|
+
* @returns {{ ms: number|null, complete: boolean }}
|
|
90
|
+
*/
|
|
91
|
+
export function legsWallMs(legs) {
|
|
92
|
+
let ms = 0;
|
|
93
|
+
let known = 0;
|
|
94
|
+
let complete = legs.length > 0;
|
|
95
|
+
for (const l of legs) {
|
|
96
|
+
if (l.startMs !== null && l.endMs !== null && l.endMs >= l.startMs) { ms += l.endMs - l.startMs; known += 1; } else complete = false;
|
|
97
|
+
}
|
|
98
|
+
return { ms: known === 0 ? null : ms, complete: complete && known > 0 };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Parse a spine's TEXT, tolerant exactly where the writer leaves damage. A kill mid-append leaves a torn
|
|
103
|
+
* line; a resume never edits it (the spine is append-only forever) — it writes one `\n` and starts a fresh
|
|
104
|
+
* line, so the torn bytes end up alone on their own line, immediately BEFORE a `leg-resume` marker. That
|
|
105
|
+
* line, and the file's own last two lines (the older tail rule: a kill mid-append of the file's final
|
|
106
|
+
* record), are tolerated and named. An unparseable line anywhere else is `corrupt`: a reconstruction from
|
|
107
|
+
* it would invent a history.
|
|
108
|
+
* @param {string} raw
|
|
109
|
+
* @returns {{ records: any[], tolerated: {line: number, why: 'tail'|'before-leg-resume'}[], corrupt: {line: number, message: string}|null }}
|
|
110
|
+
* `line` is 1-based. Only the FIRST corrupt line is reported.
|
|
111
|
+
*/
|
|
112
|
+
export function parseSpineText(raw) {
|
|
113
|
+
const lines = String(raw).split('\n');
|
|
114
|
+
/** @type {any[]} */
|
|
115
|
+
const records = [];
|
|
116
|
+
/** @type {{line: number, why: 'tail'|'before-leg-resume'}[]} */
|
|
117
|
+
const tolerated = [];
|
|
118
|
+
/** @type {{line: number, message: string}|null} */
|
|
119
|
+
let corrupt = null;
|
|
120
|
+
const BAD = Symbol('torn');
|
|
121
|
+
/** @type {any[]} undefined = blank line, BAD = unparseable */
|
|
122
|
+
const parsed = lines.map((l) => {
|
|
123
|
+
if (!l.trim()) return undefined;
|
|
124
|
+
try { return JSON.parse(l); } catch { return BAD; }
|
|
125
|
+
});
|
|
126
|
+
/** the next non-blank line's parse result after index i, skipping blanks */
|
|
127
|
+
const nextOf = (/** @type {number} */ i) => {
|
|
128
|
+
for (let j = i + 1; j < lines.length; j += 1) if (parsed[j] !== undefined) return parsed[j];
|
|
129
|
+
return undefined;
|
|
130
|
+
};
|
|
131
|
+
lines.forEach((line, i) => {
|
|
132
|
+
const p = parsed[i];
|
|
133
|
+
if (p === undefined) return;
|
|
134
|
+
if (p !== BAD) { records.push(p); return; }
|
|
135
|
+
if (i >= lines.length - 2) { tolerated.push({ line: i + 1, why: 'tail' }); return; }
|
|
136
|
+
// EXACTLY one torn line directly before a marker: the shape a resume leaves. Two in a row is not that
|
|
137
|
+
// shape — the first of them has a corrupt line (not a marker) after it, and is reported.
|
|
138
|
+
const next = nextOf(i);
|
|
139
|
+
if (next && next.type === LEG_RESUME) { tolerated.push({ line: i + 1, why: 'before-leg-resume' }); return; }
|
|
140
|
+
if (corrupt === null) {
|
|
141
|
+
let message = 'unparseable';
|
|
142
|
+
try { JSON.parse(line); } catch (/** @type {any} */ e) { message = e.message; }
|
|
143
|
+
corrupt = { line: i + 1, message };
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
return { records, tolerated, corrupt };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Where leg `leg`'s watchdog kill note lives. Leg 1 keeps the name every run has always had
|
|
151
|
+
* (`<spine>.watchdog.json`); a resumed leg's note sits beside it (`<spine>.leg<N>.watchdog.json`) so one
|
|
152
|
+
* leg never overwrites another's evidence of how it died.
|
|
153
|
+
* @param {string} spineFile
|
|
154
|
+
* @param {number} leg 1-based
|
|
155
|
+
* @returns {string}
|
|
156
|
+
*/
|
|
157
|
+
export function watchdogNotePath(spineFile, leg) {
|
|
158
|
+
return leg <= 1 ? `${spineFile}.watchdog.json` : `${spineFile}.leg${leg}.watchdog.json`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* P5 item 5 (Stop) — where a run's STOP REQUEST lives: a file beside its spine. The panel's stop route writes it;
|
|
163
|
+
* the engine reads it at the round seam of a step worker (src/planrun.js `metered`) and consumes it. One spelling, here, so the route
|
|
164
|
+
* and the engine can never name two files. A request belongs to ONE leg: the engine deletes it when it honours it,
|
|
165
|
+
* when the leg ends, and again at the start of the next leg (src/userrun.js).
|
|
166
|
+
* @param {string} spineFile
|
|
167
|
+
* @returns {string}
|
|
168
|
+
*/
|
|
169
|
+
export function stopFilePath(spineFile) {
|
|
170
|
+
return `${spineFile}.stop`;
|
|
171
|
+
}
|
package/src/monthly.js
CHANGED
|
@@ -20,13 +20,18 @@
|
|
|
20
20
|
//
|
|
21
21
|
// Honesty: a run whose spend is not fully known (a died/still-running spine, an unpriced
|
|
22
22
|
// round, a missing spine file) makes the month total an "at least" figure — never a clean
|
|
23
|
-
// number. The refusal text
|
|
23
|
+
// number. The refusal text leads with `Max $X (monthly limit`; when runs hold money it adds
|
|
24
|
+
// `, $Y held by a run in progress` before the closing paren (`monthlyRefusalText`). `atLeast` travels beside it.
|
|
24
25
|
import { existsSync } from 'node:fs';
|
|
25
26
|
import { readConfig, configPath, ConfigError } from './config.js';
|
|
26
|
-
import { readRunList, appendRun, appendRunEvent, isLiveRunner, runIsAlive } from './runlist.js';
|
|
27
|
+
import { readRunList, appendRun, appendLegStart, appendRunEvent, isLiveRunner, runIsAlive, isSettled } from './runlist.js';
|
|
28
|
+
import { legsOf } from './legs.js';
|
|
27
29
|
import { parseJsonl } from './replayio.js';
|
|
28
|
-
import { SPEND_RECORD_TYPES, spendProvenance, floorsFromRecords } from './ledger.js';
|
|
30
|
+
import { SPEND_RECORD_TYPES, spendProvenance, floorsFromRecords, legSpend, runSpend, chainSpend } from './ledger.js';
|
|
29
31
|
import { findRow } from './providerrows.js';
|
|
32
|
+
import { readOrphanDraftSpends } from './draftspend.js';
|
|
33
|
+
// the per-leg / per-run spend readers live with the ledger (one home, no import cycle through the replay reader)
|
|
34
|
+
export { legSpend, runSpend, chainSpend };
|
|
30
35
|
|
|
31
36
|
// The Money tab's per-provider breakdown groups a run by the endpoint it ran on and names
|
|
32
37
|
// the groups it knows the vendor of. This is HISTORY labelling for spend already recorded
|
|
@@ -61,41 +66,35 @@ function rowIdFor(provider, baseUrl) {
|
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
/**
|
|
64
|
-
* One run's
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* @
|
|
69
|
+
* One run's wall time off its spine: job-start -> job-end when the run ended (exact), else the
|
|
70
|
+
* first -> last record floor (the SAME floor a died / still-running run's "at least" wall uses,
|
|
71
|
+
* `floorsFromRecords`). `ms` null = unknown — never 0.
|
|
72
|
+
* @param {any[]} records one spine's records (one leg or several)
|
|
73
|
+
* @returns {{ ms: number|null, complete: boolean }}
|
|
68
74
|
*/
|
|
69
|
-
export function
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
// drafting spend: a RESUMED / door-rerun leg carries the flag again but the first leg already counted it
|
|
83
|
-
const resumed = !!jobStart && 'priorSpentUsd' in jobStart;
|
|
84
|
-
if (jobStart && !resumed && typeof jobStart.draftSpentUsd === 'number' && Number.isFinite(jobStart.draftSpentUsd) && jobStart.draftSpentUsd > 0) {
|
|
85
|
-
usd += jobStart.draftSpentUsd;
|
|
86
|
-
if (jobStart.draftSpendComplete === false) complete = false;
|
|
75
|
+
export function legWall(records) {
|
|
76
|
+
const legs = legsOf(records);
|
|
77
|
+
if (legs.length > 1) {
|
|
78
|
+
// P5-R: the sum of each leg's own working window, the gap between legs excluded (hamr 2026-10-02, 2A)
|
|
79
|
+
let ms = 0;
|
|
80
|
+
let complete = true;
|
|
81
|
+
for (const l of legs) {
|
|
82
|
+
const w = legWallOne(l.records);
|
|
83
|
+
if (w.ms === null) { complete = false; continue; }
|
|
84
|
+
ms += w.ms;
|
|
85
|
+
if (!w.complete) complete = false;
|
|
86
|
+
}
|
|
87
|
+
return { ms: legs.every((l) => legWallOne(l.records).ms === null) ? null : ms, complete };
|
|
87
88
|
}
|
|
88
|
-
return
|
|
89
|
+
return legWallOne(records);
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
/**
|
|
92
|
-
* One
|
|
93
|
-
* first -> last record floor (the SAME floor a died / still-running run's "at least" wall uses,
|
|
94
|
-
* `floorsFromRecords`). `ms` null = unknown — never 0.
|
|
93
|
+
* One LEG's wall (the single-leg rule {@link legWall} sums).
|
|
95
94
|
* @param {any[]} records
|
|
96
95
|
* @returns {{ ms: number|null, complete: boolean }}
|
|
97
96
|
*/
|
|
98
|
-
|
|
97
|
+
function legWallOne(records) {
|
|
99
98
|
const jobStart = records.find((r) => r && r.type === 'job-start') ?? null;
|
|
100
99
|
const jobEnd = records.findLast((r) => r && r.type === 'job-end') ?? null;
|
|
101
100
|
const a = Date.parse(jobStart?.ts);
|
|
@@ -137,6 +136,7 @@ export function legTokens(records) {
|
|
|
137
136
|
* @property {number} otherRounds every other round: a built-in guess, unpriced, or no provenance on record
|
|
138
137
|
* @property {number} reservedUsd what the refusal check counts: `usd`, or the leg's cap when the run is in flight
|
|
139
138
|
* @property {boolean} unreadable the spine was missing/unreadable or the listed date was bad (spend unknown)
|
|
139
|
+
* @property {boolean} [draft] drafting spend of a panel session that never became a run (src/draftspend.js) — not a run: no wall, not counted in `runs`
|
|
140
140
|
*/
|
|
141
141
|
|
|
142
142
|
/**
|
|
@@ -145,7 +145,7 @@ export function legTokens(records) {
|
|
|
145
145
|
* spend and the leg's own cap — the signed `budgetUsd` on the spine's `job-start` less the fold
|
|
146
146
|
* a resumed leg inherited (`priorSpentUsd`), the same figure the run-start seam computes
|
|
147
147
|
* (`legCapUsd`, src/userrun.js). A spine that records no `budgetUsd` reserves nothing extra.
|
|
148
|
-
* @param {any[]} records
|
|
148
|
+
* @param {any[]} records the LATEST leg's records
|
|
149
149
|
* @param {import('./runlist.js').RunRow} row
|
|
150
150
|
* @param {number} nowMs
|
|
151
151
|
* @param {number} usd the leg's spend so far
|
|
@@ -170,20 +170,22 @@ function inFlightCapUsd(records, row, nowMs, usd) {
|
|
|
170
170
|
function rowsAbove(rows, runid) {
|
|
171
171
|
if (runid === undefined) return rows;
|
|
172
172
|
const i = rows.findIndex((r) => r.runid === runid);
|
|
173
|
-
|
|
173
|
+
if (i < 0) return rows;
|
|
174
|
+
// a RESUMED run's earlier legs are already spent money in this month: its own row stays in (its spend
|
|
175
|
+
// counts, its claim does not — `readLegs`' `ownClaim`). A first-leg claim has no earlier legs to count.
|
|
176
|
+
return rows[i].leg !== undefined && rows[i].leg > 1 ? rows.slice(0, i + 1) : rows.slice(0, i);
|
|
174
177
|
}
|
|
175
178
|
|
|
176
179
|
/**
|
|
177
180
|
* Every listed run as one {@link Leg}. The ONE reader the month total, the all-time total
|
|
178
181
|
* and the per-provider figures share.
|
|
179
|
-
* @param {{ home?: string, now?: () => number, aboveRunid?: string, thisMonthOnly?: boolean }} [opts] `now` = the clock for the no-pid in-flight test; `aboveRunid` = read only the runs listed EARLIER in the file than that run (a claim yields to the claims above it, never below); `thisMonthOnly` = do not parse the spine of a run listed in another local month (the refusal check drops it anyway) unless its claim is still held
|
|
182
|
+
* @param {{ home?: string, sessionsRoot?: string, now?: () => number, aboveRunid?: string, thisMonthOnly?: boolean }} [opts] `sessionsRoot` = where the panel's sessions live (default `<home>/panel-sessions`); `now` = the clock for the no-pid in-flight test; `aboveRunid` = read only the runs listed EARLIER in the file than that run (a claim yields to the claims above it, never below); `thisMonthOnly` = do not parse the spine of a run listed in another local month (the refusal check drops it anyway) unless its claim is still held
|
|
180
183
|
* @returns {Leg[]}
|
|
181
184
|
*/
|
|
182
185
|
export function readLegs(opts = {}) {
|
|
183
186
|
const nowMs = (opts.now ?? Date.now)();
|
|
184
187
|
const { rows: allRows, events } = readRunList({ home: opts.home });
|
|
185
188
|
const rows = rowsAbove(allRows, opts.aboveRunid);
|
|
186
|
-
const settled = new Set(events.filter((e) => e.type === 'settled').map((e) => e.runid));
|
|
187
189
|
/** @type {Leg[]} */
|
|
188
190
|
const legs = [];
|
|
189
191
|
for (const row of rows) {
|
|
@@ -193,31 +195,60 @@ export function readLegs(opts = {}) {
|
|
|
193
195
|
// pid is a live bareloop runner, however quiet its spine — killed = process gone, not silence.
|
|
194
196
|
// An older row with no `pid` keeps the spine-mtime rule (`inFlightCapUsd`), never guessed alive.
|
|
195
197
|
const hasPid = Number.isInteger(row.pid);
|
|
196
|
-
|
|
197
|
-
|
|
198
|
+
// a resume's OWN claim never holds against itself: its earlier legs' spend counts, its cap is what is being checked
|
|
199
|
+
const ownClaim = opts.aboveRunid !== undefined && row.runid === opts.aboveRunid;
|
|
200
|
+
const heldCap = !ownClaim && hasPid && !isSettled(events, row) && typeof row.capUsd === 'number' && Number.isFinite(row.capUsd) && runIsAlive(row, nowMs) ? row.capUsd : null;
|
|
201
|
+
// a RESUMED run (`row.leg`) may hold a leg of this month under a start date of another: read its spine
|
|
202
|
+
if (opts.thisMonthOnly && heldCap === null && row.leg === undefined && !Number.isNaN(at.getTime()) && !sameLocalMonth(at, new Date(nowMs))) continue;
|
|
198
203
|
if (Number.isNaN(at.getTime()) || !existsSync(row.spine)) { legs.push({ ...base, reservedUsd: heldCap ?? 0 }); continue; }
|
|
199
204
|
/** @type {any[]} */
|
|
200
205
|
let records;
|
|
201
206
|
try { records = parseJsonl(row.spine).records; } catch { legs.push({ ...base, reservedUsd: heldCap ?? 0 }); continue; }
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
207
|
+
// P5-R: a resumed run is ONE spine of several legs and ONE listed row. The month and the Money tab still
|
|
208
|
+
// count each leg on its own date with its own spend (the figures they always read), so a leg resumed in a
|
|
209
|
+
// later month is that month's, never silently folded back into the month the run began in. The claim (the
|
|
210
|
+
// hold) belongs to the LATEST leg only.
|
|
211
|
+
const found = legsOf(records);
|
|
212
|
+
const spineLegs = found.length > 0 ? found : [{ leg: 1, records, start: null, jobStart: null }];
|
|
213
|
+
const lastLegNo = spineLegs.length;
|
|
214
|
+
const first = records.find((r) => r && r.type === 'job-start') ?? null;
|
|
215
|
+
for (const sl of spineLegs) {
|
|
216
|
+
const lstart = sl.jobStart ?? first;
|
|
217
|
+
const leg = legSpend(sl.records);
|
|
218
|
+
const prov = spendProvenance(sl.records);
|
|
219
|
+
const wall = legWall(sl.records);
|
|
220
|
+
const isLast = sl.leg === lastLegNo;
|
|
221
|
+
const legAt = sl.leg === 1 ? at : new Date(sl.start?.ts ?? sl.start?.at ?? row.at);
|
|
222
|
+
const ended = sl.records.some((r) => r && r.type === 'job-end');
|
|
223
|
+
legs.push({
|
|
224
|
+
at: Number.isNaN(legAt.getTime()) ? at : legAt,
|
|
225
|
+
model: typeof lstart?.model === 'string' ? lstart.model : null,
|
|
226
|
+
wallMs: wall.ms,
|
|
227
|
+
wallComplete: wall.complete,
|
|
228
|
+
provider: typeof lstart?.provider === 'string' ? lstart.provider : null,
|
|
229
|
+
baseUrl: typeof lstart?.baseUrl === 'string' ? lstart.baseUrl : null,
|
|
230
|
+
usd: leg.usd,
|
|
231
|
+
complete: leg.complete,
|
|
232
|
+
tokens: legTokens(sl.records),
|
|
233
|
+
vouchedRounds: prov.vouched.rounds,
|
|
234
|
+
otherRounds: prov.guessed.rounds + prov.unpriced.rounds + prov.unknown.rounds,
|
|
235
|
+
reservedUsd: !isLast || ownClaim ? leg.usd
|
|
236
|
+
: hasPid ? (ended || heldCap === null ? leg.usd : Math.max(leg.usd, heldCap))
|
|
237
|
+
: inFlightCapUsd(sl.records, row, nowMs, leg.usd),
|
|
238
|
+
unreadable: false,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
// a leg that CLAIMED (its `leg-start` is on the list) but has not yet written its marker: its claim still
|
|
242
|
+
// holds, exactly as a first leg's row does before its spine exists
|
|
243
|
+
if (row.leg !== undefined && row.leg > lastLegNo && heldCap !== null) legs.push({ ...base, reservedUsd: heldCap });
|
|
244
|
+
}
|
|
245
|
+
// drafting spend of panel sessions that never became a run (abandoned / refused / panel restarted): money
|
|
246
|
+
// already spent, counted by the SAME readers as a run's leg (src/draftspend.js — a signed run carries its own)
|
|
247
|
+
for (const d of readOrphanDraftSpends({ home: opts.home, sessionsRoot: opts.sessionsRoot })) {
|
|
206
248
|
legs.push({
|
|
207
|
-
at,
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
wallComplete: wall.complete,
|
|
211
|
-
provider: typeof start?.provider === 'string' ? start.provider : null,
|
|
212
|
-
baseUrl: typeof start?.baseUrl === 'string' ? start.baseUrl : null,
|
|
213
|
-
usd: leg.usd,
|
|
214
|
-
complete: leg.complete,
|
|
215
|
-
tokens: legTokens(records),
|
|
216
|
-
vouchedRounds: prov.vouched.rounds,
|
|
217
|
-
otherRounds: prov.guessed.rounds + prov.unpriced.rounds + prov.unknown.rounds,
|
|
218
|
-
reservedUsd: hasPid ? (records.some((r) => r && r.type === 'job-end') || heldCap === null ? leg.usd : Math.max(leg.usd, heldCap))
|
|
219
|
-
: inFlightCapUsd(records, row, nowMs, leg.usd),
|
|
220
|
-
unreadable: false,
|
|
249
|
+
at: new Date(d.startedAt), provider: d.provider, baseUrl: d.baseUrl, model: d.model, wallMs: null, wallComplete: false,
|
|
250
|
+
usd: d.spentUsd, complete: d.spendComplete, tokens: 0, vouchedRounds: 0, otherRounds: 0, reservedUsd: d.spentUsd,
|
|
251
|
+
unreadable: false, draft: true,
|
|
221
252
|
});
|
|
222
253
|
}
|
|
223
254
|
return legs;
|
|
@@ -231,7 +262,7 @@ export function readLegs(opts = {}) {
|
|
|
231
262
|
const sameLocalMonth = (at, now) => at.getFullYear() === now.getFullYear() && at.getMonth() === now.getMonth();
|
|
232
263
|
|
|
233
264
|
/**
|
|
234
|
-
* @param {{ home?: string, now?: () => number, aboveRunid?: string }} [opts] `aboveRunid`: see {@link readLegs}
|
|
265
|
+
* @param {{ home?: string, sessionsRoot?: string, now?: () => number, aboveRunid?: string }} [opts] `aboveRunid`, `sessionsRoot`: see {@link readLegs}
|
|
235
266
|
* @returns {{ usd: number, atLeast: boolean, runs: number, reservedUsd: number, heldUsd: number, heldRuns: number }} `usd` = real spend (the Money tab's figure); `reservedUsd` = the same with in-flight runs at their cap (the refusal check's); `heldUsd` = what in-progress runs set aside beyond what they have spent (`reservedUsd - usd`), over `heldRuns` runs
|
|
236
267
|
*/
|
|
237
268
|
export function monthSpend(opts = {}) {
|
|
@@ -242,11 +273,11 @@ export function monthSpend(opts = {}) {
|
|
|
242
273
|
let runs = 0;
|
|
243
274
|
let heldUsd = 0;
|
|
244
275
|
let heldRuns = 0;
|
|
245
|
-
for (const leg of readLegs({ home: opts.home, now: opts.now, aboveRunid: opts.aboveRunid, thisMonthOnly: true })) {
|
|
276
|
+
for (const leg of readLegs({ home: opts.home, sessionsRoot: opts.sessionsRoot, now: opts.now, aboveRunid: opts.aboveRunid, thisMonthOnly: true })) {
|
|
246
277
|
// an unreadable date could belong to this month — unknown, never dropped
|
|
247
278
|
if (Number.isNaN(leg.at.getTime())) { atLeast = true; continue; }
|
|
248
279
|
if (!sameLocalMonth(leg.at, nowDate)) continue;
|
|
249
|
-
runs += 1;
|
|
280
|
+
if (!leg.draft) runs += 1;
|
|
250
281
|
if (leg.unreadable) { atLeast = true; reservedUsd += leg.reservedUsd; if (leg.reservedUsd > 0) { heldUsd += leg.reservedUsd; heldRuns += 1; } continue; }
|
|
251
282
|
usd += leg.usd;
|
|
252
283
|
reservedUsd += leg.reservedUsd;
|
|
@@ -261,7 +292,7 @@ export function monthSpend(opts = {}) {
|
|
|
261
292
|
* (rows from `rowIdFor`; a run on no known row lands under its own provider name, never
|
|
262
293
|
* pooled into another row). Any leg with unknown spend makes the figure it belongs to
|
|
263
294
|
* an "at least".
|
|
264
|
-
* @param {{ home?: string, now?: () => number, rows?: readonly import('./providerrows.js').KeyRow[] }} [opts] `rows` = the Providers rows: when given, `tokensByRow` sums each row's tokens
|
|
295
|
+
* @param {{ home?: string, sessionsRoot?: string, now?: () => number, rows?: readonly import('./providerrows.js').KeyRow[] }} [opts] `rows` = the Providers rows: when given, `tokensByRow` sums each row's tokens
|
|
265
296
|
* @returns {{ tokensByRow: Record<string, number>, total: {usd: number, atLeast: boolean, tokens: number}, month: {usd: number, atLeast: boolean, tokens: number},
|
|
266
297
|
* byProvider: Record<string, {label: string, monthUsd: number, monthAtLeast: boolean, totalUsd: number, totalAtLeast: boolean, tokens: number, vouchedRounds: number, otherRounds: number, monthWallMs: number|null, monthWallAtLeast: boolean, totalWallMs: number|null, totalWallAtLeast: boolean}> }}
|
|
267
298
|
*/
|
|
@@ -276,7 +307,7 @@ export function spendSummary(opts = {}) {
|
|
|
276
307
|
const wallSeen = {};
|
|
277
308
|
/** total tokens of the runs each Providers row served (matched by shape + endpoint + model) @type {Record<string, number>} */
|
|
278
309
|
const tokensByRow = {};
|
|
279
|
-
for (const leg of readLegs({ home: opts.home })) {
|
|
310
|
+
for (const leg of readLegs({ home: opts.home, sessionsRoot: opts.sessionsRoot })) {
|
|
280
311
|
let served = opts.rows ? findRow(opts.rows, { provider: leg.provider, baseUrl: leg.baseUrl, model: leg.model }) : null;
|
|
281
312
|
// an older spine records a model but no provider: it counts toward the row whose Name EXACTLY
|
|
282
313
|
// equals that model. Two rows sharing the Name make it ambiguous, so it counts toward neither.
|
|
@@ -295,9 +326,11 @@ export function spendSummary(opts = {}) {
|
|
|
295
326
|
: (notRecorded ? (leg.model ? `not recorded (model ${leg.model})` : 'not recorded') : key.slice('other:'.length));
|
|
296
327
|
const p = (byProvider[key] ??= { label, monthUsd: 0, monthAtLeast: false, totalUsd: 0, totalAtLeast: false, tokens: 0, vouchedRounds: 0, otherRounds: 0, monthWallMs: 0, monthWallAtLeast: false, totalWallMs: 0, totalWallAtLeast: false });
|
|
297
328
|
const w = (wallSeen[key] ??= { monthRuns: 0, monthKnown: 0, totalKnown: 0 });
|
|
329
|
+
// drafting spend has no wall (it is not a run): it never turns a provider's wall into an "at least" or an unknown
|
|
330
|
+
const noWall = leg.draft === true;
|
|
298
331
|
const unknown = leg.unreadable || !leg.complete;
|
|
299
332
|
const wallKnown = !leg.unreadable && leg.wallMs !== null;
|
|
300
|
-
const wallAtLeast = !wallKnown || !leg.wallComplete;
|
|
333
|
+
const wallAtLeast = !noWall && (!wallKnown || !leg.wallComplete);
|
|
301
334
|
total.usd += leg.usd; total.tokens += leg.tokens; if (unknown) total.atLeast = true;
|
|
302
335
|
p.totalUsd += leg.usd; p.tokens += leg.tokens; p.vouchedRounds += leg.vouchedRounds; p.otherRounds += leg.otherRounds; if (unknown) p.totalAtLeast = true;
|
|
303
336
|
if (wallKnown) { p.totalWallMs = /** @type {number} */ (p.totalWallMs) + /** @type {number} */ (leg.wallMs); w.totalKnown += 1; }
|
|
@@ -305,7 +338,7 @@ export function spendSummary(opts = {}) {
|
|
|
305
338
|
if (inMonth) {
|
|
306
339
|
month.usd += leg.usd; month.tokens += leg.tokens; if (unknown) month.atLeast = true;
|
|
307
340
|
p.monthUsd += leg.usd; if (unknown) p.monthAtLeast = true;
|
|
308
|
-
w.monthRuns += 1;
|
|
341
|
+
if (!noWall) w.monthRuns += 1;
|
|
309
342
|
if (wallKnown) { p.monthWallMs = /** @type {number} */ (p.monthWallMs) + /** @type {number} */ (leg.wallMs); w.monthKnown += 1; }
|
|
310
343
|
if (wallAtLeast) p.monthWallAtLeast = true;
|
|
311
344
|
}
|
|
@@ -350,17 +383,17 @@ export function monthlyLimitOf(config, home) {
|
|
|
350
383
|
* number above 0 throws `ConfigError`.
|
|
351
384
|
* Compared in whole cents. Also throws `ConfigError` when config.json is unreadable — a gate
|
|
352
385
|
* whose own instrument is broken refuses; it never silently runs with no limit.
|
|
353
|
-
* @param {{ capUsd: number, home?: string, now?: () => number, aboveRunid?: string }} args `aboveRunid`: count only the claims listed above that run (a claim in progress)
|
|
386
|
+
* @param {{ capUsd: number, home?: string, sessionsRoot?: string, now?: () => number, aboveRunid?: string }} args `aboveRunid`: count only the claims listed above that run (a claim in progress)
|
|
354
387
|
* @returns {MonthlyRoom}
|
|
355
388
|
*/
|
|
356
|
-
export function checkMonthlyRoom({ capUsd, home, now, aboveRunid }) {
|
|
389
|
+
export function checkMonthlyRoom({ capUsd, home, now, aboveRunid, sessionsRoot }) {
|
|
357
390
|
const cfg = readConfig({ home });
|
|
358
391
|
if (cfg.problem) throw new ConfigError(cfg.problem);
|
|
359
392
|
const limit = monthlyLimitOf(cfg.config, home);
|
|
360
393
|
if (limit === null) {
|
|
361
394
|
return { ok: true, leftUsd: null, limitUsd: null, atLeast: false };
|
|
362
395
|
}
|
|
363
|
-
const spent = monthSpend({ home, now, aboveRunid });
|
|
396
|
+
const spent = monthSpend({ home, sessionsRoot, now, aboveRunid });
|
|
364
397
|
const leftCents = Math.max(0, Math.floor((limit - spent.reservedUsd) * 100 + 1e-6));
|
|
365
398
|
const capCents = Math.ceil(capUsd * 100 - 1e-6); // a non-finite cap never fits (NaN/Infinity compare false) — never read as $0
|
|
366
399
|
return { ok: capCents <= leftCents, leftUsd: leftCents / 100, limitUsd: limit, atLeast: spent.atLeast, heldUsd: spent.heldUsd, heldRuns: spent.heldRuns };
|
|
@@ -389,13 +422,14 @@ export function monthlyRefusalText(room) {
|
|
|
389
422
|
*/
|
|
390
423
|
export function settleDeadClaims({ home, by, aboveRunid }) {
|
|
391
424
|
const { rows, events } = readRunList({ home });
|
|
392
|
-
const settled = new Set(events.filter((e) => e.type === 'settled').map((e) => e.runid));
|
|
393
425
|
let n = 0;
|
|
394
426
|
for (const row of rowsAbove(rows, aboveRunid)) {
|
|
395
|
-
|
|
427
|
+
// the run's own claim is never closed by itself (a resume's own row stays in `rowsAbove` for its SPEND)
|
|
428
|
+
if (aboveRunid !== undefined && row.runid === aboveRunid) continue;
|
|
429
|
+
if (!Number.isInteger(row.pid) || isSettled(events, row) || isLiveRunner(/** @type {number} */ (row.pid))) continue;
|
|
396
430
|
let spentUsd = 0;
|
|
397
|
-
try { spentUsd = legSpend(parseJsonl(row.spine).records).usd; } catch { /* no spine: nothing was recorded spent */ }
|
|
398
|
-
appendRunEvent({ runid: row.runid, type: 'settled', by, reason: 'process gone', spentUsd, spendComplete: false, at: new Date().toISOString() }, { home });
|
|
431
|
+
try { spentUsd = legSpend(legsOf(parseJsonl(row.spine).records).at(-1)?.records ?? []).usd; } catch { /* no spine: nothing was recorded spent */ }
|
|
432
|
+
appendRunEvent({ runid: row.runid, type: 'settled', by, reason: 'process gone', spentUsd, spendComplete: false, at: new Date().toISOString(), ...(row.leg === undefined ? {} : { leg: row.leg }) }, { home });
|
|
399
433
|
n += 1;
|
|
400
434
|
}
|
|
401
435
|
return n;
|
|
@@ -409,20 +443,25 @@ export function settleDeadClaims({ home, by, aboveRunid }) {
|
|
|
409
443
|
* it appends `{type:'released', reason:'refused'}` and reports the refusal, nothing spent. No limit set =
|
|
410
444
|
* no claim (`claimed: false`; the caller lists the run as it always did). An unreadable config, or a row
|
|
411
445
|
* that cannot be written, throws `ConfigError` — the gate refuses, never reads "no limit".
|
|
412
|
-
*
|
|
446
|
+
* A RESUMED leg (`leg` 2+, P5-R) claims the same way but appends a `leg-start` beside the run's one row
|
|
447
|
+
* (the row itself is added only when the list has none), and a refusal gives back THAT leg's claim.
|
|
448
|
+
* @param {{ row: import('./runlist.js').RunRow, capUsd: number, home?: string, now?: () => number, leg?: number }} args
|
|
413
449
|
* @returns {{ room: MonthlyRoom, claimed: boolean }}
|
|
414
450
|
*/
|
|
415
|
-
export function claimRun({ row, capUsd, home, now }) {
|
|
451
|
+
export function claimRun({ row, capUsd, home, now, leg = 1 }) {
|
|
416
452
|
const cfg = readConfig({ home });
|
|
417
453
|
if (cfg.problem) throw new ConfigError(cfg.problem);
|
|
418
454
|
if (monthlyLimitOf(cfg.config, home) === null) return { room: { ok: true, leftUsd: null, limitUsd: null, atLeast: false }, claimed: false };
|
|
419
|
-
try {
|
|
455
|
+
try {
|
|
456
|
+
appendRun(row, { home });
|
|
457
|
+
if (leg > 1) appendLegStart({ type: 'leg-start', runid: row.runid, leg, pid: row.pid, capUsd: row.capUsd, at: new Date().toISOString() }, { home });
|
|
458
|
+
} catch (/** @type {any} */ e) {
|
|
420
459
|
throw new ConfigError(`cannot write this run's claim to the run list (${e.message})`);
|
|
421
460
|
}
|
|
422
461
|
settleDeadClaims({ home, by: row.runid, aboveRunid: row.runid });
|
|
423
462
|
const room = checkMonthlyRoom({ capUsd, home, now, aboveRunid: row.runid });
|
|
424
463
|
if (!room.ok) {
|
|
425
|
-
try { appendRunEvent({ runid: row.runid, type: 'released', by: row.runid, reason: 'refused', at: new Date().toISOString() }, { home }); } catch { /* the refusal stands; a stranded row of a dead process is closed by the next run */ }
|
|
464
|
+
try { appendRunEvent({ runid: row.runid, type: 'released', by: row.runid, reason: 'refused', at: new Date().toISOString(), ...(leg > 1 ? { leg } : {}) }, { home }); } catch { /* the refusal stands; a stranded row of a dead process is closed by the next run */ }
|
|
426
465
|
return { room, claimed: false };
|
|
427
466
|
}
|
|
428
467
|
return { room, claimed: true };
|
package/src/movefile.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Move a file between two paths that may sit on DIFFERENT filesystems.
|
|
2
|
+
// `renameSync` cannot cross a device (EXDEV): a worktree inside the person's own repo can be on /tmp or
|
|
3
|
+
// another drive while the spine / output dir stays under ~/.config. EXDEV only falls back to copy + unlink;
|
|
4
|
+
// every other error rethrows untouched. Renames inside one directory (atomic tmp -> final writes) don't need this.
|
|
5
|
+
import { renameSync, copyFileSync, unlinkSync } from 'node:fs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {string} src
|
|
9
|
+
* @param {string} dst
|
|
10
|
+
* @param {{ rename?: (a: string, b: string) => void }} [deps] test seam: the rename to try first
|
|
11
|
+
* @returns {void}
|
|
12
|
+
*/
|
|
13
|
+
export function moveFile(src, dst, deps = {}) {
|
|
14
|
+
const rename = deps.rename ?? renameSync;
|
|
15
|
+
try {
|
|
16
|
+
rename(src, dst);
|
|
17
|
+
} catch (e) {
|
|
18
|
+
if (/** @type {NodeJS.ErrnoException} */ (e)?.code !== 'EXDEV') throw e;
|
|
19
|
+
copyFileSync(src, dst);
|
|
20
|
+
unlinkSync(src);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// P6 item 3 (signed 2026-10-05): bareloop installs a JS repo's packages ITSELF, $0, before any token,
|
|
2
|
+
// for the one case whose command is exact and offline-reproducible: a committed `package-lock.json` /
|
|
3
|
+
// `npm-shrinkwrap.json` -> `npm ci --ignore-scripts`. It is the panel door's own step, never a verb the
|
|
4
|
+
// agent is granted (the tool menu is untouched): the agent never sees, asks for, or triggers it.
|
|
5
|
+
// Anything else (another lock file, no lock file, npm missing, a failure, a timeout) returns `ok:false`
|
|
6
|
+
// and the caller falls back to the install-needed wait with the real command.
|
|
7
|
+
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import { spawnClose, CLOSE_ENV_DENY } from './ralph.js';
|
|
10
|
+
import { redactSecrets } from './validate.js';
|
|
11
|
+
|
|
12
|
+
/** Wall-clock cap for one `npm ci`: 10 minutes. A cold install of a large repo is minutes, never an hour;
|
|
13
|
+
* past this the person is better served by the printed command in their own terminal. Operator-set. */
|
|
14
|
+
export const NPM_CI_TIMEOUT_MS = 600_000;
|
|
15
|
+
|
|
16
|
+
/** the lock files whose gap command is `npm ci` (the rule table in src/source.js, LOCKFILE_COMMANDS) */
|
|
17
|
+
export const NPM_CI_LOCKS = Object.freeze(['package-lock.json', 'npm-shrinkwrap.json']);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The env npm sees: the panel's own env minus every credential `CLOSE_ENV_DENY` names (never re-spelled).
|
|
21
|
+
* `npm ci` needs no provider key; a private registry token (NPM_TOKEN) is stripped too, so such a repo
|
|
22
|
+
* simply falls back to the person's own install.
|
|
23
|
+
* @param {NodeJS.ProcessEnv} base
|
|
24
|
+
* @returns {NodeJS.ProcessEnv}
|
|
25
|
+
*/
|
|
26
|
+
export function npmEnv(base) {
|
|
27
|
+
const env = { ...base };
|
|
28
|
+
delete env.NODE_TEST_CONTEXT;
|
|
29
|
+
for (const name of Object.keys(env)) {
|
|
30
|
+
if (CLOSE_ENV_DENY.names.includes(name)
|
|
31
|
+
|| CLOSE_ENV_DENY.prefixes.some((p) => name.startsWith(p))
|
|
32
|
+
|| CLOSE_ENV_DENY.shape.test(name)) delete env[name];
|
|
33
|
+
}
|
|
34
|
+
return env;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Run `npm ci --ignore-scripts` in `cwd`. Async (never blocks the panel's event loop); argv array, no shell.
|
|
39
|
+
* @param {string} cwd the directory holding package.json + the lock file
|
|
40
|
+
* @param {{timeoutMs?: number, env?: NodeJS.ProcessEnv, run?: typeof spawnClose}} [o] `run` is the test seam
|
|
41
|
+
* @returns {Promise<{ok: true}|{ok: false, reason: string}>}
|
|
42
|
+
*/
|
|
43
|
+
export async function runNpmCi(cwd, { timeoutMs = NPM_CI_TIMEOUT_MS, env = process.env, run = spawnClose } = {}) {
|
|
44
|
+
const r = await run('npm', ['ci', '--ignore-scripts'], { env: npmEnv(env), cwd, timeoutMs });
|
|
45
|
+
if (r.error) {
|
|
46
|
+
const code = r.error.code;
|
|
47
|
+
if (code === 'ETIMEDOUT') return { ok: false, reason: `timed out after ${Math.round(timeoutMs / 1000)}s` };
|
|
48
|
+
if (code === 'ENOENT') return existsSync(cwd) ? { ok: false, reason: 'npm was not found' } : { ok: false, reason: 'the install folder does not exist' };
|
|
49
|
+
return { ok: false, reason: redactSecrets(String(r.error.message ?? r.error)).slice(0, 200) };
|
|
50
|
+
}
|
|
51
|
+
if (r.status === 0) return { ok: true };
|
|
52
|
+
const lines = String(r.stderr || r.stdout || '').split('\n').map((l) => l.trim()).filter(Boolean);
|
|
53
|
+
const last = redactSecrets(lines[lines.length - 1] ?? '').slice(0, 200);
|
|
54
|
+
return { ok: false, reason: last || (r.status === null ? `killed by ${r.signal ?? 'a signal'}` : `exit ${r.status}`) };
|
|
55
|
+
}
|