bareloop 0.30.0 → 0.32.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 +154 -0
- package/bareloop.context.md +205 -10
- package/bin/bareloop.mjs +0 -1
- package/package.json +1 -1
- package/src/authorflow.js +22 -42
- package/src/authorjob.js +5 -8
- package/src/authorrun.js +42 -16
- package/src/cli.js +12 -6
- package/src/config.js +135 -0
- package/src/interviewrun.js +10 -2
- package/src/keysfile.js +156 -0
- package/src/ledger.js +26 -0
- package/src/monthly.js +352 -0
- package/src/panel/authorroutes.js +328 -0
- package/src/panel/authorsession.js +555 -0
- package/src/panel/index.html +1247 -50
- package/src/panel/server.js +449 -51
- package/src/panel/settingsroutes.js +230 -0
- package/src/planrun.js +61 -5
- package/src/promptregisters.js +3 -0
- package/src/providerrows.js +217 -0
- package/src/providers.js +84 -0
- package/src/replay.js +80 -2
- package/src/replayio.js +14 -3
- package/src/run.js +48 -2
- package/src/runlist.js +13 -0
- package/src/userrun.js +134 -13
- package/types/authorflow.d.ts +4 -21
- package/types/authorrun.d.ts +5 -0
- package/types/cli.d.ts +2 -1
- package/types/config.d.ts +48 -0
- package/types/interviewrun.d.ts +5 -0
- package/types/keysfile.d.ts +90 -0
- package/types/ledger.d.ts +14 -0
- package/types/monthly.d.ts +192 -0
- package/types/panel/authorroutes.d.ts +67 -0
- package/types/panel/authorsession.d.ts +53 -0
- package/types/panel/server.d.ts +42 -5
- package/types/panel/settingsroutes.d.ts +14 -0
- package/types/providerrows.d.ts +164 -0
- package/types/providers.d.ts +34 -0
- package/types/replay.d.ts +31 -0
- package/types/replayio.d.ts +12 -1
- package/types/run.d.ts +26 -1
- package/types/runlist.d.ts +1 -0
- package/types/userrun.d.ts +22 -0
package/src/panel/server.js
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
// `src/cli.js` calls — `src/replayio.js`'s read side and `src/runlist.js`'s
|
|
6
6
|
// run list — never a re-implementation of either.
|
|
7
7
|
//
|
|
8
|
-
// READ-ONLY, BY CONSTRUCTION: GET/HEAD only (anything else -> 405)
|
|
9
|
-
//
|
|
8
|
+
// READ-ONLY, BY CONSTRUCTION: GET/HEAD only (anything else -> 405) EXCEPT the two
|
|
9
|
+
// human-click-guarded route families in their own modules (`/api/author/*`,
|
|
10
|
+
// `/api/settings/*` — P3/P4a); no endpoint here runs a job, spends money, signs, or
|
|
11
|
+
// returns a key/.env value. Nothing
|
|
10
12
|
// here imports `src/providers.js` or touches `process.env` for a secret —
|
|
11
13
|
// grepped before writing this file, and the same discipline is kept here.
|
|
12
14
|
//
|
|
@@ -28,18 +30,23 @@ import {
|
|
|
28
30
|
dirname, join, basename, relative, isAbsolute, sep,
|
|
29
31
|
} from 'node:path';
|
|
30
32
|
import { fileURLToPath } from 'node:url';
|
|
31
|
-
import { readRunList } from '../runlist.js';
|
|
33
|
+
import { readRunList, DIED_MTIME_MS } from '../runlist.js';
|
|
32
34
|
import {
|
|
33
35
|
replayOne, parseJsonl, resolveSiblings, isSidecarByName,
|
|
34
36
|
} from '../replayio.js';
|
|
35
37
|
import { summarizeForAllLine, auditWindow } from '../replay.js';
|
|
36
38
|
import { runBehaviour } from '../behaviour.js';
|
|
37
|
-
import { SPEND_RECORD_TYPES } from '../ledger.js';
|
|
39
|
+
import { SPEND_RECORD_TYPES, floorsFromRecords } from '../ledger.js';
|
|
38
40
|
import { jobSpecHash } from '../job.js';
|
|
39
41
|
import { confirmProtections } from '../authorflow.js';
|
|
42
|
+
import { createAuthorRoutes, mintToken } from './authorroutes.js';
|
|
43
|
+
import { createSettingsRoutes } from './settingsroutes.js';
|
|
40
44
|
|
|
41
45
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
42
46
|
|
|
47
|
+
/** Largest POST body the panel buffers (job cards are short prose); a bigger one is refused with 413. */
|
|
48
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
49
|
+
|
|
43
50
|
/**
|
|
44
51
|
* A runid, as it appears in a URL path segment — never a raw filesystem
|
|
45
52
|
* path. `~` is included so a backfill-disambiguated runid (F197:
|
|
@@ -130,17 +137,10 @@ export function checkTypeTitle(verdictType, atIso) {
|
|
|
130
137
|
return null;
|
|
131
138
|
}
|
|
132
139
|
|
|
133
|
-
// DIED (hamr's ruling B, 2026-09-25): a run with no `job-end` never
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
// machine slept) and gets its own [?] glyph. The distinguishing signal is
|
|
138
|
-
// the spine FILE's own mtime, never wall-clock "now minus job-start" (a
|
|
139
|
-
// resumed/paused run can legitimately sit quiet for a long time without
|
|
140
|
-
// having died): still fresh (written to within this window) reads as the
|
|
141
|
-
// existing `running` [▶] state; older than this reads as died. Tighten-only,
|
|
142
|
-
// named so a future change is a deliberate, visible edit.
|
|
143
|
-
export const DIED_MTIME_MS = 10 * 60 * 1000;
|
|
140
|
+
// DIED (hamr's ruling B, 2026-09-25): a run with no `job-end` is `died` [?], never [✗], once its
|
|
141
|
+
// spine file's mtime is older than DIED_MTIME_MS — the constant and its full comment live in
|
|
142
|
+
// src/runlist.js (the monthly limit reads the same rule); re-exported here for the panel's callers.
|
|
143
|
+
export { DIED_MTIME_MS };
|
|
144
144
|
|
|
145
145
|
/**
|
|
146
146
|
* Plain-words description of ONE spine record, for the died "why" sentence's
|
|
@@ -241,29 +241,21 @@ function deriveDeath(spinePath, records, outcome) {
|
|
|
241
241
|
died: false, why: null, spendFloorUsd: null, wallFloorMs: null,
|
|
242
242
|
};
|
|
243
243
|
if (outcome !== null && outcome !== undefined) return notDied; // a real job-end was reached
|
|
244
|
+
// no job-end yet — the floor derivation is identical whether this turns
|
|
245
|
+
// out to be a died run or one that is genuinely still running (build item
|
|
246
|
+
// 6): computed here, once, before the died/still-running branch below.
|
|
247
|
+
const floors = floorsFromRecords(records);
|
|
244
248
|
let mtimeMs;
|
|
245
|
-
try { mtimeMs = statSync(spinePath).mtimeMs; } catch { return notDied; }
|
|
246
|
-
if (Date.now() - mtimeMs <= DIED_MTIME_MS) return notDied; // still fresh — genuinely `running`, not died
|
|
249
|
+
try { mtimeMs = statSync(spinePath).mtimeMs; } catch { return { ...notDied, ...floors }; }
|
|
250
|
+
if (Date.now() - mtimeMs <= DIED_MTIME_MS) return { ...notDied, ...floors }; // still fresh — genuinely `running`, not died
|
|
247
251
|
|
|
248
252
|
const withTs = records.filter((r) => r && typeof r === 'object' && typeof r.ts === 'string');
|
|
249
253
|
const last = withTs.length ? withTs[withTs.length - 1] : null;
|
|
250
254
|
const when = last ? formatTimestamp(last.ts) : 'an unknown time';
|
|
251
255
|
const why = `died — no ending was recorded (killed, crashed, or the machine slept). Last thing it did: ${describeLastRecord(last)} at ${when}.`;
|
|
252
256
|
|
|
253
|
-
let spendSum = 0;
|
|
254
|
-
let pricedCount = 0;
|
|
255
|
-
for (const r of records) {
|
|
256
|
-
if (!r || typeof r !== 'object' || !SPEND_RECORD_TYPES.includes(r.type)) continue; // worker-result echoes excluded by construction — not a spend type
|
|
257
|
-
if (typeof r.costUsd === 'number' && Number.isFinite(r.costUsd)) { spendSum += r.costUsd; pricedCount += 1; }
|
|
258
|
-
}
|
|
259
|
-
const spendFloorUsd = pricedCount > 0 ? spendSum : null;
|
|
260
|
-
|
|
261
|
-
const firstMs = withTs.length ? Date.parse(withTs[0].ts) : NaN;
|
|
262
|
-
const lastMs = withTs.length ? Date.parse(withTs[withTs.length - 1].ts) : NaN;
|
|
263
|
-
const wallFloorMs = Number.isFinite(firstMs) && Number.isFinite(lastMs) && lastMs >= firstMs ? lastMs - firstMs : null;
|
|
264
|
-
|
|
265
257
|
return {
|
|
266
|
-
died: true, why,
|
|
258
|
+
died: true, why, ...floors,
|
|
267
259
|
};
|
|
268
260
|
}
|
|
269
261
|
|
|
@@ -322,9 +314,24 @@ function summarizeRow(row) {
|
|
|
322
314
|
model: summary.model,
|
|
323
315
|
// died: never "unknown" when a priced round exists — glyph [?] already
|
|
324
316
|
// carries the word "died", so the row meta text itself never repeats it.
|
|
317
|
+
// `spend` is the LIBRARY's own 4-decimal string (never changed here —
|
|
318
|
+
// `--all`'s CLI listing reads the same `summarizeForAllLine`, F: "do not
|
|
319
|
+
// change the library's CLI output"). The panel page renders its own
|
|
320
|
+
// 2-decimal text off the numeric fields below instead of this string.
|
|
325
321
|
spend: death.died ? (death.spendFloorUsd !== null ? `at least $${death.spendFloorUsd.toFixed(4)}` : 'unknown') : line.spend,
|
|
326
322
|
wall: death.died ? (death.wallFloorMs !== null ? `at least ${formatDurationMs(death.wallFloorMs)}` : 'unknown') : line.wall,
|
|
327
323
|
date: typeof row.at === 'string' ? row.at.slice(0, 10) : null,
|
|
324
|
+
// numeric fields for the panel's own 2-decimal render (hamr's ruling
|
|
325
|
+
// 2026-09-28, "panel money 2-decimals") — `spentUsd`/`spendComplete` are
|
|
326
|
+
// `null`/`false` on a died row (no job-end, see `spendFloorUsd` instead);
|
|
327
|
+
// `draftSpentUsd`/`draftSpendComplete` are `null` when this run carried
|
|
328
|
+
// no drafting fold at all, exactly like `summary.draftSpentUsd` itself.
|
|
329
|
+
spentUsd: death.died ? null : summary.spentUsd,
|
|
330
|
+
spendComplete: death.died ? false : summary.spendComplete,
|
|
331
|
+
spendFloorUsd: death.died ? death.spendFloorUsd : null,
|
|
332
|
+
draftSpentUsd: summary.draftSpentUsd,
|
|
333
|
+
draftSpendComplete: summary.draftSpendComplete,
|
|
334
|
+
budgetUsd: summary.budgetUsd,
|
|
328
335
|
};
|
|
329
336
|
}
|
|
330
337
|
|
|
@@ -375,9 +382,19 @@ export function getRunDetail(runid, opts = {}) {
|
|
|
375
382
|
runid, job: row.job, at: row.at, via: row.via, fileMissing: true,
|
|
376
383
|
};
|
|
377
384
|
}
|
|
378
|
-
|
|
379
|
-
|
|
385
|
+
// build item 7 (2026-09-28): resolve the audit path through the ONE
|
|
386
|
+
// shared resolver (`resolveAuditPathForRow`, already the sole owner for
|
|
387
|
+
// `scopedBehaviour`/`getRunAudit`) BEFORE calling `replayOne`, and hand it
|
|
388
|
+
// the result — otherwise `replayOne`'s own internal resolution only ever
|
|
389
|
+
// finds the FINISHED-run convention, so a still-LIVE run's part-level
|
|
390
|
+
// `byTool`/`toolCalls` (summary.parts, read from `replayRun`) read
|
|
391
|
+
// "unknown" even though the SAME run's Run-tab tools/cache summary and
|
|
392
|
+
// Audit tab (both driven through `resolveAuditPathForRow` already) can
|
|
393
|
+
// see the during-run sidecar just fine.
|
|
380
394
|
const rawSpineRecords = parseJsonl(row.spine).records;
|
|
395
|
+
const auditPath = resolveAuditPathForRow(row, rawSpineRecords);
|
|
396
|
+
const summary = replayOne(row.spine, { auditPathOverride: auditPath });
|
|
397
|
+
const timelineKind = summary.timelineKind;
|
|
381
398
|
const death = deriveDeath(row.spine, rawSpineRecords, summary.outcome);
|
|
382
399
|
// judgeModel (Summary box "judge:" line): the FIRST `judge-round`'s own
|
|
383
400
|
// `model` field (src/planrun.js:1704's `onJudgeCost` emit) — a soft-green
|
|
@@ -509,12 +526,24 @@ export function getRunDetail(runid, opts = {}) {
|
|
|
509
526
|
died: death.died,
|
|
510
527
|
stopReason: death.died ? death.why : summary.stopReason,
|
|
511
528
|
spentUsd: summary.spentUsd,
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
// `
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
529
|
+
// draftSpentUsd (hamr's ruling 2026-09-28) — the drafting share of this
|
|
530
|
+
// run's cap, or null when this run carried none. `src/replay.js`'s
|
|
531
|
+
// `moneyWithDraft` is the one place that decides how to render it.
|
|
532
|
+
draftSpentUsd: summary.draftSpentUsd,
|
|
533
|
+
// draftSpendComplete (hamr's ruling 2026-09-28, 2nd addendum) — whether
|
|
534
|
+
// the drafting share above was EXACT; `null` when there is no drafting
|
|
535
|
+
// share to qualify at all. Read by the panel's own `panelMoneyWithDraft`.
|
|
536
|
+
draftSpendComplete: summary.draftSpendComplete,
|
|
537
|
+
// a spend/wall floor summed from real priced rounds/timestamped records
|
|
538
|
+
// present in the file — never null/unknown when at least one priced
|
|
539
|
+
// round exists. `deriveDeath` already returns `null` for both whenever
|
|
540
|
+
// a real job-end WAS reached (the normal `spentUsd`/`wallMs` fields
|
|
541
|
+
// above are the real, complete figures there); build item 6
|
|
542
|
+
// (2026-09-28) widened this from "died runs only" to ALSO cover a
|
|
543
|
+
// genuinely still-running spine, so the Run tab can show a running
|
|
544
|
+
// floor instead of "unknown" while a run is live.
|
|
545
|
+
spendFloorUsd: death.spendFloorUsd,
|
|
546
|
+
wallFloorMs: death.wallFloorMs,
|
|
518
547
|
spendComplete: summary.spendComplete,
|
|
519
548
|
wallMs: summary.wallMs,
|
|
520
549
|
timelineKind,
|
|
@@ -529,9 +558,15 @@ export function getRunDetail(runid, opts = {}) {
|
|
|
529
558
|
// parts (panel build item B/C, 2026-09-26): the ONE ordered part list
|
|
530
559
|
// (`src/replay.js`'s own field, computed once server-side) — drives the
|
|
531
560
|
// Run tab's map+cards and the Audit tab's grouped rows. Passed through
|
|
532
|
-
// VERBATIM (never re-derived client-side)
|
|
533
|
-
//
|
|
534
|
-
|
|
561
|
+
// VERBATIM (never re-derived client-side) EXCEPT each attempt's own
|
|
562
|
+
// declared-close `stages` array, which gets its per-stage `kind`/
|
|
563
|
+
// `direction`/`baselineKind` attached here (build item, 2026-09-28: the
|
|
564
|
+
// gap 764a9ef named — the signed closeDecl is the only place this lives,
|
|
565
|
+
// and it is not part of `replayOne`'s own spine-derived shape) — see
|
|
566
|
+
// {@link enrichPartsWithStageKind}. `kindMeta` is `null` (every stage
|
|
567
|
+
// passes through unchanged) whenever no spec resolves at all, so the two
|
|
568
|
+
// tabs still never disagree about order/counts/blocked-call figures.
|
|
569
|
+
parts: enrichPartsWithStageKind(summary.parts, stageKindMetaFromSpec(resolveSpecForRow(row))),
|
|
535
570
|
replans: summary.replans,
|
|
536
571
|
close: summary.close,
|
|
537
572
|
branch: summary.branch,
|
|
@@ -1121,6 +1156,278 @@ function sourceNearSpine(spinePath) {
|
|
|
1121
1156
|
};
|
|
1122
1157
|
}
|
|
1123
1158
|
|
|
1159
|
+
/**
|
|
1160
|
+
* The resolved SPEC OBJECT for a run — routes (a) bundle `spec.json`, (b)
|
|
1161
|
+
* `jobs/<job>.json`, (c) the run's own `resolved-spec.json` beside the spine
|
|
1162
|
+
* (the three real-spec routes {@link getRunJob}'s `fromSpec` also tries, in
|
|
1163
|
+
* the same order; never route (d), the run's own job-start record, which
|
|
1164
|
+
* carries no `closeDecl` for {@link stageKindMetaFromSpec} to read a
|
|
1165
|
+
* stage's kind from). A specHash mismatch against `jobs/<job>.json` is NOT
|
|
1166
|
+
* checked here — the Job tab's own mismatch note is a display concern of
|
|
1167
|
+
* that endpoint, not a reason to withhold a stage's kind (the declaration a
|
|
1168
|
+
* hash mismatch flags is still the one the close actually ran under until
|
|
1169
|
+
* the run is repeated). `null` when no route resolves or every candidate
|
|
1170
|
+
* fails to parse as an object — never a guessed spec.
|
|
1171
|
+
* @param {{ spine: string, job: string }} row
|
|
1172
|
+
* @returns {any|null}
|
|
1173
|
+
*/
|
|
1174
|
+
function resolveSpecForRow(row) {
|
|
1175
|
+
if (!existsSync(row.spine)) return null;
|
|
1176
|
+
const bundleDir = bundleDirForSpine(row.spine);
|
|
1177
|
+
const bundleSpecPath = bundleDir ? join(bundleDir, 'spec.json') : null;
|
|
1178
|
+
if (bundleSpecPath && existsSync(bundleSpecPath)) {
|
|
1179
|
+
try {
|
|
1180
|
+
const spec = JSON.parse(readFileSync(bundleSpecPath, 'utf8'));
|
|
1181
|
+
if (spec && typeof spec === 'object') return spec;
|
|
1182
|
+
} catch { /* falls through to the next route */ }
|
|
1183
|
+
}
|
|
1184
|
+
const jobsSpecPath = join(jobsDir(), `${row.job}.json`);
|
|
1185
|
+
if (existsSync(jobsSpecPath)) {
|
|
1186
|
+
try {
|
|
1187
|
+
const spec = JSON.parse(readFileSync(jobsSpecPath, 'utf8'));
|
|
1188
|
+
if (spec && typeof spec === 'object') return spec;
|
|
1189
|
+
} catch { /* falls through to the next route */ }
|
|
1190
|
+
}
|
|
1191
|
+
const near = sourceNearSpine(row.spine);
|
|
1192
|
+
if (near.specPath) {
|
|
1193
|
+
try {
|
|
1194
|
+
const spec = JSON.parse(readFileSync(near.specPath, 'utf8'));
|
|
1195
|
+
if (spec && typeof spec === 'object') return spec;
|
|
1196
|
+
} catch { /* no spec resolvable */ }
|
|
1197
|
+
}
|
|
1198
|
+
return null;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* THE PLAIN-QUESTION OWNER (plain-checks build, 2026-09-28, hamr's ruling:
|
|
1203
|
+
* the "checks N/M" headline is WITHDRAWN — "confusing, reads like 6 failed
|
|
1204
|
+
* when 5 never ran"). ONE pure function mapping a declared stage's own
|
|
1205
|
+
* signed `kind`+`params` (plus the close's own `genre`, also a signed field)
|
|
1206
|
+
* to a short plain-English question a person can read without knowing the
|
|
1207
|
+
* catalogue — never a second spelling on the client, and never anything the
|
|
1208
|
+
* model authored (the catalogue's kinds/params are the whole vocabulary this
|
|
1209
|
+
* reads).
|
|
1210
|
+
*
|
|
1211
|
+
* Every branch is traced straight off `src/kinds.js`'s runner semantics and
|
|
1212
|
+
* the TYPES genre template (`src/authoring.js` `TYPES_GENRE_TEMPLATE`,
|
|
1213
|
+
* `classGuards`'s `MECHANICAL_GUARDS`) — never guessed:
|
|
1214
|
+
* - `files-changed` is always the `changed-from-seed` guard: "did it
|
|
1215
|
+
* change any file?"
|
|
1216
|
+
* - `pattern-absent-in-diff` is always the `no-suppressions` guard: "no
|
|
1217
|
+
* casts or silencers added?"
|
|
1218
|
+
* - `command-exit` is the genre's `suite-green` stage (the only shipped use
|
|
1219
|
+
* of this kind): "does the test suite pass?"
|
|
1220
|
+
* - `count-not-worse` splits three ways on its own declared
|
|
1221
|
+
* `direction`/`baseline` (never the runtime number — see
|
|
1222
|
+
* {@link stageKindMetaFromSpec}'s own doc for why the baseline KIND must
|
|
1223
|
+
* travel separately from the measured figure):
|
|
1224
|
+
* - `higher-is-better` + `baseline: 'seed'` is the genre's `tests-kept`
|
|
1225
|
+
* floor: "did all the old tests still exist?"
|
|
1226
|
+
* - `lower-is-better` + `baseline: 0` is the genre's in-scope
|
|
1227
|
+
* `typecheck` stage — UNLESS its own `cmd`+`args` is the SAME
|
|
1228
|
+
* command a `command-exit` stage in the same close already runs
|
|
1229
|
+
* (`suiteCmdKeys`, below): the TYPES template's `suite-green` is "the
|
|
1230
|
+
* suite exits clean AND reports zero failing tests — TWO assertions"
|
|
1231
|
+
* over ONE population (the same `npm test`/`pytest` invocation), so a
|
|
1232
|
+
* `count-not-worse` stage sharing that exact command is the failing-
|
|
1233
|
+
* test-count half of that pair, never a typecheck stage: "no failing
|
|
1234
|
+
* tests?" — derived from the same cmd+args correlation (the stage
|
|
1235
|
+
* counts the suite's own failing tests), never from the stage's own
|
|
1236
|
+
* name string. The live check that found this (run mul5fofw's own
|
|
1237
|
+
* `suite-zero-failing-tests`, cmd `npm test`, same as its sibling
|
|
1238
|
+
* `suite-green` command-exit stage) would otherwise have read "test/
|
|
1239
|
+
* has 0 type errors?", which is false: its parser counts FAILING
|
|
1240
|
+
* TESTS (`^# fail (\d+)$`), not type-checker output. hamr's ruling:
|
|
1241
|
+
* name this half honestly rather than reusing "type errors" for a
|
|
1242
|
+
* population it was never about, or falling back to the stage's raw
|
|
1243
|
+
* name. When the command differs from every sibling `command-exit`
|
|
1244
|
+
* stage, the declared `scope.includePrefixes` naming exactly ONE path
|
|
1245
|
+
* is quoted verbatim (a signed param, never invented): "<path> has 0
|
|
1246
|
+
* type errors?" — otherwise the generic "type errors is 0?", never a
|
|
1247
|
+
* guessed filename.
|
|
1248
|
+
* - `lower-is-better` + `baseline: 'seed'` with a non-empty
|
|
1249
|
+
* `scope.excludePrefixes` is the genre's `typecheck-outside` ceiling:
|
|
1250
|
+
* "other files didn't get more type errors?"
|
|
1251
|
+
* The "type errors" wording is genre-owned (`GENRE_WHAT`), because TYPES is
|
|
1252
|
+
* the only genre this catalogue admits today (doc-genre kinds are not yet
|
|
1253
|
+
* built) — an unrecognised genre, or a `count-not-worse` shape none of the
|
|
1254
|
+
* three above matches, returns `null` rather than invent a fourth shape.
|
|
1255
|
+
*
|
|
1256
|
+
* `null` means "no honest plain question" — the caller (buildAttemptsList,
|
|
1257
|
+
* partLine1Text's `attemptChecksLine`) falls back to the stage's OWN name,
|
|
1258
|
+
* exactly as it already does for a stage with no resolvable kind at all.
|
|
1259
|
+
* @param {{kind: string|null, params: any, genre: string|null, suiteCmdKeys: Set<string>}} o
|
|
1260
|
+
* @returns {string|null}
|
|
1261
|
+
*/
|
|
1262
|
+
const GENRE_WHAT = Object.freeze({ TYPES: 'type errors' });
|
|
1263
|
+
|
|
1264
|
+
/** `cmd`+`args`, as a comparable key — the one signal (besides `kind`
|
|
1265
|
+
* itself) that two stages measure the SAME command's output, used to tell a
|
|
1266
|
+
* genuine typecheck stage apart from the TYPES genre's other `lower-is-
|
|
1267
|
+
* better`/`baseline: 0` stage (the failing-test-count half of `suite-green`'s
|
|
1268
|
+
* "two assertions"), which shares that stage's own shape but not its
|
|
1269
|
+
* population. `null` for a stage whose `cmd` isn't a non-empty string —
|
|
1270
|
+
* never matches anything, so it never falsely suppresses a real typecheck
|
|
1271
|
+
* question.
|
|
1272
|
+
* @param {any} params @returns {string|null} */
|
|
1273
|
+
function cmdKey(params) {
|
|
1274
|
+
if (!params || typeof params.cmd !== 'string' || params.cmd.length === 0) return null;
|
|
1275
|
+
const args = Array.isArray(params.args) ? params.args : [];
|
|
1276
|
+
return JSON.stringify([params.cmd, args]);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function stageQuestionText({
|
|
1280
|
+
kind, params, genre, suiteCmdKeys,
|
|
1281
|
+
}) {
|
|
1282
|
+
if (kind === 'files-changed') return 'did it change any file?';
|
|
1283
|
+
if (kind === 'pattern-absent-in-diff') return 'no casts or silencers added?';
|
|
1284
|
+
if (kind === 'command-exit') return 'does the test suite pass?';
|
|
1285
|
+
if (kind !== 'count-not-worse' || !params || typeof params !== 'object') return null;
|
|
1286
|
+
const what = Object.hasOwn(GENRE_WHAT, String(genre)) ? GENRE_WHAT[String(genre)] : null;
|
|
1287
|
+
if (params.direction === 'higher-is-better' && params.baseline === 'seed') {
|
|
1288
|
+
return 'did all the old tests still exist?';
|
|
1289
|
+
}
|
|
1290
|
+
if (!what) return null;
|
|
1291
|
+
const scope = params.scope && typeof params.scope === 'object' ? params.scope : null;
|
|
1292
|
+
if (params.direction === 'lower-is-better' && params.baseline === 0) {
|
|
1293
|
+
const key = cmdKey(params);
|
|
1294
|
+
if (key !== null && suiteCmdKeys instanceof Set && suiteCmdKeys.has(key)) return 'no failing tests?';
|
|
1295
|
+
const include = scope && Array.isArray(scope.includePrefixes) ? scope.includePrefixes : null;
|
|
1296
|
+
if (include && include.length === 1 && typeof include[0] === 'string' && include[0].length > 0) {
|
|
1297
|
+
return `${include[0]} has 0 ${what}?`;
|
|
1298
|
+
}
|
|
1299
|
+
return `${what} is 0?`;
|
|
1300
|
+
}
|
|
1301
|
+
if (params.direction === 'lower-is-better' && params.baseline === 'seed') {
|
|
1302
|
+
const exclude = scope && Array.isArray(scope.excludePrefixes) ? scope.excludePrefixes : null;
|
|
1303
|
+
if (exclude && exclude.length > 0) return `other files didn't get more ${what}?`;
|
|
1304
|
+
return null;
|
|
1305
|
+
}
|
|
1306
|
+
return null;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* Per-stage `{kind, direction, baselineKind, question}`, keyed by stage NAME,
|
|
1311
|
+
* off a resolved spec's own signed `closeDecl.stages` — the facts the client
|
|
1312
|
+
* needs to pick §3a's numeric wording (down-to-a-goal / up-out-of-a-total /
|
|
1313
|
+
* not-worse-than-a-baseline / pass-fail-only) AND the plain-checks build's
|
|
1314
|
+
* own question text ({@link stageQuestionText}), never guessed from the
|
|
1315
|
+
* runtime number or the stage's model-authored name alone: a `lower-is-better`
|
|
1316
|
+
* stage whose SEED happened to measure 0 is numerically indistinguishable
|
|
1317
|
+
* from a `baseline: 0` declared goal, so the declared baseline KIND (`'seed'`
|
|
1318
|
+
* vs the literal `0`) has to travel separately from the measured number.
|
|
1319
|
+
* Iteration order is `spec.closeDecl.stages`' own DECLARED order (a `Map`
|
|
1320
|
+
* preserves insertion order) — the plain-checks build's client reads this
|
|
1321
|
+
* same order for a stage's `#N` position and for which declared stages never
|
|
1322
|
+
* ran. `null` when the spec has no `closeDecl.stages` array at all (a
|
|
1323
|
+
* command-close spec, or no spec resolved).
|
|
1324
|
+
* @param {any} spec
|
|
1325
|
+
* @returns {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null}>|null}
|
|
1326
|
+
*/
|
|
1327
|
+
function stageKindMetaFromSpec(spec) {
|
|
1328
|
+
if (!spec || typeof spec !== 'object' || !spec.closeDecl || !Array.isArray(spec.closeDecl.stages)) return null;
|
|
1329
|
+
const genre = typeof spec.closeDecl.genre === 'string' ? spec.closeDecl.genre : null;
|
|
1330
|
+
// every command-exit stage's own cmd+args, gathered FIRST (a separate pass)
|
|
1331
|
+
// so stageQuestionText can tell the failing-test-count half of suite-green's
|
|
1332
|
+
// "two assertions" apart from a genuine typecheck stage — see its own doc.
|
|
1333
|
+
/** @type {Set<string>} */
|
|
1334
|
+
const suiteCmdKeys = new Set();
|
|
1335
|
+
for (const s of spec.closeDecl.stages) {
|
|
1336
|
+
if (s && s.kind === 'command-exit') {
|
|
1337
|
+
const key = cmdKey(s.params);
|
|
1338
|
+
if (key !== null) suiteCmdKeys.add(key);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
/** @type {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null}>} */
|
|
1342
|
+
const map = new Map();
|
|
1343
|
+
for (const s of spec.closeDecl.stages) {
|
|
1344
|
+
if (!s || typeof s.name !== 'string' || s.name.length === 0) continue;
|
|
1345
|
+
const kind = typeof s.kind === 'string' ? s.kind : null;
|
|
1346
|
+
const params = s.params && typeof s.params === 'object' ? s.params : null;
|
|
1347
|
+
const direction = params && typeof params.direction === 'string' ? params.direction : null;
|
|
1348
|
+
const baselineKind = params && (params.baseline === 'seed' || params.baseline === 0) ? params.baseline : null;
|
|
1349
|
+
const question = stageQuestionText({
|
|
1350
|
+
kind, params, genre, suiteCmdKeys,
|
|
1351
|
+
});
|
|
1352
|
+
map.set(s.name, {
|
|
1353
|
+
kind, direction, baselineKind, question,
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
return map;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* Attaches one stage's `{kind, direction, baselineKind, question}` (from
|
|
1361
|
+
* {@link stageKindMetaFromSpec}'s map) onto its already-recorded runtime
|
|
1362
|
+
* shape (`{name, verdict, value?, baseline?, …}`, straight off the spine) —
|
|
1363
|
+
* a shallow copy, never a mutation of the spine-derived object, and only when
|
|
1364
|
+
* BOTH a name and a matching declaration entry exist; otherwise the stage
|
|
1365
|
+
* passes through unchanged (the client's existing name fallback still
|
|
1366
|
+
* renders for it).
|
|
1367
|
+
* @param {any} stage
|
|
1368
|
+
* @param {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null}>|null} kindMeta
|
|
1369
|
+
* @returns {any}
|
|
1370
|
+
*/
|
|
1371
|
+
function attachStageKind(stage, kindMeta) {
|
|
1372
|
+
if (!stage || typeof stage !== 'object' || typeof stage.name !== 'string' || !kindMeta) return stage;
|
|
1373
|
+
const meta = kindMeta.get(stage.name);
|
|
1374
|
+
if (!meta) return stage;
|
|
1375
|
+
return {
|
|
1376
|
+
...stage, kind: meta.kind, direction: meta.direction, baselineKind: meta.baselineKind, question: meta.question,
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/**
|
|
1381
|
+
* `summary.parts` (verbatim except each attempt's `stages` array, which gets
|
|
1382
|
+
* {@link attachStageKind} applied per stage) — a shallow re-map, never a
|
|
1383
|
+
* mutation of `replayOne`'s own returned objects (other endpoints, e.g.
|
|
1384
|
+
* {@link getRunAudit}, call `replayOne` fresh per request, but this stays
|
|
1385
|
+
* defensive rather than relying on that).
|
|
1386
|
+
*
|
|
1387
|
+
* Also stamps each attempt with `declaredStagesTotal` (checks-count fix,
|
|
1388
|
+
* 2026-09-28): `kindMeta.size` — the number of stages the signed
|
|
1389
|
+
* `closeDecl` DECLARED, never `stages.length` (the number that RAN). First-
|
|
1390
|
+
* red-wins means an attempt that stopped early carries a `stages` array
|
|
1391
|
+
* shorter than the declaration; the client reads this field for the total
|
|
1392
|
+
* count so an unrun stage still counts in the total exactly once, computed
|
|
1393
|
+
* here and never re-derived client-side — "checks 1/2" on a 7-stage close
|
|
1394
|
+
* (hamr's live catch) was this exact bug.
|
|
1395
|
+
*
|
|
1396
|
+
* `declaredStages` (plain-checks build, 2026-09-28): every declared stage's
|
|
1397
|
+
* `{name, question}`, in DECLARED order — `Array.from(kindMeta)` walks a
|
|
1398
|
+
* `Map`'s own insertion order, which is `spec.closeDecl.stages`' order. The
|
|
1399
|
+
* client zips this against the attempt's own (shorter, when first-red-wins
|
|
1400
|
+
* stopped it early) `stages` array BY NAME to render every declared stage in
|
|
1401
|
+
* the expanded view, including ones that never ran ("· not run") — never by
|
|
1402
|
+
* POSITION, because a stage's declared index is exactly what `#N` in the
|
|
1403
|
+
* headline/expanded view already means, and a name lookup is robust to any
|
|
1404
|
+
* future reordering between the resolved spec and the executed close.
|
|
1405
|
+
*
|
|
1406
|
+
* Both fields are `null`/omitted whenever `kindMeta` is `null` — no spec
|
|
1407
|
+
* resolved, or the spec carries no `closeDecl.stages` at all — so the client
|
|
1408
|
+
* falls back to `stages.length` and renders only the stages that ran, exactly
|
|
1409
|
+
* as before this build.
|
|
1410
|
+
* @param {any[]|null|undefined} parts
|
|
1411
|
+
* @param {Map<string, any>|null} kindMeta
|
|
1412
|
+
* @returns {any[]|null|undefined}
|
|
1413
|
+
*/
|
|
1414
|
+
function enrichPartsWithStageKind(parts, kindMeta) {
|
|
1415
|
+
if (!Array.isArray(parts)) return parts;
|
|
1416
|
+
const declaredStagesTotal = kindMeta ? kindMeta.size : null;
|
|
1417
|
+
const declaredStages = kindMeta
|
|
1418
|
+
? Array.from(kindMeta, ([name, meta]) => ({ name, question: meta.question }))
|
|
1419
|
+
: null;
|
|
1420
|
+
return parts.map((part) => ({
|
|
1421
|
+
...part,
|
|
1422
|
+
attempts: Array.isArray(part.attempts) ? part.attempts.map((a) => ({
|
|
1423
|
+
...a,
|
|
1424
|
+
stages: (kindMeta && Array.isArray(a.stages)) ? a.stages.map((s) => attachStageKind(s, kindMeta)) : a.stages,
|
|
1425
|
+
...(declaredStagesTotal ? { declaredStagesTotal } : {}),
|
|
1426
|
+
...(declaredStages ? { declaredStages } : {}),
|
|
1427
|
+
})) : part.attempts,
|
|
1428
|
+
}));
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1124
1431
|
/**
|
|
1125
1432
|
* The close's stage names, in declared order — `close[].name` (a hand-authored
|
|
1126
1433
|
* command close) or `closeDecl.stages[].name` (an authored declaration); the
|
|
@@ -1406,16 +1713,29 @@ function sendText(res, code, text) {
|
|
|
1406
1713
|
* separately from {@link createPanelServer} so tests can drive it without a
|
|
1407
1714
|
* real listening socket where that is simpler (most path-safety/405 tests
|
|
1408
1715
|
* still go through a real socket, per the build spec).
|
|
1716
|
+
*
|
|
1717
|
+
* PANEL-BUILD.md P3: this file's own GET/HEAD-only rule stands for every
|
|
1718
|
+
* route it owns; `/api/author/*` is the ONE family of routes that may be
|
|
1719
|
+
* POSTed to, and it is handled entirely by {@link createAuthorRoutes}
|
|
1720
|
+
* (`src/panel/authorroutes.js`) — a separate module so this file's own
|
|
1721
|
+
* "read-only, by construction" header comment stays true of everything else
|
|
1722
|
+
* in it. `opts.authorRoutes` is absent on every P1/P2 caller (read-only
|
|
1723
|
+
* tests, and the CLI's own `panelMain` when no author routes are wired) —
|
|
1724
|
+
* only `createPanelServer` below always supplies one.
|
|
1409
1725
|
* @param {import('node:http').IncomingMessage} req
|
|
1410
1726
|
* @param {import('node:http').ServerResponse} res
|
|
1411
|
-
* @param {{ home?: string, port: number }} opts
|
|
1727
|
+
* @param {{ home?: string, port: number, token?: string, authorRoutes?: ReturnType<typeof createAuthorRoutes>, settingsRoutes?: ReturnType<typeof createSettingsRoutes> }} opts
|
|
1412
1728
|
*/
|
|
1413
1729
|
export function handleRequest(req, res, opts) {
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1730
|
+
/** the ONE writable route family for a path, or null — `/api/author/*` (authoring) or
|
|
1731
|
+
* `/api/settings/*` (Settings, P4a): separate modules, each behind the human-click guard.
|
|
1732
|
+
* @param {any} o @param {string} p */
|
|
1733
|
+
function routesFor(o, p) {
|
|
1734
|
+
if (o.authorRoutes && p.startsWith('/api/author')) return o.authorRoutes;
|
|
1735
|
+
if (o.settingsRoutes && p.startsWith('/api/settings')) return o.settingsRoutes;
|
|
1736
|
+
return null;
|
|
1418
1737
|
}
|
|
1738
|
+
const method = req.method ?? 'GET';
|
|
1419
1739
|
|
|
1420
1740
|
let url;
|
|
1421
1741
|
try {
|
|
@@ -1426,6 +1746,43 @@ export function handleRequest(req, res, opts) {
|
|
|
1426
1746
|
}
|
|
1427
1747
|
const { pathname } = url;
|
|
1428
1748
|
|
|
1749
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
1750
|
+
const routes = routesFor(opts, pathname);
|
|
1751
|
+
if (routes) {
|
|
1752
|
+
let raw = '';
|
|
1753
|
+
let size = 0;
|
|
1754
|
+
let refused = false;
|
|
1755
|
+
req.on('data', (c) => {
|
|
1756
|
+
if (refused) return;
|
|
1757
|
+
size += c.length;
|
|
1758
|
+
if (size > MAX_BODY_BYTES) {
|
|
1759
|
+
refused = true;
|
|
1760
|
+
raw = '';
|
|
1761
|
+
res.once('finish', () => { req.destroy(); });
|
|
1762
|
+
res.setHeader('connection', 'close');
|
|
1763
|
+
sendText(res, 413, `request body over ${MAX_BODY_BYTES} bytes`);
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
raw += c;
|
|
1767
|
+
});
|
|
1768
|
+
req.on('end', () => {
|
|
1769
|
+
if (refused) return;
|
|
1770
|
+
/** @type {any} */
|
|
1771
|
+
let body = null;
|
|
1772
|
+
if (raw.length > 0) { try { body = JSON.parse(raw); } catch { body = null; } }
|
|
1773
|
+
routes.handle(req, res, pathname, body);
|
|
1774
|
+
});
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
sendText(res, 405, 'method not allowed — this panel is read-only outside /api/author and /api/settings (GET/HEAD only)');
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
const getRoutes = routesFor(opts, pathname);
|
|
1781
|
+
if (getRoutes) {
|
|
1782
|
+
getRoutes.handle(req, res, pathname, null);
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1429
1786
|
const send = (code, body) => {
|
|
1430
1787
|
if (method === 'HEAD') {
|
|
1431
1788
|
const text = JSON.stringify(body);
|
|
@@ -1446,6 +1803,11 @@ export function handleRequest(req, res, opts) {
|
|
|
1446
1803
|
return;
|
|
1447
1804
|
}
|
|
1448
1805
|
html = html.replace(/__BARELOOP_PANEL_PORT__/g, String(opts.port));
|
|
1806
|
+
// PANEL-BUILD.md P3's human-click guard: a per-server-start token,
|
|
1807
|
+
// templated into the page exactly like the port — absent on every P1/P2
|
|
1808
|
+
// caller (no `opts.token`), which is fine: the page's own JS only reads
|
|
1809
|
+
// it to send on a POST, and there are none to send without P3's routes.
|
|
1810
|
+
html = html.replace(/__BARELOOP_PANEL_TOKEN__/g, String(opts.token ?? ''));
|
|
1449
1811
|
if (method === 'HEAD') {
|
|
1450
1812
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-length': Buffer.byteLength(html) });
|
|
1451
1813
|
res.end();
|
|
@@ -1509,12 +1871,25 @@ export function handleRequest(req, res, opts) {
|
|
|
1509
1871
|
* header). Resolves once actually listening; rejects on a bind error
|
|
1510
1872
|
* (including `EADDRINUSE`) with a `.port` field on the error for the
|
|
1511
1873
|
* caller's message.
|
|
1512
|
-
*
|
|
1513
|
-
*
|
|
1874
|
+
*
|
|
1875
|
+
* PANEL-BUILD.md P3: a fresh {@link mintToken} token and one {@link
|
|
1876
|
+
* createAuthorRoutes} instance are built here, ONCE per server start, and
|
|
1877
|
+
* held for the server's lifetime — `opts.env`/`opts.sessionsRoot`/
|
|
1878
|
+
* `opts.spawnFn`/`opts.bareloopBin` are test seams the author routes take
|
|
1879
|
+
* (a real caller passes none of them and {@link createAuthorRoutes} defaults
|
|
1880
|
+
* each one to the real environment/spawn/binary, this file itself touching
|
|
1881
|
+
* none of them, and the real
|
|
1882
|
+
* `~/.config/bareloop/panel-sessions`, the real `child_process.spawn`, and
|
|
1883
|
+
* this package's own `bin/bareloop.mjs`).
|
|
1884
|
+
* @param {{ port?: number, home?: string, env?: Record<string,string|undefined>,
|
|
1885
|
+
* sessionsRoot?: string, spawnFn?: (...a: any[]) => any, bareloopBin?: string,
|
|
1886
|
+
* fetchImpl?: typeof fetch }} [opts]
|
|
1887
|
+
* @returns {Promise<{ server: import('node:http').Server, port: number, token: string, close: () => Promise<void> }>}
|
|
1514
1888
|
*/
|
|
1515
1889
|
export function createPanelServer(opts = {}) {
|
|
1516
1890
|
const requestedPort = opts.port ?? DEFAULT_PORT;
|
|
1517
1891
|
const home = opts.home;
|
|
1892
|
+
const token = mintToken();
|
|
1518
1893
|
return new Promise((resolve, reject) => {
|
|
1519
1894
|
// Bound port is resolved from the live socket (`server.address().port`)
|
|
1520
1895
|
// once listening starts, not the requested value — this is what makes
|
|
@@ -1522,9 +1897,15 @@ export function createPanelServer(opts = {}) {
|
|
|
1522
1897
|
// test suite, while `--port N` / DEFAULT_PORT callers still get back
|
|
1523
1898
|
// exactly the port they asked for.
|
|
1524
1899
|
let boundPort = requestedPort;
|
|
1900
|
+
/** @type {ReturnType<typeof createAuthorRoutes>|undefined} */
|
|
1901
|
+
let authorRoutes;
|
|
1902
|
+
/** @type {ReturnType<typeof createSettingsRoutes>|undefined} */
|
|
1903
|
+
let settingsRoutes;
|
|
1525
1904
|
const server = createServer((req, res) => {
|
|
1526
1905
|
try {
|
|
1527
|
-
handleRequest(req, res, {
|
|
1906
|
+
handleRequest(req, res, {
|
|
1907
|
+
home, port: boundPort, token, authorRoutes, settingsRoutes,
|
|
1908
|
+
});
|
|
1528
1909
|
} catch (e) {
|
|
1529
1910
|
sendText(res, 500, `internal error: ${/** @type {Error} */ (e).message}`);
|
|
1530
1911
|
}
|
|
@@ -1537,9 +1918,23 @@ export function createPanelServer(opts = {}) {
|
|
|
1537
1918
|
server.listen(requestedPort, '127.0.0.1', () => {
|
|
1538
1919
|
const addr = server.address();
|
|
1539
1920
|
boundPort = typeof addr === 'object' && addr !== null ? addr.port : requestedPort;
|
|
1921
|
+
authorRoutes = createAuthorRoutes({
|
|
1922
|
+
port: boundPort,
|
|
1923
|
+
token,
|
|
1924
|
+
env: opts.env,
|
|
1925
|
+
sessionsRoot: opts.sessionsRoot,
|
|
1926
|
+
spawnFn: opts.spawnFn,
|
|
1927
|
+
bareloopBin: opts.bareloopBin,
|
|
1928
|
+
fetchImpl: opts.fetchImpl,
|
|
1929
|
+
home,
|
|
1930
|
+
});
|
|
1931
|
+
settingsRoutes = createSettingsRoutes({
|
|
1932
|
+
port: boundPort, token, home, env: opts.env, fetchImpl: opts.fetchImpl,
|
|
1933
|
+
});
|
|
1540
1934
|
resolve({
|
|
1541
1935
|
server,
|
|
1542
1936
|
port: boundPort,
|
|
1937
|
+
token,
|
|
1543
1938
|
close: () => new Promise((res2) => { server.close(() => res2(undefined)); }),
|
|
1544
1939
|
});
|
|
1545
1940
|
});
|
|
@@ -1551,7 +1946,10 @@ export function createPanelServer(opts = {}) {
|
|
|
1551
1946
|
* Never picks a different port on collision (see file header): prints a
|
|
1552
1947
|
* loud, named error to stderr and returns 1.
|
|
1553
1948
|
* @param {string[]} argv
|
|
1554
|
-
* @param {{ out: (s: string) => void, err: (s: string) => void, runlistHome?: string }} ctx
|
|
1949
|
+
* @param {{ out: (s: string) => void, err: (s: string) => void, runlistHome?: string, env?: Record<string,string|undefined> }} ctx
|
|
1950
|
+
* `env` is the RAW shell env (never the start-time merge with the keys file) — the routes
|
|
1951
|
+
* re-merge `~/.config/bareloop/.env` onto it per request so Reload keys is real;
|
|
1952
|
+
* undefined = the process env.
|
|
1555
1953
|
* @returns {Promise<number>}
|
|
1556
1954
|
*/
|
|
1557
1955
|
export async function panelMain(argv, ctx) {
|
|
@@ -1565,7 +1963,7 @@ export async function panelMain(argv, ctx) {
|
|
|
1565
1963
|
}
|
|
1566
1964
|
}
|
|
1567
1965
|
try {
|
|
1568
|
-
const { port: boundPort } = await createPanelServer({ port, home: ctx.runlistHome });
|
|
1966
|
+
const { port: boundPort } = await createPanelServer({ port, home: ctx.runlistHome, env: ctx.env });
|
|
1569
1967
|
ctx.out(`bareloop panel — read-only, http://127.0.0.1:${boundPort} (Ctrl-C to stop)`);
|
|
1570
1968
|
// never resolves on its own — the process stays up until killed, same
|
|
1571
1969
|
// shape any other long-running dev server takes.
|