bareloop 0.30.0 → 0.31.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 +82 -0
- package/bareloop.context.md +125 -4
- package/package.json +1 -1
- package/src/authorflow.js +22 -42
- package/src/authorjob.js +5 -8
- package/src/authorrun.js +28 -12
- package/src/panel/authorroutes.js +283 -0
- package/src/panel/authorsession.js +557 -0
- package/src/panel/index.html +972 -49
- package/src/panel/server.js +425 -34
- package/src/planrun.js +61 -5
- package/src/promptregisters.js +3 -0
- package/src/providers.js +82 -0
- package/src/replay.js +80 -2
- package/src/replayio.js +14 -3
- package/src/run.js +48 -2
- package/src/userrun.js +75 -8
- package/types/authorflow.d.ts +4 -21
- package/types/panel/authorroutes.d.ts +65 -0
- package/types/panel/authorsession.d.ts +61 -0
- package/types/panel/server.d.ts +33 -3
- 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/userrun.d.ts +17 -0
package/src/panel/server.js
CHANGED
|
@@ -37,6 +37,7 @@ import { runBehaviour } from '../behaviour.js';
|
|
|
37
37
|
import { SPEND_RECORD_TYPES } from '../ledger.js';
|
|
38
38
|
import { jobSpecHash } from '../job.js';
|
|
39
39
|
import { confirmProtections } from '../authorflow.js';
|
|
40
|
+
import { createAuthorRoutes, mintToken } from './authorroutes.js';
|
|
40
41
|
|
|
41
42
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
42
43
|
|
|
@@ -236,20 +237,17 @@ export function formatTimestamp(ts) {
|
|
|
236
237
|
* @param {string|null} outcome `replayRun`'s own `summary.outcome`
|
|
237
238
|
* @returns {{died: boolean, why: string|null, spendFloorUsd: number|null, wallFloorMs: number|null}}
|
|
238
239
|
*/
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
const when = last ? formatTimestamp(last.ts) : 'an unknown time';
|
|
251
|
-
const why = `died — no ending was recorded (killed, crashed, or the machine slept). Last thing it did: ${describeLastRecord(last)} at ${when}.`;
|
|
252
|
-
|
|
240
|
+
/**
|
|
241
|
+
* The priced-rounds spend floor + first-record→last-record wall floor,
|
|
242
|
+
* derived straight off the spine's raw records — the same derivation a
|
|
243
|
+
* died run's "at least …" figures use, factored out so build item 6
|
|
244
|
+
* (2026-09-28) can hand a genuinely-still-running spine the identical
|
|
245
|
+
* floor instead of "unknown" on the Run tab, without a second copy of this
|
|
246
|
+
* math.
|
|
247
|
+
* @param {any[]} records raw parsed spine records
|
|
248
|
+
* @returns {{spendFloorUsd: number|null, wallFloorMs: number|null}}
|
|
249
|
+
*/
|
|
250
|
+
function floorsFromRecords(records) {
|
|
253
251
|
let spendSum = 0;
|
|
254
252
|
let pricedCount = 0;
|
|
255
253
|
for (const r of records) {
|
|
@@ -258,12 +256,33 @@ function deriveDeath(spinePath, records, outcome) {
|
|
|
258
256
|
}
|
|
259
257
|
const spendFloorUsd = pricedCount > 0 ? spendSum : null;
|
|
260
258
|
|
|
259
|
+
const withTs = records.filter((r) => r && typeof r === 'object' && typeof r.ts === 'string');
|
|
261
260
|
const firstMs = withTs.length ? Date.parse(withTs[0].ts) : NaN;
|
|
262
261
|
const lastMs = withTs.length ? Date.parse(withTs[withTs.length - 1].ts) : NaN;
|
|
263
262
|
const wallFloorMs = Number.isFinite(firstMs) && Number.isFinite(lastMs) && lastMs >= firstMs ? lastMs - firstMs : null;
|
|
263
|
+
return { spendFloorUsd, wallFloorMs };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function deriveDeath(spinePath, records, outcome) {
|
|
267
|
+
const notDied = {
|
|
268
|
+
died: false, why: null, spendFloorUsd: null, wallFloorMs: null,
|
|
269
|
+
};
|
|
270
|
+
if (outcome !== null && outcome !== undefined) return notDied; // a real job-end was reached
|
|
271
|
+
// no job-end yet — the floor derivation is identical whether this turns
|
|
272
|
+
// out to be a died run or one that is genuinely still running (build item
|
|
273
|
+
// 6): computed here, once, before the died/still-running branch below.
|
|
274
|
+
const floors = floorsFromRecords(records);
|
|
275
|
+
let mtimeMs;
|
|
276
|
+
try { mtimeMs = statSync(spinePath).mtimeMs; } catch { return { ...notDied, ...floors }; }
|
|
277
|
+
if (Date.now() - mtimeMs <= DIED_MTIME_MS) return { ...notDied, ...floors }; // still fresh — genuinely `running`, not died
|
|
278
|
+
|
|
279
|
+
const withTs = records.filter((r) => r && typeof r === 'object' && typeof r.ts === 'string');
|
|
280
|
+
const last = withTs.length ? withTs[withTs.length - 1] : null;
|
|
281
|
+
const when = last ? formatTimestamp(last.ts) : 'an unknown time';
|
|
282
|
+
const why = `died — no ending was recorded (killed, crashed, or the machine slept). Last thing it did: ${describeLastRecord(last)} at ${when}.`;
|
|
264
283
|
|
|
265
284
|
return {
|
|
266
|
-
died: true, why,
|
|
285
|
+
died: true, why, ...floors,
|
|
267
286
|
};
|
|
268
287
|
}
|
|
269
288
|
|
|
@@ -322,9 +341,24 @@ function summarizeRow(row) {
|
|
|
322
341
|
model: summary.model,
|
|
323
342
|
// died: never "unknown" when a priced round exists — glyph [?] already
|
|
324
343
|
// carries the word "died", so the row meta text itself never repeats it.
|
|
344
|
+
// `spend` is the LIBRARY's own 4-decimal string (never changed here —
|
|
345
|
+
// `--all`'s CLI listing reads the same `summarizeForAllLine`, F: "do not
|
|
346
|
+
// change the library's CLI output"). The panel page renders its own
|
|
347
|
+
// 2-decimal text off the numeric fields below instead of this string.
|
|
325
348
|
spend: death.died ? (death.spendFloorUsd !== null ? `at least $${death.spendFloorUsd.toFixed(4)}` : 'unknown') : line.spend,
|
|
326
349
|
wall: death.died ? (death.wallFloorMs !== null ? `at least ${formatDurationMs(death.wallFloorMs)}` : 'unknown') : line.wall,
|
|
327
350
|
date: typeof row.at === 'string' ? row.at.slice(0, 10) : null,
|
|
351
|
+
// numeric fields for the panel's own 2-decimal render (hamr's ruling
|
|
352
|
+
// 2026-09-28, "panel money 2-decimals") — `spentUsd`/`spendComplete` are
|
|
353
|
+
// `null`/`false` on a died row (no job-end, see `spendFloorUsd` instead);
|
|
354
|
+
// `draftSpentUsd`/`draftSpendComplete` are `null` when this run carried
|
|
355
|
+
// no drafting fold at all, exactly like `summary.draftSpentUsd` itself.
|
|
356
|
+
spentUsd: death.died ? null : summary.spentUsd,
|
|
357
|
+
spendComplete: death.died ? false : summary.spendComplete,
|
|
358
|
+
spendFloorUsd: death.died ? death.spendFloorUsd : null,
|
|
359
|
+
draftSpentUsd: summary.draftSpentUsd,
|
|
360
|
+
draftSpendComplete: summary.draftSpendComplete,
|
|
361
|
+
budgetUsd: summary.budgetUsd,
|
|
328
362
|
};
|
|
329
363
|
}
|
|
330
364
|
|
|
@@ -375,9 +409,19 @@ export function getRunDetail(runid, opts = {}) {
|
|
|
375
409
|
runid, job: row.job, at: row.at, via: row.via, fileMissing: true,
|
|
376
410
|
};
|
|
377
411
|
}
|
|
378
|
-
|
|
379
|
-
|
|
412
|
+
// build item 7 (2026-09-28): resolve the audit path through the ONE
|
|
413
|
+
// shared resolver (`resolveAuditPathForRow`, already the sole owner for
|
|
414
|
+
// `scopedBehaviour`/`getRunAudit`) BEFORE calling `replayOne`, and hand it
|
|
415
|
+
// the result — otherwise `replayOne`'s own internal resolution only ever
|
|
416
|
+
// finds the FINISHED-run convention, so a still-LIVE run's part-level
|
|
417
|
+
// `byTool`/`toolCalls` (summary.parts, read from `replayRun`) read
|
|
418
|
+
// "unknown" even though the SAME run's Run-tab tools/cache summary and
|
|
419
|
+
// Audit tab (both driven through `resolveAuditPathForRow` already) can
|
|
420
|
+
// see the during-run sidecar just fine.
|
|
380
421
|
const rawSpineRecords = parseJsonl(row.spine).records;
|
|
422
|
+
const auditPath = resolveAuditPathForRow(row, rawSpineRecords);
|
|
423
|
+
const summary = replayOne(row.spine, { auditPathOverride: auditPath });
|
|
424
|
+
const timelineKind = summary.timelineKind;
|
|
381
425
|
const death = deriveDeath(row.spine, rawSpineRecords, summary.outcome);
|
|
382
426
|
// judgeModel (Summary box "judge:" line): the FIRST `judge-round`'s own
|
|
383
427
|
// `model` field (src/planrun.js:1704's `onJudgeCost` emit) — a soft-green
|
|
@@ -509,12 +553,24 @@ export function getRunDetail(runid, opts = {}) {
|
|
|
509
553
|
died: death.died,
|
|
510
554
|
stopReason: death.died ? death.why : summary.stopReason,
|
|
511
555
|
spentUsd: summary.spentUsd,
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
// `
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
556
|
+
// draftSpentUsd (hamr's ruling 2026-09-28) — the drafting share of this
|
|
557
|
+
// run's cap, or null when this run carried none. `src/replay.js`'s
|
|
558
|
+
// `moneyWithDraft` is the one place that decides how to render it.
|
|
559
|
+
draftSpentUsd: summary.draftSpentUsd,
|
|
560
|
+
// draftSpendComplete (hamr's ruling 2026-09-28, 2nd addendum) — whether
|
|
561
|
+
// the drafting share above was EXACT; `null` when there is no drafting
|
|
562
|
+
// share to qualify at all. Read by the panel's own `panelMoneyWithDraft`.
|
|
563
|
+
draftSpendComplete: summary.draftSpendComplete,
|
|
564
|
+
// a spend/wall floor summed from real priced rounds/timestamped records
|
|
565
|
+
// present in the file — never null/unknown when at least one priced
|
|
566
|
+
// round exists. `deriveDeath` already returns `null` for both whenever
|
|
567
|
+
// a real job-end WAS reached (the normal `spentUsd`/`wallMs` fields
|
|
568
|
+
// above are the real, complete figures there); build item 6
|
|
569
|
+
// (2026-09-28) widened this from "died runs only" to ALSO cover a
|
|
570
|
+
// genuinely still-running spine, so the Run tab can show a running
|
|
571
|
+
// floor instead of "unknown" while a run is live.
|
|
572
|
+
spendFloorUsd: death.spendFloorUsd,
|
|
573
|
+
wallFloorMs: death.wallFloorMs,
|
|
518
574
|
spendComplete: summary.spendComplete,
|
|
519
575
|
wallMs: summary.wallMs,
|
|
520
576
|
timelineKind,
|
|
@@ -529,9 +585,15 @@ export function getRunDetail(runid, opts = {}) {
|
|
|
529
585
|
// parts (panel build item B/C, 2026-09-26): the ONE ordered part list
|
|
530
586
|
// (`src/replay.js`'s own field, computed once server-side) — drives the
|
|
531
587
|
// Run tab's map+cards and the Audit tab's grouped rows. Passed through
|
|
532
|
-
// VERBATIM (never re-derived client-side)
|
|
533
|
-
//
|
|
534
|
-
|
|
588
|
+
// VERBATIM (never re-derived client-side) EXCEPT each attempt's own
|
|
589
|
+
// declared-close `stages` array, which gets its per-stage `kind`/
|
|
590
|
+
// `direction`/`baselineKind` attached here (build item, 2026-09-28: the
|
|
591
|
+
// gap 764a9ef named — the signed closeDecl is the only place this lives,
|
|
592
|
+
// and it is not part of `replayOne`'s own spine-derived shape) — see
|
|
593
|
+
// {@link enrichPartsWithStageKind}. `kindMeta` is `null` (every stage
|
|
594
|
+
// passes through unchanged) whenever no spec resolves at all, so the two
|
|
595
|
+
// tabs still never disagree about order/counts/blocked-call figures.
|
|
596
|
+
parts: enrichPartsWithStageKind(summary.parts, stageKindMetaFromSpec(resolveSpecForRow(row))),
|
|
535
597
|
replans: summary.replans,
|
|
536
598
|
close: summary.close,
|
|
537
599
|
branch: summary.branch,
|
|
@@ -1121,6 +1183,278 @@ function sourceNearSpine(spinePath) {
|
|
|
1121
1183
|
};
|
|
1122
1184
|
}
|
|
1123
1185
|
|
|
1186
|
+
/**
|
|
1187
|
+
* The resolved SPEC OBJECT for a run — routes (a) bundle `spec.json`, (b)
|
|
1188
|
+
* `jobs/<job>.json`, (c) the run's own `resolved-spec.json` beside the spine
|
|
1189
|
+
* (the three real-spec routes {@link getRunJob}'s `fromSpec` also tries, in
|
|
1190
|
+
* the same order; never route (d), the run's own job-start record, which
|
|
1191
|
+
* carries no `closeDecl` for {@link stageKindMetaFromSpec} to read a
|
|
1192
|
+
* stage's kind from). A specHash mismatch against `jobs/<job>.json` is NOT
|
|
1193
|
+
* checked here — the Job tab's own mismatch note is a display concern of
|
|
1194
|
+
* that endpoint, not a reason to withhold a stage's kind (the declaration a
|
|
1195
|
+
* hash mismatch flags is still the one the close actually ran under until
|
|
1196
|
+
* the run is repeated). `null` when no route resolves or every candidate
|
|
1197
|
+
* fails to parse as an object — never a guessed spec.
|
|
1198
|
+
* @param {{ spine: string, job: string }} row
|
|
1199
|
+
* @returns {any|null}
|
|
1200
|
+
*/
|
|
1201
|
+
function resolveSpecForRow(row) {
|
|
1202
|
+
if (!existsSync(row.spine)) return null;
|
|
1203
|
+
const bundleDir = bundleDirForSpine(row.spine);
|
|
1204
|
+
const bundleSpecPath = bundleDir ? join(bundleDir, 'spec.json') : null;
|
|
1205
|
+
if (bundleSpecPath && existsSync(bundleSpecPath)) {
|
|
1206
|
+
try {
|
|
1207
|
+
const spec = JSON.parse(readFileSync(bundleSpecPath, 'utf8'));
|
|
1208
|
+
if (spec && typeof spec === 'object') return spec;
|
|
1209
|
+
} catch { /* falls through to the next route */ }
|
|
1210
|
+
}
|
|
1211
|
+
const jobsSpecPath = join(jobsDir(), `${row.job}.json`);
|
|
1212
|
+
if (existsSync(jobsSpecPath)) {
|
|
1213
|
+
try {
|
|
1214
|
+
const spec = JSON.parse(readFileSync(jobsSpecPath, 'utf8'));
|
|
1215
|
+
if (spec && typeof spec === 'object') return spec;
|
|
1216
|
+
} catch { /* falls through to the next route */ }
|
|
1217
|
+
}
|
|
1218
|
+
const near = sourceNearSpine(row.spine);
|
|
1219
|
+
if (near.specPath) {
|
|
1220
|
+
try {
|
|
1221
|
+
const spec = JSON.parse(readFileSync(near.specPath, 'utf8'));
|
|
1222
|
+
if (spec && typeof spec === 'object') return spec;
|
|
1223
|
+
} catch { /* no spec resolvable */ }
|
|
1224
|
+
}
|
|
1225
|
+
return null;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* THE PLAIN-QUESTION OWNER (plain-checks build, 2026-09-28, hamr's ruling:
|
|
1230
|
+
* the "checks N/M" headline is WITHDRAWN — "confusing, reads like 6 failed
|
|
1231
|
+
* when 5 never ran"). ONE pure function mapping a declared stage's own
|
|
1232
|
+
* signed `kind`+`params` (plus the close's own `genre`, also a signed field)
|
|
1233
|
+
* to a short plain-English question a person can read without knowing the
|
|
1234
|
+
* catalogue — never a second spelling on the client, and never anything the
|
|
1235
|
+
* model authored (the catalogue's kinds/params are the whole vocabulary this
|
|
1236
|
+
* reads).
|
|
1237
|
+
*
|
|
1238
|
+
* Every branch is traced straight off `src/kinds.js`'s runner semantics and
|
|
1239
|
+
* the TYPES genre template (`src/authoring.js` `TYPES_GENRE_TEMPLATE`,
|
|
1240
|
+
* `classGuards`'s `MECHANICAL_GUARDS`) — never guessed:
|
|
1241
|
+
* - `files-changed` is always the `changed-from-seed` guard: "did it
|
|
1242
|
+
* change any file?"
|
|
1243
|
+
* - `pattern-absent-in-diff` is always the `no-suppressions` guard: "no
|
|
1244
|
+
* casts or silencers added?"
|
|
1245
|
+
* - `command-exit` is the genre's `suite-green` stage (the only shipped use
|
|
1246
|
+
* of this kind): "does the test suite pass?"
|
|
1247
|
+
* - `count-not-worse` splits three ways on its own declared
|
|
1248
|
+
* `direction`/`baseline` (never the runtime number — see
|
|
1249
|
+
* {@link stageKindMetaFromSpec}'s own doc for why the baseline KIND must
|
|
1250
|
+
* travel separately from the measured figure):
|
|
1251
|
+
* - `higher-is-better` + `baseline: 'seed'` is the genre's `tests-kept`
|
|
1252
|
+
* floor: "did all the old tests still exist?"
|
|
1253
|
+
* - `lower-is-better` + `baseline: 0` is the genre's in-scope
|
|
1254
|
+
* `typecheck` stage — UNLESS its own `cmd`+`args` is the SAME
|
|
1255
|
+
* command a `command-exit` stage in the same close already runs
|
|
1256
|
+
* (`suiteCmdKeys`, below): the TYPES template's `suite-green` is "the
|
|
1257
|
+
* suite exits clean AND reports zero failing tests — TWO assertions"
|
|
1258
|
+
* over ONE population (the same `npm test`/`pytest` invocation), so a
|
|
1259
|
+
* `count-not-worse` stage sharing that exact command is the failing-
|
|
1260
|
+
* test-count half of that pair, never a typecheck stage: "no failing
|
|
1261
|
+
* tests?" — derived from the same cmd+args correlation (the stage
|
|
1262
|
+
* counts the suite's own failing tests), never from the stage's own
|
|
1263
|
+
* name string. The live check that found this (run mul5fofw's own
|
|
1264
|
+
* `suite-zero-failing-tests`, cmd `npm test`, same as its sibling
|
|
1265
|
+
* `suite-green` command-exit stage) would otherwise have read "test/
|
|
1266
|
+
* has 0 type errors?", which is false: its parser counts FAILING
|
|
1267
|
+
* TESTS (`^# fail (\d+)$`), not type-checker output. hamr's ruling:
|
|
1268
|
+
* name this half honestly rather than reusing "type errors" for a
|
|
1269
|
+
* population it was never about, or falling back to the stage's raw
|
|
1270
|
+
* name. When the command differs from every sibling `command-exit`
|
|
1271
|
+
* stage, the declared `scope.includePrefixes` naming exactly ONE path
|
|
1272
|
+
* is quoted verbatim (a signed param, never invented): "<path> has 0
|
|
1273
|
+
* type errors?" — otherwise the generic "type errors is 0?", never a
|
|
1274
|
+
* guessed filename.
|
|
1275
|
+
* - `lower-is-better` + `baseline: 'seed'` with a non-empty
|
|
1276
|
+
* `scope.excludePrefixes` is the genre's `typecheck-outside` ceiling:
|
|
1277
|
+
* "other files didn't get more type errors?"
|
|
1278
|
+
* The "type errors" wording is genre-owned (`GENRE_WHAT`), because TYPES is
|
|
1279
|
+
* the only genre this catalogue admits today (doc-genre kinds are not yet
|
|
1280
|
+
* built) — an unrecognised genre, or a `count-not-worse` shape none of the
|
|
1281
|
+
* three above matches, returns `null` rather than invent a fourth shape.
|
|
1282
|
+
*
|
|
1283
|
+
* `null` means "no honest plain question" — the caller (buildAttemptsList,
|
|
1284
|
+
* partLine1Text's `attemptChecksLine`) falls back to the stage's OWN name,
|
|
1285
|
+
* exactly as it already does for a stage with no resolvable kind at all.
|
|
1286
|
+
* @param {{kind: string|null, params: any, genre: string|null, suiteCmdKeys: Set<string>}} o
|
|
1287
|
+
* @returns {string|null}
|
|
1288
|
+
*/
|
|
1289
|
+
const GENRE_WHAT = Object.freeze({ TYPES: 'type errors' });
|
|
1290
|
+
|
|
1291
|
+
/** `cmd`+`args`, as a comparable key — the one signal (besides `kind`
|
|
1292
|
+
* itself) that two stages measure the SAME command's output, used to tell a
|
|
1293
|
+
* genuine typecheck stage apart from the TYPES genre's other `lower-is-
|
|
1294
|
+
* better`/`baseline: 0` stage (the failing-test-count half of `suite-green`'s
|
|
1295
|
+
* "two assertions"), which shares that stage's own shape but not its
|
|
1296
|
+
* population. `null` for a stage whose `cmd` isn't a non-empty string —
|
|
1297
|
+
* never matches anything, so it never falsely suppresses a real typecheck
|
|
1298
|
+
* question.
|
|
1299
|
+
* @param {any} params @returns {string|null} */
|
|
1300
|
+
function cmdKey(params) {
|
|
1301
|
+
if (!params || typeof params.cmd !== 'string' || params.cmd.length === 0) return null;
|
|
1302
|
+
const args = Array.isArray(params.args) ? params.args : [];
|
|
1303
|
+
return JSON.stringify([params.cmd, args]);
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
function stageQuestionText({
|
|
1307
|
+
kind, params, genre, suiteCmdKeys,
|
|
1308
|
+
}) {
|
|
1309
|
+
if (kind === 'files-changed') return 'did it change any file?';
|
|
1310
|
+
if (kind === 'pattern-absent-in-diff') return 'no casts or silencers added?';
|
|
1311
|
+
if (kind === 'command-exit') return 'does the test suite pass?';
|
|
1312
|
+
if (kind !== 'count-not-worse' || !params || typeof params !== 'object') return null;
|
|
1313
|
+
const what = Object.hasOwn(GENRE_WHAT, String(genre)) ? GENRE_WHAT[String(genre)] : null;
|
|
1314
|
+
if (params.direction === 'higher-is-better' && params.baseline === 'seed') {
|
|
1315
|
+
return 'did all the old tests still exist?';
|
|
1316
|
+
}
|
|
1317
|
+
if (!what) return null;
|
|
1318
|
+
const scope = params.scope && typeof params.scope === 'object' ? params.scope : null;
|
|
1319
|
+
if (params.direction === 'lower-is-better' && params.baseline === 0) {
|
|
1320
|
+
const key = cmdKey(params);
|
|
1321
|
+
if (key !== null && suiteCmdKeys instanceof Set && suiteCmdKeys.has(key)) return 'no failing tests?';
|
|
1322
|
+
const include = scope && Array.isArray(scope.includePrefixes) ? scope.includePrefixes : null;
|
|
1323
|
+
if (include && include.length === 1 && typeof include[0] === 'string' && include[0].length > 0) {
|
|
1324
|
+
return `${include[0]} has 0 ${what}?`;
|
|
1325
|
+
}
|
|
1326
|
+
return `${what} is 0?`;
|
|
1327
|
+
}
|
|
1328
|
+
if (params.direction === 'lower-is-better' && params.baseline === 'seed') {
|
|
1329
|
+
const exclude = scope && Array.isArray(scope.excludePrefixes) ? scope.excludePrefixes : null;
|
|
1330
|
+
if (exclude && exclude.length > 0) return `other files didn't get more ${what}?`;
|
|
1331
|
+
return null;
|
|
1332
|
+
}
|
|
1333
|
+
return null;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* Per-stage `{kind, direction, baselineKind, question}`, keyed by stage NAME,
|
|
1338
|
+
* off a resolved spec's own signed `closeDecl.stages` — the facts the client
|
|
1339
|
+
* needs to pick §3a's numeric wording (down-to-a-goal / up-out-of-a-total /
|
|
1340
|
+
* not-worse-than-a-baseline / pass-fail-only) AND the plain-checks build's
|
|
1341
|
+
* own question text ({@link stageQuestionText}), never guessed from the
|
|
1342
|
+
* runtime number or the stage's model-authored name alone: a `lower-is-better`
|
|
1343
|
+
* stage whose SEED happened to measure 0 is numerically indistinguishable
|
|
1344
|
+
* from a `baseline: 0` declared goal, so the declared baseline KIND (`'seed'`
|
|
1345
|
+
* vs the literal `0`) has to travel separately from the measured number.
|
|
1346
|
+
* Iteration order is `spec.closeDecl.stages`' own DECLARED order (a `Map`
|
|
1347
|
+
* preserves insertion order) — the plain-checks build's client reads this
|
|
1348
|
+
* same order for a stage's `#N` position and for which declared stages never
|
|
1349
|
+
* ran. `null` when the spec has no `closeDecl.stages` array at all (a
|
|
1350
|
+
* command-close spec, or no spec resolved).
|
|
1351
|
+
* @param {any} spec
|
|
1352
|
+
* @returns {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null}>|null}
|
|
1353
|
+
*/
|
|
1354
|
+
function stageKindMetaFromSpec(spec) {
|
|
1355
|
+
if (!spec || typeof spec !== 'object' || !spec.closeDecl || !Array.isArray(spec.closeDecl.stages)) return null;
|
|
1356
|
+
const genre = typeof spec.closeDecl.genre === 'string' ? spec.closeDecl.genre : null;
|
|
1357
|
+
// every command-exit stage's own cmd+args, gathered FIRST (a separate pass)
|
|
1358
|
+
// so stageQuestionText can tell the failing-test-count half of suite-green's
|
|
1359
|
+
// "two assertions" apart from a genuine typecheck stage — see its own doc.
|
|
1360
|
+
/** @type {Set<string>} */
|
|
1361
|
+
const suiteCmdKeys = new Set();
|
|
1362
|
+
for (const s of spec.closeDecl.stages) {
|
|
1363
|
+
if (s && s.kind === 'command-exit') {
|
|
1364
|
+
const key = cmdKey(s.params);
|
|
1365
|
+
if (key !== null) suiteCmdKeys.add(key);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
/** @type {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null}>} */
|
|
1369
|
+
const map = new Map();
|
|
1370
|
+
for (const s of spec.closeDecl.stages) {
|
|
1371
|
+
if (!s || typeof s.name !== 'string' || s.name.length === 0) continue;
|
|
1372
|
+
const kind = typeof s.kind === 'string' ? s.kind : null;
|
|
1373
|
+
const params = s.params && typeof s.params === 'object' ? s.params : null;
|
|
1374
|
+
const direction = params && typeof params.direction === 'string' ? params.direction : null;
|
|
1375
|
+
const baselineKind = params && (params.baseline === 'seed' || params.baseline === 0) ? params.baseline : null;
|
|
1376
|
+
const question = stageQuestionText({
|
|
1377
|
+
kind, params, genre, suiteCmdKeys,
|
|
1378
|
+
});
|
|
1379
|
+
map.set(s.name, {
|
|
1380
|
+
kind, direction, baselineKind, question,
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
return map;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/**
|
|
1387
|
+
* Attaches one stage's `{kind, direction, baselineKind, question}` (from
|
|
1388
|
+
* {@link stageKindMetaFromSpec}'s map) onto its already-recorded runtime
|
|
1389
|
+
* shape (`{name, verdict, value?, baseline?, …}`, straight off the spine) —
|
|
1390
|
+
* a shallow copy, never a mutation of the spine-derived object, and only when
|
|
1391
|
+
* BOTH a name and a matching declaration entry exist; otherwise the stage
|
|
1392
|
+
* passes through unchanged (the client's existing name fallback still
|
|
1393
|
+
* renders for it).
|
|
1394
|
+
* @param {any} stage
|
|
1395
|
+
* @param {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null}>|null} kindMeta
|
|
1396
|
+
* @returns {any}
|
|
1397
|
+
*/
|
|
1398
|
+
function attachStageKind(stage, kindMeta) {
|
|
1399
|
+
if (!stage || typeof stage !== 'object' || typeof stage.name !== 'string' || !kindMeta) return stage;
|
|
1400
|
+
const meta = kindMeta.get(stage.name);
|
|
1401
|
+
if (!meta) return stage;
|
|
1402
|
+
return {
|
|
1403
|
+
...stage, kind: meta.kind, direction: meta.direction, baselineKind: meta.baselineKind, question: meta.question,
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* `summary.parts` (verbatim except each attempt's `stages` array, which gets
|
|
1409
|
+
* {@link attachStageKind} applied per stage) — a shallow re-map, never a
|
|
1410
|
+
* mutation of `replayOne`'s own returned objects (other endpoints, e.g.
|
|
1411
|
+
* {@link getRunAudit}, call `replayOne` fresh per request, but this stays
|
|
1412
|
+
* defensive rather than relying on that).
|
|
1413
|
+
*
|
|
1414
|
+
* Also stamps each attempt with `declaredStagesTotal` (checks-count fix,
|
|
1415
|
+
* 2026-09-28): `kindMeta.size` — the number of stages the signed
|
|
1416
|
+
* `closeDecl` DECLARED, never `stages.length` (the number that RAN). First-
|
|
1417
|
+
* red-wins means an attempt that stopped early carries a `stages` array
|
|
1418
|
+
* shorter than the declaration; the client reads this field for the total
|
|
1419
|
+
* count so an unrun stage still counts in the total exactly once, computed
|
|
1420
|
+
* here and never re-derived client-side — "checks 1/2" on a 7-stage close
|
|
1421
|
+
* (hamr's live catch) was this exact bug.
|
|
1422
|
+
*
|
|
1423
|
+
* `declaredStages` (plain-checks build, 2026-09-28): every declared stage's
|
|
1424
|
+
* `{name, question}`, in DECLARED order — `Array.from(kindMeta)` walks a
|
|
1425
|
+
* `Map`'s own insertion order, which is `spec.closeDecl.stages`' order. The
|
|
1426
|
+
* client zips this against the attempt's own (shorter, when first-red-wins
|
|
1427
|
+
* stopped it early) `stages` array BY NAME to render every declared stage in
|
|
1428
|
+
* the expanded view, including ones that never ran ("· not run") — never by
|
|
1429
|
+
* POSITION, because a stage's declared index is exactly what `#N` in the
|
|
1430
|
+
* headline/expanded view already means, and a name lookup is robust to any
|
|
1431
|
+
* future reordering between the resolved spec and the executed close.
|
|
1432
|
+
*
|
|
1433
|
+
* Both fields are `null`/omitted whenever `kindMeta` is `null` — no spec
|
|
1434
|
+
* resolved, or the spec carries no `closeDecl.stages` at all — so the client
|
|
1435
|
+
* falls back to `stages.length` and renders only the stages that ran, exactly
|
|
1436
|
+
* as before this build.
|
|
1437
|
+
* @param {any[]|null|undefined} parts
|
|
1438
|
+
* @param {Map<string, any>|null} kindMeta
|
|
1439
|
+
* @returns {any[]|null|undefined}
|
|
1440
|
+
*/
|
|
1441
|
+
function enrichPartsWithStageKind(parts, kindMeta) {
|
|
1442
|
+
if (!Array.isArray(parts)) return parts;
|
|
1443
|
+
const declaredStagesTotal = kindMeta ? kindMeta.size : null;
|
|
1444
|
+
const declaredStages = kindMeta
|
|
1445
|
+
? Array.from(kindMeta, ([name, meta]) => ({ name, question: meta.question }))
|
|
1446
|
+
: null;
|
|
1447
|
+
return parts.map((part) => ({
|
|
1448
|
+
...part,
|
|
1449
|
+
attempts: Array.isArray(part.attempts) ? part.attempts.map((a) => ({
|
|
1450
|
+
...a,
|
|
1451
|
+
stages: (kindMeta && Array.isArray(a.stages)) ? a.stages.map((s) => attachStageKind(s, kindMeta)) : a.stages,
|
|
1452
|
+
...(declaredStagesTotal ? { declaredStagesTotal } : {}),
|
|
1453
|
+
...(declaredStages ? { declaredStages } : {}),
|
|
1454
|
+
})) : part.attempts,
|
|
1455
|
+
}));
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1124
1458
|
/**
|
|
1125
1459
|
* The close's stage names, in declared order — `close[].name` (a hand-authored
|
|
1126
1460
|
* command close) or `closeDecl.stages[].name` (an authored declaration); the
|
|
@@ -1406,16 +1740,21 @@ function sendText(res, code, text) {
|
|
|
1406
1740
|
* separately from {@link createPanelServer} so tests can drive it without a
|
|
1407
1741
|
* real listening socket where that is simpler (most path-safety/405 tests
|
|
1408
1742
|
* still go through a real socket, per the build spec).
|
|
1743
|
+
*
|
|
1744
|
+
* PANEL-BUILD.md P3: this file's own GET/HEAD-only rule stands for every
|
|
1745
|
+
* route it owns; `/api/author/*` is the ONE family of routes that may be
|
|
1746
|
+
* POSTed to, and it is handled entirely by {@link createAuthorRoutes}
|
|
1747
|
+
* (`src/panel/authorroutes.js`) — a separate module so this file's own
|
|
1748
|
+
* "read-only, by construction" header comment stays true of everything else
|
|
1749
|
+
* in it. `opts.authorRoutes` is absent on every P1/P2 caller (read-only
|
|
1750
|
+
* tests, and the CLI's own `panelMain` when no author routes are wired) —
|
|
1751
|
+
* only `createPanelServer` below always supplies one.
|
|
1409
1752
|
* @param {import('node:http').IncomingMessage} req
|
|
1410
1753
|
* @param {import('node:http').ServerResponse} res
|
|
1411
|
-
* @param {{ home?: string, port: number }} opts
|
|
1754
|
+
* @param {{ home?: string, port: number, token?: string, authorRoutes?: ReturnType<typeof createAuthorRoutes> }} opts
|
|
1412
1755
|
*/
|
|
1413
1756
|
export function handleRequest(req, res, opts) {
|
|
1414
1757
|
const method = req.method ?? 'GET';
|
|
1415
|
-
if (method !== 'GET' && method !== 'HEAD') {
|
|
1416
|
-
sendText(res, 405, 'method not allowed — this panel is read-only (GET/HEAD only)');
|
|
1417
|
-
return;
|
|
1418
|
-
}
|
|
1419
1758
|
|
|
1420
1759
|
let url;
|
|
1421
1760
|
try {
|
|
@@ -1426,6 +1765,26 @@ export function handleRequest(req, res, opts) {
|
|
|
1426
1765
|
}
|
|
1427
1766
|
const { pathname } = url;
|
|
1428
1767
|
|
|
1768
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
1769
|
+
if (opts.authorRoutes && pathname.startsWith('/api/author')) {
|
|
1770
|
+
let raw = '';
|
|
1771
|
+
req.on('data', (c) => { raw += c; });
|
|
1772
|
+
req.on('end', () => {
|
|
1773
|
+
/** @type {any} */
|
|
1774
|
+
let body = null;
|
|
1775
|
+
if (raw.length > 0) { try { body = JSON.parse(raw); } catch { body = null; } }
|
|
1776
|
+
opts.authorRoutes?.handle(req, res, pathname, body);
|
|
1777
|
+
});
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
sendText(res, 405, 'method not allowed — this panel is read-only outside /api/author (GET/HEAD only)');
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
if (opts.authorRoutes && pathname.startsWith('/api/author')) {
|
|
1784
|
+
opts.authorRoutes.handle(req, res, pathname, null);
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1429
1788
|
const send = (code, body) => {
|
|
1430
1789
|
if (method === 'HEAD') {
|
|
1431
1790
|
const text = JSON.stringify(body);
|
|
@@ -1446,6 +1805,11 @@ export function handleRequest(req, res, opts) {
|
|
|
1446
1805
|
return;
|
|
1447
1806
|
}
|
|
1448
1807
|
html = html.replace(/__BARELOOP_PANEL_PORT__/g, String(opts.port));
|
|
1808
|
+
// PANEL-BUILD.md P3's human-click guard: a per-server-start token,
|
|
1809
|
+
// templated into the page exactly like the port — absent on every P1/P2
|
|
1810
|
+
// caller (no `opts.token`), which is fine: the page's own JS only reads
|
|
1811
|
+
// it to send on a POST, and there are none to send without P3's routes.
|
|
1812
|
+
html = html.replace(/__BARELOOP_PANEL_TOKEN__/g, String(opts.token ?? ''));
|
|
1449
1813
|
if (method === 'HEAD') {
|
|
1450
1814
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-length': Buffer.byteLength(html) });
|
|
1451
1815
|
res.end();
|
|
@@ -1509,12 +1873,25 @@ export function handleRequest(req, res, opts) {
|
|
|
1509
1873
|
* header). Resolves once actually listening; rejects on a bind error
|
|
1510
1874
|
* (including `EADDRINUSE`) with a `.port` field on the error for the
|
|
1511
1875
|
* caller's message.
|
|
1512
|
-
*
|
|
1513
|
-
*
|
|
1876
|
+
*
|
|
1877
|
+
* PANEL-BUILD.md P3: a fresh {@link mintToken} token and one {@link
|
|
1878
|
+
* createAuthorRoutes} instance are built here, ONCE per server start, and
|
|
1879
|
+
* held for the server's lifetime — `opts.env`/`opts.sessionsRoot`/
|
|
1880
|
+
* `opts.spawnFn`/`opts.bareloopBin` are test seams the author routes take
|
|
1881
|
+
* (a real caller passes none of them and {@link createAuthorRoutes} defaults
|
|
1882
|
+
* each one to the real environment/spawn/binary, this file itself touching
|
|
1883
|
+
* none of them, and the real
|
|
1884
|
+
* `~/.config/bareloop/panel-sessions`, the real `child_process.spawn`, and
|
|
1885
|
+
* this package's own `bin/bareloop.mjs`).
|
|
1886
|
+
* @param {{ port?: number, home?: string, env?: Record<string,string|undefined>,
|
|
1887
|
+
* sessionsRoot?: string, spawnFn?: (...a: any[]) => any, bareloopBin?: string,
|
|
1888
|
+
* fetchImpl?: typeof fetch }} [opts]
|
|
1889
|
+
* @returns {Promise<{ server: import('node:http').Server, port: number, token: string, close: () => Promise<void> }>}
|
|
1514
1890
|
*/
|
|
1515
1891
|
export function createPanelServer(opts = {}) {
|
|
1516
1892
|
const requestedPort = opts.port ?? DEFAULT_PORT;
|
|
1517
1893
|
const home = opts.home;
|
|
1894
|
+
const token = mintToken();
|
|
1518
1895
|
return new Promise((resolve, reject) => {
|
|
1519
1896
|
// Bound port is resolved from the live socket (`server.address().port`)
|
|
1520
1897
|
// once listening starts, not the requested value — this is what makes
|
|
@@ -1522,9 +1899,13 @@ export function createPanelServer(opts = {}) {
|
|
|
1522
1899
|
// test suite, while `--port N` / DEFAULT_PORT callers still get back
|
|
1523
1900
|
// exactly the port they asked for.
|
|
1524
1901
|
let boundPort = requestedPort;
|
|
1902
|
+
/** @type {ReturnType<typeof createAuthorRoutes>|undefined} */
|
|
1903
|
+
let authorRoutes;
|
|
1525
1904
|
const server = createServer((req, res) => {
|
|
1526
1905
|
try {
|
|
1527
|
-
handleRequest(req, res, {
|
|
1906
|
+
handleRequest(req, res, {
|
|
1907
|
+
home, port: boundPort, token, authorRoutes,
|
|
1908
|
+
});
|
|
1528
1909
|
} catch (e) {
|
|
1529
1910
|
sendText(res, 500, `internal error: ${/** @type {Error} */ (e).message}`);
|
|
1530
1911
|
}
|
|
@@ -1537,9 +1918,19 @@ 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
|
+
});
|
|
1540
1930
|
resolve({
|
|
1541
1931
|
server,
|
|
1542
1932
|
port: boundPort,
|
|
1933
|
+
token,
|
|
1543
1934
|
close: () => new Promise((res2) => { server.close(() => res2(undefined)); }),
|
|
1544
1935
|
});
|
|
1545
1936
|
});
|