bareloop 0.43.0 → 0.44.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 +42 -0
- package/bareloop.context.md +52 -13
- package/package.json +2 -2
- package/src/authorflow.js +26 -11
- package/src/authoring.js +1 -1
- package/src/authorjob.js +27 -15
- package/src/authorreadout.js +7 -6
- package/src/declaredclose.js +4 -1
- package/src/draftspend.js +20 -2
- package/src/facts.js +54 -9
- package/src/factwords.js +58 -6
- package/src/inputlook.js +38 -3
- package/src/job.js +1 -1
- package/src/kinds.js +5 -0
- package/src/pagedropwords.js +64 -0
- package/src/pagemap.js +264 -0
- package/src/pagerules.js +319 -0
- package/src/panel/authorroutes.js +34 -30
- package/src/panel/authorsession.js +29 -5
- package/src/panel/index.html +465 -65
- package/src/panel/jobplan.js +4 -2
- package/src/panel/runroutes.js +46 -22
- package/src/panel/server.js +791 -28
- package/src/panel/status.js +33 -0
- package/src/panelmoney.js +29 -0
- package/src/planrun.js +53 -4
- package/src/promptregisters.js +4 -0
- package/src/replay.js +118 -7
- package/src/rubricstep.js +123 -11
- package/src/runlist.js +2 -1
- package/src/schedulewords.js +6 -3
- package/src/source.js +18 -0
- package/src/thinking.js +63 -2
- package/src/userrun.js +28 -14
- package/types/authorflow.d.ts +42 -6
- package/types/authorjob.d.ts +10 -2
- package/types/declaredclose.d.ts +2 -0
- package/types/draftspend.d.ts +27 -1
- package/types/facts.d.ts +15 -0
- package/types/factwords.d.ts +26 -3
- package/types/inputlook.d.ts +15 -0
- package/types/kinds.d.ts +5 -0
- package/types/pagedropwords.d.ts +26 -0
- package/types/pagemap.d.ts +154 -0
- package/types/pagerules.d.ts +86 -0
- package/types/panel/authorroutes.d.ts +17 -14
- package/types/panel/runroutes.d.ts +2 -2
- package/types/panel/server.d.ts +155 -2
- package/types/panel/status.d.ts +51 -0
- package/types/panelmoney.d.ts +5 -0
- package/types/replay.d.ts +21 -1
- package/types/rubricstep.d.ts +57 -1
- package/types/runlist.d.ts +4 -1
- package/types/schedulewords.d.ts +4 -2
- package/types/source.d.ts +11 -0
- package/types/thinking.d.ts +39 -0
- package/types/userrun.d.ts +4 -0
package/src/panel/server.js
CHANGED
|
@@ -25,13 +25,19 @@
|
|
|
25
25
|
// for every cap/threshold: a shell never widens what it was asked to do).
|
|
26
26
|
|
|
27
27
|
import { createServer } from 'node:http';
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
readFileSync, existsSync, readdirSync, lstatSync, realpathSync,
|
|
30
|
+
} from 'node:fs';
|
|
31
|
+
import { createHash } from 'node:crypto';
|
|
29
32
|
import {
|
|
30
33
|
dirname, join, basename, relative, isAbsolute, sep,
|
|
31
34
|
} from 'node:path';
|
|
32
35
|
import { fileURLToPath } from 'node:url';
|
|
33
36
|
import { readRunList, readNeedsRemovals, DIED_MTIME_MS, runIsAlive } from '../runlist.js';
|
|
34
|
-
import {
|
|
37
|
+
import { webFetchState } from '../source.js';
|
|
38
|
+
import { isWebUrl } from '../pagefetch.js';
|
|
39
|
+
import { draftingPartFor, sessionDirForRow, readDraftSteps } from '../draftspend.js';
|
|
40
|
+
import { readingOfQuestion, isAgree } from '../pagemap.js';
|
|
35
41
|
import {
|
|
36
42
|
replayOne, parseJsonl, resolveSiblings, isSidecarByName,
|
|
37
43
|
} from '../replayio.js';
|
|
@@ -60,7 +66,12 @@ import { rowsForHome, findRow, modelChoiceFor, tickedLines, endpointOf } from '.
|
|
|
60
66
|
import { SHAPES } from '../providershapes.js';
|
|
61
67
|
import { resolveProvider } from '../providers.js';
|
|
62
68
|
import { readResume, checkpointAgeGate, CHECKPOINT_OUTCOMES } from '../reuse.js';
|
|
63
|
-
import {
|
|
69
|
+
import {
|
|
70
|
+
statusFor, partStatusFor, GOAL_MET_LINE, doorViewOf, ACTOR_MARKER, DOOR_WORDS,
|
|
71
|
+
} from './status.js';
|
|
72
|
+
import { thinkingJobText } from '../thinking.js';
|
|
73
|
+
import { judgedFilesOf, lineRules, stepFileRel, proseNotCheckedItems } from '../rubricstep.js';
|
|
74
|
+
import { factPartOf, factSentence, ruleWords, moneyAs } from '../facts.js';
|
|
64
75
|
import { createSettingsRoutes } from './settingsroutes.js';
|
|
65
76
|
import { planFromSpec } from './jobplan.js';
|
|
66
77
|
|
|
@@ -199,6 +210,8 @@ function describeLastRecord(r) {
|
|
|
199
210
|
'scout-start': () => 'starting the scout',
|
|
200
211
|
'scout-result': () => 'the scout finishing',
|
|
201
212
|
'check-run': () => 'a check run',
|
|
213
|
+
'output-judged': () => 'recording the file a check read',
|
|
214
|
+
'rubric-evidence': () => 'recording the words a check read',
|
|
202
215
|
'check-preflight': () => 'a preflight check',
|
|
203
216
|
'check-menu': () => 'building the check menu',
|
|
204
217
|
'step-start': (rec) => `starting step ${rec.step ?? '?'}`,
|
|
@@ -687,11 +700,17 @@ function endedForRow(row, records, summary, death, withWorktree = false) {
|
|
|
687
700
|
* exist YET: the runner appends the row first, then opens the spine. It is `starting`,
|
|
688
701
|
* never `file missing`. Carries the same field names a normal row/detail does so every
|
|
689
702
|
* client reader (filters, groups) sees a live `▶` run, with every figure honestly null.
|
|
690
|
-
* @param {{ runid: string, job: string, at: string, via: string }} row
|
|
703
|
+
* @param {{ runid: string, job: string, at: string, via: string, spine: string }} row
|
|
691
704
|
* @returns {any}
|
|
692
705
|
*/
|
|
693
706
|
function startingStub(row) {
|
|
707
|
+
// PRD v1.106 Part 5 Q2 (moved after the listing 2026-10-19, run mvez8s54): while the run's own refetch of a web input is still going
|
|
708
|
+
// (source.json has a web input and no refrozenAt at or after the row), the words are the server's: "fetching the page…"
|
|
709
|
+
const near = sourceNearSpine(row.spine);
|
|
710
|
+
const fetching = near.sourceJsonPath !== null && webFetchState(near.sourceJsonPath, row.at).fetching;
|
|
694
711
|
return {
|
|
712
|
+
startingWord: fetching ? 'fetching the page…' : 'starting…',
|
|
713
|
+
startingNote: fetching ? 'the run has not started yet' : 'the run has not written its log yet',
|
|
695
714
|
runid: row.runid,
|
|
696
715
|
job: row.job,
|
|
697
716
|
at: row.at,
|
|
@@ -729,6 +748,28 @@ function startingStub(row) {
|
|
|
729
748
|
};
|
|
730
749
|
}
|
|
731
750
|
|
|
751
|
+
/**
|
|
752
|
+
* A run that REFUSED before it started (its refetch of a web input was refused, run mvez8s54 shape): listed, settled at $0, no spine.
|
|
753
|
+
* The status word is the table's `failed` (statusFor: "refusals, instrument stops -> failed"); the plain words ride on `endedLine`.
|
|
754
|
+
* @param {{ runid: string, job: string, at: string, via: string }} row @param {string} words
|
|
755
|
+
* @returns {any}
|
|
756
|
+
*/
|
|
757
|
+
function refusedStub(row, words) {
|
|
758
|
+
return {
|
|
759
|
+
runid: row.runid, job: row.job, at: row.at, via: row.via, fileMissing: false, refused: true, live: false, needs: null, stoppedAt: null,
|
|
760
|
+
died: false, glyph: statusFor({ outcome: 'refused-before-start' }).sign, status: statusFor({ outcome: 'refused-before-start' }),
|
|
761
|
+
outcome: 'refused-before-start', endedLine: words, ended: null, resume: null, checkType: 'unknown', checkTypeTitle: null, model: null,
|
|
762
|
+
spend: '$0', wall: '', date: typeof row.at === 'string' ? row.at.slice(0, 10) : null, spentUsd: 0, spendComplete: true,
|
|
763
|
+
spendFloorUsd: null, wallFloorMs: null, draftSpentUsd: null, draftSpendComplete: null, budgetUsd: null, steps: [], parts: [],
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** the plain words of a run that settled as "refused before start" (leg 1), or null @param {any[]} events @param {{runid: string}} row */
|
|
768
|
+
function refusalWordsOf(events, row) {
|
|
769
|
+
const e = events.find((x) => x && x.type === 'settled' && x.runid === row.runid && x.reason === 'refused before start' && !(x.leg > 1));
|
|
770
|
+
return e && typeof e.words === 'string' && e.words !== '' ? e.words : null;
|
|
771
|
+
}
|
|
772
|
+
|
|
732
773
|
/**
|
|
733
774
|
* RULED 2026-10-15 (Remove from Needs you): the ONE place a run's Needs-you state is decided. `Accept` (a live door) is never removable and never ages out.
|
|
734
775
|
* A `Resume` tag leaves when the person removed it AFTER (or at) the stop it is for — a removal covers only the ending it was made on, so a later stop
|
|
@@ -754,10 +795,13 @@ export function needsStateFor(f) {
|
|
|
754
795
|
* already applies to the CLI's own listing).
|
|
755
796
|
* @param {import('../runlist.js').RunRow} row
|
|
756
797
|
* @param {number} [nowMs] @param {Map<string, number>} [removals] `readNeedsRemovals`: when each run was taken out of Needs you
|
|
798
|
+
* @param {any[]} [events] the run list's settled/released events (a run that refused before it started has no spine; its words are here)
|
|
757
799
|
* @returns {any}
|
|
758
800
|
*/
|
|
759
|
-
function summarizeRow(row, nowMs = Date.now(), removals = undefined) {
|
|
801
|
+
function summarizeRow(row, nowMs = Date.now(), removals = undefined, events = []) {
|
|
760
802
|
if (!existsSync(row.spine)) {
|
|
803
|
+
const refusal = refusalWordsOf(events, row);
|
|
804
|
+
if (refusal !== null) return refusedStub(row, refusal);
|
|
761
805
|
if (runIsAlive(row)) return startingStub(row);
|
|
762
806
|
return {
|
|
763
807
|
runid: row.runid, job: row.job, at: row.at, via: row.via, fileMissing: true, live: false, needs: null, stoppedAt: null,
|
|
@@ -981,14 +1025,14 @@ export function summarizeJobs(rows, nowMs) {
|
|
|
981
1025
|
* @returns {any[]}
|
|
982
1026
|
*/
|
|
983
1027
|
export function listRuns(opts = {}) {
|
|
984
|
-
const { rows } = readRunList(opts);
|
|
1028
|
+
const { rows, events } = readRunList(opts);
|
|
985
1029
|
const nowMs = typeof opts.now === 'number' ? opts.now : Date.now();
|
|
986
1030
|
const nos = runNumbers(rows);
|
|
987
1031
|
const removals = readNeedsRemovals(opts);
|
|
988
1032
|
return rows
|
|
989
1033
|
.map((row, index) => ({ row, index })) // index: stable tie-break, see doc above
|
|
990
1034
|
.sort((a, b) => (atMs(b.row) - atMs(a.row)) || (a.index - b.index))
|
|
991
|
-
.map(({ row }) => ({ ...summarizeRow(row, nowMs, removals), runNo: nos.get(row.runid) }));
|
|
1035
|
+
.map(({ row }) => ({ ...summarizeRow(row, nowMs, removals, events), runNo: nos.get(row.runid) }));
|
|
992
1036
|
}
|
|
993
1037
|
|
|
994
1038
|
/**
|
|
@@ -1093,6 +1137,346 @@ export function getAcceptContext(runid, opts = {}) {
|
|
|
1093
1137
|
return { row, live: runIsAlive(row), waiting: doorWaiting(records), specPath: signed ? signed.specPath : null, specHash: signed ? signed.specHash : null };
|
|
1094
1138
|
}
|
|
1095
1139
|
|
|
1140
|
+
// ── PRD v1.107 Part 6: the Result tab (rubric jobs only) ─────────────────────────────────────────────────────────────────────────────
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* The files a run delivered: its own `destination-written` spine records (userrun.js, on a minted green only), one per path, the last record of a
|
|
1144
|
+
* path winning. This is the ONLY source of a delivered path anywhere in the panel — no request ever names one.
|
|
1145
|
+
* @param {any[]} records
|
|
1146
|
+
* @returns {{path: string, bytes: number|null, sha256: string|null}[]}
|
|
1147
|
+
*/
|
|
1148
|
+
export function deliveredOf(records) {
|
|
1149
|
+
/** @type {Map<string, {path: string, bytes: number|null, sha256: string|null}>} */
|
|
1150
|
+
const byPath = new Map();
|
|
1151
|
+
for (const r of records) {
|
|
1152
|
+
if (!r || r.type !== 'destination-written' || typeof r.path !== 'string' || !isAbsolute(r.path)) continue;
|
|
1153
|
+
byPath.set(r.path, { path: r.path, bytes: typeof r.bytes === 'number' ? r.bytes : null, sha256: typeof r.sha256 === 'string' ? r.sha256 : null });
|
|
1154
|
+
}
|
|
1155
|
+
return [...byPath.values()];
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** @param {any} spec @returns {any[]} the signed close's rubric-step stages, in declared order */
|
|
1159
|
+
function rubricStagesOf(spec) {
|
|
1160
|
+
const stages = Array.isArray(spec?.closeDecl?.stages) ? spec.closeDecl.stages : [];
|
|
1161
|
+
return stages.filter((/** @type {any} */ s) => s && s.kind === 'rubric-step' && s.params && Number.isInteger(s.params.step));
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/** what the Result tab's open door asks, after the door's own head words */
|
|
1165
|
+
export const RESULT_DOOR_ASK = 'the checks passed; is this what you wanted?';
|
|
1166
|
+
|
|
1167
|
+
/** the two sentences for a delivered file that is no longer what the run wrote (code-owned, one spelling) */
|
|
1168
|
+
export const RESULT_FILE_GONE = 'the saved file is gone';
|
|
1169
|
+
export const RESULT_FILE_CHANGED = 'the saved file changed after the run';
|
|
1170
|
+
/** the same two states for a red run's file, which is the last one the check read (not a delivered file) */
|
|
1171
|
+
export const RESULT_JUDGED_GONE = 'the file is gone';
|
|
1172
|
+
export const RESULT_JUDGED_CHANGED = 'the file changed after the check';
|
|
1173
|
+
/** what a red run's rows are, said once above them */
|
|
1174
|
+
export const RESULT_JUDGED_NOTE = 'a red run delivers nothing; these rows are the last file the check read';
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* One delivered file, read for the Result tab or a download: it must still be a regular file at the recorded path whose sha256 equals the
|
|
1178
|
+
* record's. A mismatch or a missing file is a state, never content — stale bytes are never returned.
|
|
1179
|
+
* @param {{path: string, sha256: string|null}} rec a record from {@link deliveredOf}
|
|
1180
|
+
* @returns {{state: 'ok', buf: Buffer}|{state: 'gone'|'changed', buf: null}}
|
|
1181
|
+
*/
|
|
1182
|
+
function readDelivered(rec) {
|
|
1183
|
+
let st;
|
|
1184
|
+
try { st = lstatSync(rec.path); } catch { return { state: 'gone', buf: null }; }
|
|
1185
|
+
if (!st.isFile()) return { state: 'gone', buf: null };
|
|
1186
|
+
let buf;
|
|
1187
|
+
try { buf = readFileSync(rec.path); } catch { return { state: 'gone', buf: null }; }
|
|
1188
|
+
if (rec.sha256 === null || createHash('sha256').update(buf).digest('hex') !== rec.sha256) return { state: 'changed', buf: null };
|
|
1189
|
+
return { state: 'ok', buf };
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* A red run's rows: the file the LAST check judged for job line `n`, from the run's own `output-judged` spine record `{step, iteration, path,
|
|
1194
|
+
* bytes, sha256}` (src/planrun.js, one per check run, written before the next try overwrites the file). The spine step id is a step NAME, so the
|
|
1195
|
+
* line is matched by the path the signed close judges (`stepFileRel(n)`), never by a path taken from a record or a request. It is resolved against
|
|
1196
|
+
* the run's own tree root (`treeRootForRun`: `row.patient`, else the source-seed `tree/`), must stay inside it, and must still match the record's
|
|
1197
|
+
* sha256 — stale content is never returned. `none` = no such record (an old spine, or a try that never reached its check).
|
|
1198
|
+
* @param {any[]} records @param {number} n @param {string|null} root
|
|
1199
|
+
* @returns {{state: 'ok', buf: Buffer}|{state: 'gone'|'changed'|'none', buf: null}}
|
|
1200
|
+
*/
|
|
1201
|
+
function readJudged(records, n, root) {
|
|
1202
|
+
const rel = stepFileRel(n);
|
|
1203
|
+
const rec = records.findLast((r) => r && r.type === 'output-judged' && r.path === rel && typeof r.sha256 === 'string');
|
|
1204
|
+
if (!rec) return { state: 'none', buf: null };
|
|
1205
|
+
if (root === null) return { state: 'gone', buf: null };
|
|
1206
|
+
let abs;
|
|
1207
|
+
try {
|
|
1208
|
+
abs = realpathSync(join(root, rel));
|
|
1209
|
+
const base = realpathSync(root);
|
|
1210
|
+
if (!abs.startsWith(base + sep)) return { state: 'gone', buf: null };
|
|
1211
|
+
} catch { return { state: 'gone', buf: null }; }
|
|
1212
|
+
const got = readDelivered({ path: abs, sha256: rec.sha256 });
|
|
1213
|
+
return got.state === 'ok' ? got : { state: got.state, buf: null };
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/** a delivered step file's job line: `steps/<n>-YYYY-MM-DD[-k].json` (copyOut's dated name of `output/steps/<n>.json`), else null */
|
|
1217
|
+
function stepOfDelivered(path) {
|
|
1218
|
+
const m = /^(\d+)-\d{4}-\d{2}-\d{2}(?:-\d+)?\.json$/.exec(basename(path));
|
|
1219
|
+
return m ? Number(m[1]) : null;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* Did this stage's check pass in the run's own record? The latest `check-run` for the stage says `satisfied` AND the run's last job-end is green.
|
|
1224
|
+
* Read only — the Result tab never decides anything.
|
|
1225
|
+
* @param {any[]} records @param {string} stageName
|
|
1226
|
+
* @returns {boolean}
|
|
1227
|
+
*/
|
|
1228
|
+
function stagePassedIn(records, stageName) {
|
|
1229
|
+
const end = records.findLast((r) => r && r.type === 'job-end');
|
|
1230
|
+
if (!end || end.outcome !== 'green') return false;
|
|
1231
|
+
const run = records.findLast((r) => r && r.type === 'check-run' && r.name === stageName);
|
|
1232
|
+
return !!run && run.verdict === 'satisfied';
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/** the words beside a Result-tab page link: the page is read again from the web, so it can differ from what the run read */
|
|
1236
|
+
export const RESULT_PAGE_MAY_CHANGE = 'The page may have changed since this run.';
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* The web address each input LABEL was read from, off the run's own `source.json` (`inputs[].url`, written at drafting and rewritten at run start).
|
|
1240
|
+
* Only http(s) addresses; a file or repo input has no `url` and so no entry. No source.json beside the run = an empty map (no link, nothing guessed).
|
|
1241
|
+
* @param {string} spinePath @returns {Map<string, string>}
|
|
1242
|
+
*/
|
|
1243
|
+
function pageUrlsByLabel(spinePath) {
|
|
1244
|
+
/** @type {Map<string, string>} */
|
|
1245
|
+
const out = new Map();
|
|
1246
|
+
const { sourceJsonPath } = sourceNearSpine(spinePath);
|
|
1247
|
+
if (sourceJsonPath === null) return out;
|
|
1248
|
+
try {
|
|
1249
|
+
const m = JSON.parse(readFileSync(sourceJsonPath, 'utf8'));
|
|
1250
|
+
for (const r of Array.isArray(m?.inputs) ? m.inputs : []) {
|
|
1251
|
+
if (r && typeof r.label === 'string' && typeof r.url === 'string' && isWebUrl(r.url)) out.set(r.label, r.url.trim());
|
|
1252
|
+
}
|
|
1253
|
+
} catch { /* unreadable record: no link */ }
|
|
1254
|
+
return out;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* What a result's check read, from the run's own `rubric-evidence` record (report-only; bareguard 0.25 `full.evidence`), for the proof fold.
|
|
1259
|
+
* `null` = no evidence (an old run, a malformed claim, or a quote that is not the one the check read): the page shows the plain quote.
|
|
1260
|
+
* `text` is bareguard's normalized quote; `marks` are `[start, end]` UTF-16 offsets into it, sorted, overlaps merged. Which spans: every span the
|
|
1261
|
+
* check read EXCEPT a bind-only membership token that is not this claim's (`canon` present, `matched` absent) - hamr 2026-10-19, ruled A.
|
|
1262
|
+
* The offsets are bareguard's; nothing here searches the quote for anything.
|
|
1263
|
+
* @param {any} ev one `evidence` entry @param {unknown} quote the result's own quote as the file holds it
|
|
1264
|
+
* @returns {{text: string, marks: number[][]}|null}
|
|
1265
|
+
*/
|
|
1266
|
+
function proofOf(ev, quote) {
|
|
1267
|
+
if (!ev || typeof ev.text !== 'string' || !Array.isArray(ev.spans) || typeof quote !== 'string') return null;
|
|
1268
|
+
// the entry must be about THIS quote (bareguard's normalization: marks removed, whitespace collapsed, trimmed), else it is another try's
|
|
1269
|
+
if (quote.replace(/\*\*|__/g, '').replace(/\s+/g, ' ').trim() !== ev.text) return null;
|
|
1270
|
+
const spans = ev.spans
|
|
1271
|
+
.filter((/** @type {any} */ sp) => sp && Number.isInteger(sp.start) && Number.isInteger(sp.end) && sp.start >= 0 && sp.end > sp.start && sp.end <= ev.text.length
|
|
1272
|
+
&& !(sp.canon !== undefined && sp.matched !== true))
|
|
1273
|
+
.map((/** @type {any} */ sp) => [sp.start, sp.end])
|
|
1274
|
+
.sort((/** @type {number[]} */ x, /** @type {number[]} */ y) => x[0] - y[0] || x[1] - y[1]);
|
|
1275
|
+
/** @type {number[][]} */
|
|
1276
|
+
const marks = [];
|
|
1277
|
+
for (const sp of spans) {
|
|
1278
|
+
const last = marks[marks.length - 1];
|
|
1279
|
+
if (last && sp[0] < last[1]) last[1] = Math.max(last[1], sp[1]);
|
|
1280
|
+
else marks.push([sp[0], sp[1]]);
|
|
1281
|
+
}
|
|
1282
|
+
return { text: ev.text, marks };
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* A money fact's amount exactly as the page wrote it ("144 US dollars", "€344"): the quote's text from the first to the last of the value's own
|
|
1287
|
+
* currency/unit/value spans in bareguard's evidence for this claim (a membership token that is not the claim's own is left out). `null` = nothing
|
|
1288
|
+
* located, or more than a short run: the caller keeps today's text. Offsets are bareguard's; nothing is searched.
|
|
1289
|
+
* @param {any} ev one `evidence` entry (already known to be about this result's quote) @param {number} j the fact's index (bareguard's decide index)
|
|
1290
|
+
* @returns {string|null}
|
|
1291
|
+
*/
|
|
1292
|
+
function moneyAsRead(ev, j) {
|
|
1293
|
+
const own = ev.spans.filter((/** @type {any} */ sp) => sp && sp.decide === j && ['value', 'currency', 'unit'].includes(sp.role)
|
|
1294
|
+
&& Number.isInteger(sp.start) && Number.isInteger(sp.end) && sp.start >= 0 && sp.end > sp.start && sp.end <= ev.text.length
|
|
1295
|
+
&& !(sp.canon !== undefined && sp.matched !== true));
|
|
1296
|
+
if (own.length === 0) return null;
|
|
1297
|
+
const t = ev.text.slice(Math.min(...own.map((/** @type {any} */ sp) => sp.start)), Math.max(...own.map((/** @type {any} */ sp) => sp.end))).trim();
|
|
1298
|
+
return t !== '' && t.length <= 40 ? t : null;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/** a fact's cell: a money amount in the fact's own signed currency/unit (src/facts.js moneyAs), anything else as the file wrote it */
|
|
1302
|
+
function resultCell(f, v) {
|
|
1303
|
+
const t = cellText(v);
|
|
1304
|
+
return f.shape === 'money' && /^\d[\d.,]*$/.test(t.trim()) ? moneyAs(f, t.trim(), true) : t;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/** the words after a fact whose limit the person agreed to in a Part 5b look ask */
|
|
1308
|
+
export const RESULT_YOU_CONFIRMED = '(you confirmed)';
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* The facts of line `n` whose limit came from a Part 5b `agree` answer, read from the drafting session's `human` rows (source 'look', this line,
|
|
1312
|
+
* the answer `agree`): the ask proposed `<fact name> <ruleWords>`, and `agree` wrote exactly that as a `~` rule on the line. A fact is confirmed only
|
|
1313
|
+
* when that proposal equals what the SIGNED fact says now AND the line's signed `~` rules hold it word for word. No draft log (a Reuse run, a CLI
|
|
1314
|
+
* run, a deleted session) = nothing is confirmed; nothing is guessed.
|
|
1315
|
+
* @param {any} row @param {any} jobLine the signed job line @param {any} fp the signed fact part @param {{ home?: string, sessionsRoot?: string }} opts
|
|
1316
|
+
* @returns {Set<string>} fact names
|
|
1317
|
+
*/
|
|
1318
|
+
function confirmedFactNames(row, jobLine, fp, opts) {
|
|
1319
|
+
const names = new Set();
|
|
1320
|
+
const dir = sessionDirForRow(row, opts);
|
|
1321
|
+
if (!dir || !jobLine) return names;
|
|
1322
|
+
const written = lineRules(jobLine);
|
|
1323
|
+
const humans = readDraftSteps(dir).flatMap((st) => st.humans);
|
|
1324
|
+
for (const h of humans) {
|
|
1325
|
+
if (h.source !== 'look' || h.line !== jobLine.n || !isAgree(h.answer)) continue;
|
|
1326
|
+
const reading = readingOfQuestion(h.question);
|
|
1327
|
+
if (reading === null || !written.includes(reading)) continue;
|
|
1328
|
+
for (const f of fp.facts) if (f.rule && reading === `${f.name} ${ruleWords(f)}`) names.add(f.name);
|
|
1329
|
+
}
|
|
1330
|
+
return names;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/** a result value as the file wrote it, for a table cell */
|
|
1334
|
+
function cellText(v) {
|
|
1335
|
+
if (typeof v === 'string') return v;
|
|
1336
|
+
if (typeof v === 'number' && Number.isFinite(v)) return String(v);
|
|
1337
|
+
return '';
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* `GET /api/runs/:runid/result` — the Result tab: for each rubric job line, its words and `~` rules from the signed spec, the facts it requires
|
|
1342
|
+
* (worded by {@link factSentence}), and — read from the run's own delivered step file, sha256-checked against its `destination-written`
|
|
1343
|
+
* record — one row per result with each fact's cell. `passed` is read from the run's own `check-run` record; the tab never re-decides. A red run
|
|
1344
|
+
* delivers nothing, so it has no rows: it carries the check's own first gap instead. `null` = no such run or no result tab for it.
|
|
1345
|
+
* @param {string} runid @param {{ home?: string }} opts
|
|
1346
|
+
*/
|
|
1347
|
+
export function getRunResult(runid, /** @type {{ home?: string, sessionsRoot?: string }} */ opts = {}) {
|
|
1348
|
+
const row = findRunRow(runid, opts);
|
|
1349
|
+
if (!row || row.via === 'import' || !existsSync(row.spine)) return null;
|
|
1350
|
+
const records = parseJsonl(row.spine).records;
|
|
1351
|
+
const spec = resolveSpecForRow(row);
|
|
1352
|
+
const stages = rubricStagesOf(spec);
|
|
1353
|
+
if (stages.length === 0) return null;
|
|
1354
|
+
const delivered = deliveredOf(records);
|
|
1355
|
+
const read = delivered.map((d) => readDelivered(d));
|
|
1356
|
+
const jobLines = Array.isArray(spec?.jobLines) ? spec.jobLines : [];
|
|
1357
|
+
// Not checked, split by the line the SIGNED plan ties each item to: a refusal carries its line (closeDecl.refused[].line, planFromSpec's
|
|
1358
|
+
// `lines[].refused`), a prose step carries its stage's step. What the plan ties to no line stays in `notChecked` (the bottom). Together they are
|
|
1359
|
+
// exactly the Job tab's items (its per-line "not checked" rows plus its Not checked fold) — never a second spelling.
|
|
1360
|
+
const plan = planFromSpec(spec);
|
|
1361
|
+
const proseByLine = proseNotCheckedItems(spec?.closeDecl);
|
|
1362
|
+
const prosePool = proseByLine.map((i) => i.text);
|
|
1363
|
+
const tiedToLine = (/** @type {number} */ n) => [...(plan.lines.find((l) => l.n === n)?.refused ?? []), ...proseByLine.filter((i) => i.line === n).map((i) => i.text)];
|
|
1364
|
+
const bottom = [...plan.notChecked];
|
|
1365
|
+
for (const t of prosePool) { const k = bottom.indexOf(t); if (k >= 0) bottom.splice(k, 1); }
|
|
1366
|
+
const pageUrls = pageUrlsByLabel(row.spine);
|
|
1367
|
+
const greenEnd = records.findLast((r) => r && r.type === 'job-end')?.outcome === 'green';
|
|
1368
|
+
const lines = stages.map((/** @type {any} */ st) => {
|
|
1369
|
+
const n = st.params.step;
|
|
1370
|
+
const jl = jobLines.find((/** @type {any} */ l) => l && l.n === n) ?? null;
|
|
1371
|
+
const fp = factPartOf(st.params);
|
|
1372
|
+
const di = delivered.map((d, i) => ({ d, i })).filter(({ d }) => stepOfDelivered(d.path) === n).pop() ?? null;
|
|
1373
|
+
const passed = stagePassedIn(records, st.name);
|
|
1374
|
+
/** @type {any} */
|
|
1375
|
+
const out = {
|
|
1376
|
+
notChecked: tiedToLine(n),
|
|
1377
|
+
n, text: jl && typeof jl.text === 'string' ? jl.text : '', rules: jl ? lineRules(jl) : [], kind: fp ? 'facts' : 'text', passed,
|
|
1378
|
+
file: di ? { i: di.i, state: read[di.i].state, words: read[di.i].state === 'gone' ? RESULT_FILE_GONE : (read[di.i].state === 'changed' ? RESULT_FILE_CHANGED : null) } : null,
|
|
1379
|
+
};
|
|
1380
|
+
let obj = null;
|
|
1381
|
+
if (di && read[di.i].buf) { try { obj = JSON.parse(/** @type {Buffer} */ (read[di.i].buf).toString('utf8')); } catch { obj = null; } }
|
|
1382
|
+
if (!di && !greenEnd) {
|
|
1383
|
+
// a red run: the last file the check judged (never a ✓: `passed` is false for it)
|
|
1384
|
+
const j = readJudged(records, n, treeRootForRun(row));
|
|
1385
|
+
if (j.state !== 'none') {
|
|
1386
|
+
out.file = { i: null, state: j.state, words: j.state === 'gone' ? RESULT_JUDGED_GONE : (j.state === 'changed' ? RESULT_JUDGED_CHANGED : null), judged: true, note: RESULT_JUDGED_NOTE };
|
|
1387
|
+
if (j.buf) { try { obj = JSON.parse(j.buf.toString('utf8')); } catch { obj = null; } }
|
|
1388
|
+
}
|
|
1389
|
+
} else if (di && !read[di.i].buf) {
|
|
1390
|
+
// the delivered file is gone or changed: that is said ONCE, in the Saved card. The line shows the rows of the file the check read, if the
|
|
1391
|
+
// run's own tree still holds exactly it (sha256 against the output-judged record), else the page says there is no saved result to show
|
|
1392
|
+
const j = readJudged(records, n, treeRootForRun(row));
|
|
1393
|
+
if (j.buf) { try { obj = JSON.parse(j.buf.toString('utf8')); out.fromCheck = true; } catch { obj = null; } }
|
|
1394
|
+
}
|
|
1395
|
+
if (fp) {
|
|
1396
|
+
out.source = fp.source;
|
|
1397
|
+
out.page = pageUrls.get(fp.source) ?? null;
|
|
1398
|
+
const confirmed = confirmedFactNames(row, jl, fp, opts);
|
|
1399
|
+
out.facts = fp.facts.map((/** @type {any} */ f) => ({
|
|
1400
|
+
name: f.name, kind: f.shape === 'phrase' ? 'phrase' : 'value', sentence: factSentence(f, fp.source),
|
|
1401
|
+
...(confirmed.has(f.name) ? { note: RESULT_YOU_CONFIRMED } : {}),
|
|
1402
|
+
}));
|
|
1403
|
+
const results = obj && Array.isArray(obj.results) ? obj.results : null;
|
|
1404
|
+
// what the check read, from the run's last `rubric-evidence` record for this line (the claim index IS the result's index in `results[]`)
|
|
1405
|
+
const evRec = records.findLast((r) => r && r.type === 'rubric-evidence' && r.step === n && Array.isArray(r.evidence));
|
|
1406
|
+
const evCheck = `p${st.params.parts.indexOf(fp) + 1}-facts`;
|
|
1407
|
+
out.rows = results === null ? null : results.map((/** @type {any} */ r, /** @type {number} */ k) => {
|
|
1408
|
+
const values = r && typeof r === 'object' && r.values && typeof r.values === 'object' ? r.values : {};
|
|
1409
|
+
const quote = r && typeof r === 'object' && typeof r.quote === 'string' ? r.quote : '';
|
|
1410
|
+
const ev = evRec ? evRec.evidence.find((/** @type {any} */ e) => e && e.check === evCheck && e.claim === k) : null;
|
|
1411
|
+
const proof = proofOf(ev, quote);
|
|
1412
|
+
return {
|
|
1413
|
+
n: k + 1,
|
|
1414
|
+
quote,
|
|
1415
|
+
...(proof ? { proof } : {}),
|
|
1416
|
+
// a money amount is shown the way the check read it off the page; without evidence (or nothing located) it is the fact's own spelling
|
|
1417
|
+
cells: fp.facts.map((/** @type {any} */ f, /** @type {number} */ j) => (f.shape === 'phrase' ? String(f.phrase)
|
|
1418
|
+
: ((f.shape === 'money' && proof ? moneyAsRead(ev, j) : null) ?? resultCell(f, values[f.name])))),
|
|
1419
|
+
};
|
|
1420
|
+
});
|
|
1421
|
+
} else if (obj && typeof obj === 'object') {
|
|
1422
|
+
out.answer = typeof obj.text === 'string' ? obj.text : null;
|
|
1423
|
+
const cites = obj.cites && typeof obj.cites === 'object' ? obj.cites : {};
|
|
1424
|
+
out.cites = Object.entries(cites).filter(([, list]) => Array.isArray(list)).map(([label, list]) => ({
|
|
1425
|
+
label,
|
|
1426
|
+
items: /** @type {any[]} */ (list).map((c) => ({ claim: cellText(c?.claim), quote: cellText(c?.quote) })),
|
|
1427
|
+
}));
|
|
1428
|
+
}
|
|
1429
|
+
return out;
|
|
1430
|
+
});
|
|
1431
|
+
const end = records.findLast((r) => r && r.type === 'job-end');
|
|
1432
|
+
// a run that did not end green delivered nothing: say the check's own first gap (the Audit's try line words), never rows
|
|
1433
|
+
let gap = null;
|
|
1434
|
+
if (!end || end.outcome !== 'green') {
|
|
1435
|
+
const ev = records.findLast((r) => r && r.type === 'exit-eval' && Array.isArray(r.results) && r.results.some((/** @type {any} */ x) => x && x.type === 'check-passes' && x.pass === false));
|
|
1436
|
+
const bad = ev ? ev.results.find((/** @type {any} */ x) => x && x.type === 'check-passes' && x.pass === false) : null;
|
|
1437
|
+
const g = bad ? gapOneLine(bad.detail) : null;
|
|
1438
|
+
gap = g ? { line: g.line, more: g.more, moreText: g.more > 0 ? `(+${g.more} more)` : null } : null;
|
|
1439
|
+
}
|
|
1440
|
+
// the distinct web pages the results read, in line order: ONE = the page draws a single link at the top, 2+ = each line carries its own `page`
|
|
1441
|
+
const pages = [...new Set(lines.map((/** @type {any} */ l) => l.page).filter((/** @type {any} */ u) => typeof u === 'string'))];
|
|
1442
|
+
return {
|
|
1443
|
+
runid,
|
|
1444
|
+
lines,
|
|
1445
|
+
pages,
|
|
1446
|
+
pageNote: pages.length ? RESULT_PAGE_MAY_CHANGE : null,
|
|
1447
|
+
notChecked: bottom,
|
|
1448
|
+
// the door's words, from the one table (src/panel/status.js DOOR_WORDS); the page types none of them
|
|
1449
|
+
door: { head: DOOR_WORDS.waiting.head, ask: RESULT_DOOR_ASK },
|
|
1450
|
+
delivered: delivered.map((d, i) => ({
|
|
1451
|
+
i, path: d.path, name: basename(d.path), bytes: d.bytes, state: read[i].state,
|
|
1452
|
+
words: read[i].state === 'gone' ? RESULT_FILE_GONE : (read[i].state === 'changed' ? RESULT_FILE_CHANGED : null),
|
|
1453
|
+
})),
|
|
1454
|
+
gap,
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/** download content types by extension; anything else is plain bytes */
|
|
1459
|
+
const DOWNLOAD_TYPES = { '.json': 'application/json', '.md': 'text/plain', '.txt': 'text/plain', '.csv': 'text/csv' };
|
|
1460
|
+
|
|
1461
|
+
/**
|
|
1462
|
+
* `GET /api/runs/:runid/download?i=<n>` — delivered file `n` (an index into the run's own `destination-written` list), or why not. The path is
|
|
1463
|
+
* the record's, never the request's; the sha256 must still match the record.
|
|
1464
|
+
* @param {string} runid @param {unknown} index @param {{ home?: string }} opts
|
|
1465
|
+
* @returns {{ok: true, name: string, type: string, buf: Buffer}|{ok: false, code: number, error: string}}
|
|
1466
|
+
*/
|
|
1467
|
+
export function getRunDownload(runid, index, opts = {}) {
|
|
1468
|
+
const row = findRunRow(runid, opts);
|
|
1469
|
+
if (!row || row.via === 'import' || !existsSync(row.spine)) return { ok: false, code: 404, error: 'no such run' };
|
|
1470
|
+
const delivered = deliveredOf(parseJsonl(row.spine).records);
|
|
1471
|
+
const i = typeof index === 'string' && /^\d{1,6}$/.test(index) ? Number(index) : -1;
|
|
1472
|
+
if (i < 0 || i >= delivered.length) return { ok: false, code: 404, error: 'this run saved no such file' };
|
|
1473
|
+
const got = readDelivered(delivered[i]);
|
|
1474
|
+
if (got.state !== 'ok') return { ok: false, code: 409, error: got.state === 'gone' ? RESULT_FILE_GONE : RESULT_FILE_CHANGED };
|
|
1475
|
+
const name = basename(delivered[i].path);
|
|
1476
|
+
const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')).toLowerCase() : '';
|
|
1477
|
+
return { ok: true, name, type: Object.hasOwn(DOWNLOAD_TYPES, ext) ? /** @type {Record<string,string>} */ (DOWNLOAD_TYPES)[ext] : 'application/octet-stream', buf: got.buf };
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1096
1480
|
/**
|
|
1097
1481
|
* `GET /api/runs/:runid` — the full replay for the Run tab: steps (or
|
|
1098
1482
|
* iterations), counters, summary-box fields. Looks `runid` up in the run
|
|
@@ -1164,14 +1548,14 @@ function scheduleExtrasFor(runid, job, rows, nos, opts, view) {
|
|
|
1164
1548
|
const originNo = nos.get(cur.originRunid) ?? null;
|
|
1165
1549
|
const tries = door.skippedFor(job);
|
|
1166
1550
|
const running = rows.some((r) => r && r.job === job && r.via !== 'import' && runIsAlive(r));
|
|
1167
|
-
const { sign } = scheduleSignFor({ running, red: !!(view && view.problem) });
|
|
1551
|
+
const { sign, word } = scheduleSignFor({ running, red: !!(view && view.problem) });
|
|
1168
1552
|
const words = scheduledWords(cur.trigger, door.userHome);
|
|
1169
1553
|
const head = [lowerFirst(words ?? ''), originNo === null ? null : `from run-${originNo}`, triesTailWords(tries.length) || null].filter(Boolean).join(' · ');
|
|
1170
1554
|
const when = row ? dateTimeWords(atMs(row)) : '';
|
|
1171
1555
|
const thisRun = rec ? sectionThisRunWords(rec) : `by hand.${runid === cur.originRunid ? ' The schedule was set from this run.' : ''}`;
|
|
1172
1556
|
const shown = tries.slice(0, 7).map((x, i) => ({ label: i === 0 ? 'tries' : '', text: x.line }));
|
|
1173
1557
|
const scheduleSection = {
|
|
1174
|
-
title: SCHEDULE_WORDS.scheduleGroup, sign, head,
|
|
1558
|
+
title: SCHEDULE_WORDS.scheduleGroup, sign, word, head,
|
|
1175
1559
|
rows: [{ label: 'this run', text: `${when} ${thisRun}` }, ...(shown.length > 0 ? shown : [{ label: 'tries', text: 'none' }])],
|
|
1176
1560
|
};
|
|
1177
1561
|
return { ...(schedule ? { schedule } : {}), audit: { scheduleSection } };
|
|
@@ -1187,6 +1571,8 @@ function getRunDetailBody(runid, opts) {
|
|
|
1187
1571
|
const row = findRunRow(runid, opts);
|
|
1188
1572
|
if (!row) return null;
|
|
1189
1573
|
if (!existsSync(row.spine)) {
|
|
1574
|
+
const refusal = refusalWordsOf(readRunList(opts).events, row);
|
|
1575
|
+
if (refusal !== null) return refusedStub(row, refusal);
|
|
1190
1576
|
if (runIsAlive(row)) return startingStub(row);
|
|
1191
1577
|
return {
|
|
1192
1578
|
runid, job: row.job, at: row.at, via: row.via, fileMissing: true,
|
|
@@ -1208,7 +1594,8 @@ function getRunDetailBody(runid, opts) {
|
|
|
1208
1594
|
// the drafting part (see draftingFor) heads the parts list; the enrichment below touches the spine's own parts only
|
|
1209
1595
|
const draftingPartHere = draftingFor(row, summary, opts);
|
|
1210
1596
|
const draftingShiftHere = draftingShift(row, summary, opts);
|
|
1211
|
-
const
|
|
1597
|
+
const specForParts = resolveSpecForRow(row);
|
|
1598
|
+
const enrichedParts = enrichPartsWithStageKind(summary.parts, stageKindMetaFromSpec(specForParts), specForParts);
|
|
1212
1599
|
const death = deriveDeath(row, rawSpineRecords, summary.outcome);
|
|
1213
1600
|
const { ended, resume, status } = endedForRow(row, rawSpineRecords, summary, death, true);
|
|
1214
1601
|
// judgeModel (Summary box "judge:" line): the FIRST `judge-round`'s own
|
|
@@ -1461,9 +1848,16 @@ function getRunDetailBody(runid, opts) {
|
|
|
1461
1848
|
// carries no contamination risk); `null` when the spine carries no
|
|
1462
1849
|
// `memory-cache` record at all (never armed on this run).
|
|
1463
1850
|
behaviour: scopedBehaviour(row, rawSpineRecords),
|
|
1851
|
+
// PRD v1.106 Part 5 F: the two Run summary rows, worded here (the page prints them)
|
|
1852
|
+
callsLine: callsLineOf(rawSpineRecords, scopedBehaviour(row, rawSpineRecords)),
|
|
1853
|
+
checksLine: checksLineOf(rawSpineRecords, enrichedParts),
|
|
1464
1854
|
memoryCache: summary.memoryCache,
|
|
1465
1855
|
// T Part 8: is this run waiting for the person's Accept? The SAME answer fills the runs list's Needs-you tag (`doorWaiting`, one owner)
|
|
1466
1856
|
accept: !runIsAlive(row) && doorWaiting(rawSpineRecords),
|
|
1857
|
+
// PRD v1.107 Part 6: the Result tab shows for a rubric run whose signed close has a rubric-step stage, never otherwise; `delivered` is the
|
|
1858
|
+
// files the run's own `destination-written` records name (the Result tab's downloads index into this list, never a path from a request)
|
|
1859
|
+
hasResult: checkTypeLabel(summary.verdictType, row.at) === 'rubric' && rubricStagesOf(specForParts).length > 0,
|
|
1860
|
+
delivered: deliveredOf(rawSpineRecords).map(({ path, bytes, sha256 }) => ({ path, bytes, sha256 })),
|
|
1467
1861
|
// T Part 7: why a SCHEDULED run started (the words `trigger.json` carried) and the fixed catch-up sentence; null on every hand run
|
|
1468
1862
|
...(() => {
|
|
1469
1863
|
const trig = triggerRecordOf(row);
|
|
@@ -1900,7 +2294,7 @@ export const ROUNDS_PAGE_MAX = 200;
|
|
|
1900
2294
|
* resolve to a real window, or the spine file is missing — the caller
|
|
1901
2295
|
* renders 404, same posture as {@link getRunAudit}/{@link getRunJob}.
|
|
1902
2296
|
* @param {string} runid
|
|
1903
|
-
* @param {{ part?: number, attempt?: number, offset?: number, limit?: number }} query
|
|
2297
|
+
* @param {{ all?: boolean, part?: number, attempt?: number, offset?: number, limit?: number }} query
|
|
1904
2298
|
* @param {{ home?: string }} [opts]
|
|
1905
2299
|
* @returns {any|null}
|
|
1906
2300
|
*/
|
|
@@ -1922,6 +2316,11 @@ export function getRunRounds(runid, query = {}, opts = {}) {
|
|
|
1922
2316
|
if (shift && partIndex === 0) return null;
|
|
1923
2317
|
const part = Array.isArray(summary.parts) ? summary.parts[partIndex - shift] : null;
|
|
1924
2318
|
if (!part) return null;
|
|
2319
|
+
// PRD v1.106 Part 5 (C): a plan step with graded tries shows ONE table for the whole step (a Try column, round numbers from 1 per step,
|
|
2320
|
+
// a check row after each try, 20 rows a page); `all=1` asks for it. Every other part keeps its per-attempt table.
|
|
2321
|
+
if (query.all === true && part.kind === 'step' && Array.isArray(part.attempts) && part.attempts.some((x) => Array.isArray(x.checks))) {
|
|
2322
|
+
return stepRoundsPage(runid, row, part, { partIndex, offset, limit: Number.isInteger(query.limit) && /** @type {number} */ (query.limit) > 0 ? Math.min(/** @type {number} */ (query.limit), ROUNDS_PAGE_MAX) : STEP_ROWS_PAGE }, opts);
|
|
2323
|
+
}
|
|
1925
2324
|
const a = Array.isArray(part.attempts) ? part.attempts[attempt - 1] : null;
|
|
1926
2325
|
if (!a) return null;
|
|
1927
2326
|
/** @type {{startSeq: number, endSeq: number, outcome: string|null, detail: string|null}} */
|
|
@@ -1983,6 +2382,90 @@ export function getRunRounds(runid, query = {}, opts = {}) {
|
|
|
1983
2382
|
};
|
|
1984
2383
|
}
|
|
1985
2384
|
|
|
2385
|
+
/** the Audit step table's page size (PRD v1.106 Part 5 choice 3): rows, not rounds */
|
|
2386
|
+
export const STEP_ROWS_PAGE = 20;
|
|
2387
|
+
|
|
2388
|
+
/** the Round table's check row: the sign and the plain words, ONE place (PRD v1.106 Part 5 C) @param {number} passed @param {number} failed */
|
|
2389
|
+
export function checkRowWords(passed, failed) {
|
|
2390
|
+
return failed > 0 ? { sign: '✗', text: `${failed} failed, ${passed} passed` } : { sign: '✓', text: `${passed} passed` };
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
/**
|
|
2394
|
+
* One plan step's whole Round table (PRD v1.106 Part 5 C): every model call, every tool call and one check row per graded try, in order, each
|
|
2395
|
+
* with its Try; round numbers restart at 1 inside the step (the flat Audit view keeps the run-wide numbers). A page is `limit` ROWS.
|
|
2396
|
+
* @param {string} runid @param {any} row @param {any} part @param {{partIndex: number, offset: number, limit: number}} q @param {{ home?: string }} opts
|
|
2397
|
+
*/
|
|
2398
|
+
function stepRoundsPage(runid, row, part, q, opts) {
|
|
2399
|
+
const attempts = part.attempts.filter((/** @type {any} */ x) => Array.isArray(x.checks));
|
|
2400
|
+
const cut = part.cutOffTry ?? null;
|
|
2401
|
+
/** @type {Array<{n: number, startSeq: number, endSeq: number, a: any}>} */
|
|
2402
|
+
const windows = attempts.map((/** @type {any} */ a) => ({ n: a.n, startSeq: a.startSeq, endSeq: a.endSeq, a }));
|
|
2403
|
+
if (cut) windows.push({ n: cut.n, startSeq: cut.startSeq, endSeq: cut.endSeq, a: null });
|
|
2404
|
+
const lo = windows[0].startSeq;
|
|
2405
|
+
const hi = windows[windows.length - 1].endSeq;
|
|
2406
|
+
const tryOfSeq = (/** @type {number} */ seq) => windows.find((w) => seq > w.startSeq && seq <= w.endSeq)?.n ?? windows[windows.length - 1].n;
|
|
2407
|
+
const { records: spineRecords } = parseJsonl(row.spine);
|
|
2408
|
+
const roundOf = makeRoundLookup(spineRecords);
|
|
2409
|
+
const spend = spineRecords
|
|
2410
|
+
.filter((/** @type {any} */ r) => r && SPEND_RECORD_TYPES.includes(r.type) && typeof r.seq === 'number' && r.seq > lo && r.seq <= hi)
|
|
2411
|
+
.sort((/** @type {any} */ a, /** @type {any} */ b) => a.seq - b.seq);
|
|
2412
|
+
const auditResult = getRunAudit(runid, opts);
|
|
2413
|
+
const toolLogSaved = !!auditResult && auditResult.reason !== 'no-sidecar';
|
|
2414
|
+
/** @type {Map<number, any[]>} */
|
|
2415
|
+
const toolsByRound = new Map();
|
|
2416
|
+
if (toolLogSaved && auditResult) {
|
|
2417
|
+
for (const r of auditResult.rows) {
|
|
2418
|
+
if (r.kind !== 'tool-call' || typeof r.round !== 'number') continue;
|
|
2419
|
+
const bucket = toolsByRound.get(r.round) ?? [];
|
|
2420
|
+
bucket.push(r);
|
|
2421
|
+
toolsByRound.set(r.round, bucket);
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
const spec = resolveSpecForRow(row);
|
|
2425
|
+
const exitTs = new Map(spineRecords.filter((/** @type {any} */ r) => r && r.type === 'exit-eval' && typeof r.seq === 'number').map((/** @type {any} */ r) => [r.seq, r.ts]));
|
|
2426
|
+
/** @type {Array<{seq: number, rows: any[]}>} */
|
|
2427
|
+
const blocks = [];
|
|
2428
|
+
spend.forEach((/** @type {any} */ r, i) => {
|
|
2429
|
+
const runWide = roundOf(r.ts);
|
|
2430
|
+
const tryN = tryOfSeq(r.seq);
|
|
2431
|
+
const local = i + 1;
|
|
2432
|
+
const modelCall = toolLogSaved && auditResult ? auditResult.rows.find((x) => x.kind === 'model-call' && x.round === runWide) : null;
|
|
2433
|
+
const rows = [{
|
|
2434
|
+
kind: 'model', round: local, try: tryN, time: typeof r.ts === 'string' ? r.ts : null,
|
|
2435
|
+
costUsd: typeof r.costUsd === 'number' && Number.isFinite(r.costUsd) ? r.costUsd : null,
|
|
2436
|
+
tokens: typeof r.tokens === 'number' && Number.isFinite(r.tokens) ? r.tokens : null,
|
|
2437
|
+
durationMs: modelCall ? modelCall.durationMs : null,
|
|
2438
|
+
}];
|
|
2439
|
+
for (const tc of (runWide === null ? [] : (toolsByRound.get(runWide) ?? []))) rows.push({ ...tc, kind: 'tool', round: local, try: tryN });
|
|
2440
|
+
blocks.push({ seq: r.seq, rows });
|
|
2441
|
+
});
|
|
2442
|
+
for (const w of windows) {
|
|
2443
|
+
if (!w.a) continue; // a try cut off before its checks ran has no check row
|
|
2444
|
+
const passed = w.a.checks.filter((/** @type {any} */ c) => c.pass).length;
|
|
2445
|
+
const failed = w.a.checks.length - passed;
|
|
2446
|
+
const named = w.a.checks.find((/** @type {any} */ c) => c.type === 'check-passes');
|
|
2447
|
+
const judgedPath = named && named.name ? judgedFilesOf(spec?.closeDecl, named.name)[0] ?? null : null;
|
|
2448
|
+
blocks.push({
|
|
2449
|
+
seq: w.endSeq,
|
|
2450
|
+
rows: [{ kind: 'check', round: null, try: w.n, time: exitTs.get(w.endSeq) ?? null, path: judgedPath, ...checkRowWords(passed, failed) }],
|
|
2451
|
+
});
|
|
2452
|
+
}
|
|
2453
|
+
blocks.sort((a, b) => a.seq - b.seq);
|
|
2454
|
+
const all = blocks.flatMap((b) => b.rows);
|
|
2455
|
+
return {
|
|
2456
|
+
runid,
|
|
2457
|
+
part: q.partIndex,
|
|
2458
|
+
partKind: 'step',
|
|
2459
|
+
mode: 'step',
|
|
2460
|
+
step: partDisplayLabel(part),
|
|
2461
|
+
rows: all.slice(q.offset, q.offset + q.limit),
|
|
2462
|
+
totalRows: all.length,
|
|
2463
|
+
offset: q.offset,
|
|
2464
|
+
limit: q.limit,
|
|
2465
|
+
toolLogSaved,
|
|
2466
|
+
};
|
|
2467
|
+
}
|
|
2468
|
+
|
|
1986
2469
|
/**
|
|
1987
2470
|
* The `↳ close:`-style text for one iteration's OWN close-verdict — same
|
|
1988
2471
|
* shape as `src/replay.js`'s private `closeStageLine`, but that helper is
|
|
@@ -2236,7 +2719,7 @@ function stageQuestionText({
|
|
|
2236
2719
|
* ran. `null` when the spec has no `closeDecl.stages` array at all (a
|
|
2237
2720
|
* command-close spec, or no spec resolved).
|
|
2238
2721
|
* @param {any} spec
|
|
2239
|
-
* @returns {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null}>|null}
|
|
2722
|
+
* @returns {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null, fromLine: number[]}>|null}
|
|
2240
2723
|
*/
|
|
2241
2724
|
function stageKindMetaFromSpec(spec) {
|
|
2242
2725
|
if (!spec || typeof spec !== 'object' || !spec.closeDecl || !Array.isArray(spec.closeDecl.stages)) return null;
|
|
@@ -2252,7 +2735,7 @@ function stageKindMetaFromSpec(spec) {
|
|
|
2252
2735
|
if (key !== null) suiteCmdKeys.add(key);
|
|
2253
2736
|
}
|
|
2254
2737
|
}
|
|
2255
|
-
/** @type {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null}>} */
|
|
2738
|
+
/** @type {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null, fromLine: number[]}>} */
|
|
2256
2739
|
const map = new Map();
|
|
2257
2740
|
for (const s of spec.closeDecl.stages) {
|
|
2258
2741
|
if (!s || typeof s.name !== 'string' || s.name.length === 0) continue;
|
|
@@ -2264,7 +2747,7 @@ function stageKindMetaFromSpec(spec) {
|
|
|
2264
2747
|
kind, params, genre, suiteCmdKeys,
|
|
2265
2748
|
});
|
|
2266
2749
|
map.set(s.name, {
|
|
2267
|
-
kind, direction, baselineKind, question,
|
|
2750
|
+
kind, direction, baselineKind, question, fromLine: Array.isArray(s.fromLine) ? s.fromLine.filter(Number.isInteger) : [],
|
|
2268
2751
|
});
|
|
2269
2752
|
}
|
|
2270
2753
|
return map;
|
|
@@ -2334,21 +2817,261 @@ function attachStageKind(stage, kindMeta) {
|
|
|
2334
2817
|
* @param {Map<string, any>|null} kindMeta
|
|
2335
2818
|
* @returns {any[]|null|undefined}
|
|
2336
2819
|
*/
|
|
2337
|
-
function enrichPartsWithStageKind(parts, kindMeta) {
|
|
2820
|
+
function enrichPartsWithStageKind(parts, kindMeta, spec = null) {
|
|
2338
2821
|
if (!Array.isArray(parts)) return parts;
|
|
2339
2822
|
const declaredStagesTotal = kindMeta ? kindMeta.size : null;
|
|
2340
2823
|
const declaredStages = kindMeta
|
|
2341
2824
|
? Array.from(kindMeta, ([name, meta]) => ({ name, question: meta.question }))
|
|
2342
2825
|
: null;
|
|
2343
|
-
return parts.map((part) =>
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2826
|
+
return parts.map((part) => {
|
|
2827
|
+
const attempts = Array.isArray(part.attempts) ? part.attempts.map((a) => {
|
|
2828
|
+
// PRD v1.106 Part 5 (D1): a plan step's attempt carries the checks its try RAN (replay), worded here; they also stand in as the attempt's
|
|
2829
|
+
// `stages`, which is what the page's headline and check list read (before this they were null, so every check read "not run")
|
|
2830
|
+
const checks = Array.isArray(a.checks) ? tryChecksOf(a.checks, kindMeta, spec) : null;
|
|
2831
|
+
return {
|
|
2832
|
+
...a,
|
|
2833
|
+
...(checks ? { checks, judged: (Array.isArray(a.outputJudged) ? a.outputJudged : []).map(judgedFileLine), stages: checks.map(stageOfCheck) } : { stages: (kindMeta && Array.isArray(a.stages)) ? a.stages.map((s) => attachStageKind(s, kindMeta)) : a.stages }),
|
|
2834
|
+
...(declaredStagesTotal ? { declaredStagesTotal } : {}),
|
|
2835
|
+
...(declaredStages ? { declaredStages } : {}),
|
|
2836
|
+
};
|
|
2837
|
+
}) : part.attempts;
|
|
2838
|
+
return {
|
|
2839
|
+
...part,
|
|
2840
|
+
attempts,
|
|
2841
|
+
...(part.kind === 'step' ? (() => { const tries = triesOf(attempts, part.cutOffTry); return { tries, triesLine: triesLineOf(tries), marker: ACTOR_MARKER.model, tally: toolsTallyLine(part.byTool, part.blocked) }; })() : {}),
|
|
2842
|
+
...(part.kind === 'door' && part.door ? { marker: ACTOR_MARKER.human, doorView: doorViewOf(part.door) } : {}),
|
|
2843
|
+
};
|
|
2844
|
+
});
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
/** the words for a try the wall (or a cap, or a stop) ended before any of its checks ran (D2) */
|
|
2848
|
+
const CUT_OFF_WORDS = 'cut off before its checks ran';
|
|
2849
|
+
|
|
2850
|
+
/**
|
|
2851
|
+
* "71 model · 91 tool · 0 denied" — the Run summary's `calls:` row (PRD v1.106 Part 5 F). Model calls are the run's own spend records
|
|
2852
|
+
* (`worker-round`, `judge-round`; the attempt-level `worker-result` echoes are not rounds and drafting is not the run's), tool and denied come
|
|
2853
|
+
* from the gate-audit rows scoped to this run (the same figure `tools:` shows). No tool log saved says so, never a zero.
|
|
2854
|
+
* @param {any[]} records the run's raw spine @param {{totalCalls: number, denied: number}|null} behaviour
|
|
2855
|
+
* @returns {string}
|
|
2856
|
+
*/
|
|
2857
|
+
export function callsLineOf(records, behaviour) {
|
|
2858
|
+
const model = records.filter((r) => r && (r.type === 'worker-round' || r.type === 'judge-round')).length;
|
|
2859
|
+
return behaviour
|
|
2860
|
+
? `${model} model · ${behaviour.totalCalls} tool · ${behaviour.denied} denied`
|
|
2861
|
+
: `${model} model · tool calls: no log saved`;
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
/** "1-5", "2", "1-2, 4" from try numbers @param {number[]} ns */
|
|
2865
|
+
function tryRanges(ns) {
|
|
2866
|
+
const out = [];
|
|
2867
|
+
for (let i = 0; i < ns.length; i += 1) {
|
|
2868
|
+
let j = i;
|
|
2869
|
+
while (j + 1 < ns.length && ns[j + 1] === ns[j] + 1) j += 1;
|
|
2870
|
+
out.push(j > i ? `${ns[i]}-${ns[j]}` : String(ns[i]));
|
|
2871
|
+
i = j;
|
|
2872
|
+
}
|
|
2873
|
+
return out.join(', ');
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
/**
|
|
2877
|
+
* "16 run · 8 passed · 8 failed (write-flight-facts tries 1-5, then tries 1-3)" — counts of what ran, never a fraction (the "checks N/M"
|
|
2878
|
+
* headline stays withdrawn). Every `exit-eval` result plus every graded `outer-close` stage; the parenthesis names the steps and try ranges
|
|
2879
|
+
* that failed, from the same `tries[]` the Audit reads. A run with neither says `not recorded`.
|
|
2880
|
+
* @param {any[]} records the run's raw spine @param {any[]|null|undefined} parts the enriched parts
|
|
2881
|
+
* @returns {string}
|
|
2882
|
+
*/
|
|
2883
|
+
export function checksLineOf(records, parts) {
|
|
2884
|
+
let run = 0;
|
|
2885
|
+
let passed = 0;
|
|
2886
|
+
let outerFailed = false;
|
|
2887
|
+
for (const r of records) {
|
|
2888
|
+
if (!r) continue;
|
|
2889
|
+
if (r.type === 'exit-eval' && Array.isArray(r.results)) {
|
|
2890
|
+
for (const x of r.results) { run += 1; if (x && x.pass === true) passed += 1; }
|
|
2891
|
+
} else if (r.type === 'outer-close' && Array.isArray(r.stages)) {
|
|
2892
|
+
for (const x of r.stages) { run += 1; if (x && x.verdict === 'satisfied') passed += 1; else outerFailed = true; }
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
if (run === 0) return 'not recorded';
|
|
2896
|
+
/** @type {string[]} */
|
|
2897
|
+
const named = [];
|
|
2898
|
+
/** @type {Set<string>} */
|
|
2899
|
+
const seenLabel = new Set();
|
|
2900
|
+
for (const p of parts ?? []) {
|
|
2901
|
+
if (p.kind !== 'step' || !Array.isArray(p.tries)) continue;
|
|
2902
|
+
const bad = p.tries.filter((/** @type {any} */ t) => t.failed > 0).map((/** @type {any} */ t) => t.n);
|
|
2903
|
+
if (bad.length === 0) continue;
|
|
2904
|
+
const text = `tries ${tryRanges(bad)}`;
|
|
2905
|
+
named.push(seenLabel.has(p.label) ? `then ${text}` : `${p.label} ${text}`);
|
|
2906
|
+
seenLabel.add(p.label);
|
|
2907
|
+
}
|
|
2908
|
+
if (outerFailed) named.push('the outer close');
|
|
2909
|
+
const failed = run - passed;
|
|
2910
|
+
return `${run} run · ${passed} passed · ${failed} failed${named.length ? ` (${named.join(', ')})` : ''}`;
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
/** 1 -> "1st", 4 -> "4th", 12 -> "12th" @param {number} n */
|
|
2914
|
+
function ordinal(n) {
|
|
2915
|
+
const v = n % 100;
|
|
2916
|
+
if (v >= 11 && v <= 13) return `${n}th`;
|
|
2917
|
+
return `${n}${({ 1: 'st', 2: 'nd', 3: 'rd' })[/** @type {1|2|3} */ (n % 10)] ?? 'th'}`;
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
/**
|
|
2921
|
+
* The step card's extra line (PRD v1.106 Part 5 G): "5 tries · last try: 1 check passed, 1 failed", with the cut-off try named when there is one
|
|
2922
|
+
* ("4 tries (the 4th cut off before its checks ran) · last try: no checks ran"). Words for what passed and failed, never a fraction. `null`
|
|
2923
|
+
* when the step has no graded try (an old spine).
|
|
2924
|
+
* @param {Array<{n: number, cutOff: boolean, passed: number, failed: number, ran: number}>} tries
|
|
2925
|
+
* @returns {string|null}
|
|
2926
|
+
*/
|
|
2927
|
+
export function triesLineOf(tries) {
|
|
2928
|
+
if (!Array.isArray(tries) || tries.length === 0) return null;
|
|
2929
|
+
const last = tries[tries.length - 1];
|
|
2930
|
+
const count = `${tries.length} ${tries.length === 1 ? 'try' : 'tries'}${last.cutOff ? ` (the ${ordinal(last.n)} ${CUT_OFF_WORDS})` : ''}`;
|
|
2931
|
+
const checks = (/** @type {number} */ n) => `${n} ${n === 1 ? 'check' : 'checks'}`;
|
|
2932
|
+
let lastWords;
|
|
2933
|
+
if (last.cutOff || last.ran === 0) lastWords = 'no checks ran';
|
|
2934
|
+
else if (last.failed === 0) lastWords = `${checks(last.passed)} passed`;
|
|
2935
|
+
else if (last.passed === 0) lastWords = `${checks(last.failed)} failed`;
|
|
2936
|
+
else lastWords = `${checks(last.passed)} passed, ${last.failed} failed`;
|
|
2937
|
+
return `${count} · last try: ${lastWords}`;
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
/** tool kinds in the order the Audit tally lists them; anything else follows alphabetically */
|
|
2941
|
+
const TALLY_ORDER = ['read', 'grep', 'write', 'edit'];
|
|
2942
|
+
|
|
2943
|
+
/**
|
|
2944
|
+
* "tools: read 16 · grep 22 · write 5 · denied 0" — the step's tool calls by kind, then how many the gate denied (PRD v1.106 Part 5 C). The
|
|
2945
|
+
* `shell_`/`ctx_` prefixes are stripped like the rest of the page. `null` when no tool log was saved (nothing honest to count).
|
|
2946
|
+
* @param {Record<string, number>|null|undefined} byTool @param {number|null|undefined} blocked
|
|
2947
|
+
* @returns {string|null}
|
|
2948
|
+
*/
|
|
2949
|
+
export function toolsTallyLine(byTool, blocked) {
|
|
2950
|
+
if (!byTool || typeof byTool !== 'object') return null;
|
|
2951
|
+
const named = Object.entries(byTool).map(([k, n]) => [k.replace(/^shell_/, '').replace(/^ctx_/, ''), n]);
|
|
2952
|
+
named.sort((a, b) => {
|
|
2953
|
+
const ia = TALLY_ORDER.indexOf(String(a[0]));
|
|
2954
|
+
const ib = TALLY_ORDER.indexOf(String(b[0]));
|
|
2955
|
+
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib) || String(a[0]).localeCompare(String(b[0]));
|
|
2956
|
+
});
|
|
2957
|
+
return `tools: ${[...named.map(([k, n]) => `${k} ${n}`), `denied ${typeof blocked === 'number' ? blocked : 0}`].join(' · ')}`;
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
/**
|
|
2961
|
+
* A step's tries in order: each graded try (it ran checks) and, last, the one cut off before its checks ran (D2). `ran` is how many checks ran,
|
|
2962
|
+
* `passed`/`failed` how they went; a cut-off try ran none. The Audit, the step card and the Run summary all read this one list.
|
|
2963
|
+
* @param {any[]} attempts @param {any} cutOffTry
|
|
2964
|
+
* @returns {Array<{words?: string, n: number, cutOff: boolean, outcome: string|null, iteration: number|null, ran: number, passed: number, failed: number, thinking: string|null}>}
|
|
2965
|
+
*/
|
|
2966
|
+
function triesOf(attempts, cutOffTry) {
|
|
2967
|
+
const graded = (Array.isArray(attempts) ? attempts : []).filter((a) => Array.isArray(a.checks)).map((a) => ({
|
|
2968
|
+
n: a.n,
|
|
2969
|
+
cutOff: false,
|
|
2970
|
+
outcome: a.outcome ?? null,
|
|
2971
|
+
iteration: typeof a.iteration === 'number' ? a.iteration : null,
|
|
2972
|
+
ran: a.checks.length,
|
|
2973
|
+
passed: a.checks.filter((/** @type {any} */ c) => c.pass).length,
|
|
2974
|
+
failed: a.checks.filter((/** @type {any} */ c) => !c.pass).length,
|
|
2975
|
+
thinking: typeof a.thinking === 'string' ? a.thinking : null,
|
|
2351
2976
|
}));
|
|
2977
|
+
if (!cutOffTry) return graded;
|
|
2978
|
+
return [...graded, {
|
|
2979
|
+
words: CUT_OFF_WORDS, n: graded.length + 1, cutOff: true, outcome: null, iteration: typeof cutOffTry.iteration === 'number' ? cutOffTry.iteration : null, ran: 0, passed: 0, failed: 0, thinking: typeof cutOffTry.thinking === 'string' ? cutOffTry.thinking : null,
|
|
2980
|
+
}];
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
/** the words for a `check-passes` result that no `check-run` named (an old spine), and the one tree-changed question, both code-owned here */
|
|
2984
|
+
const FILES_CHANGED_QUESTION = 'did it change any file?';
|
|
2985
|
+
|
|
2986
|
+
/**
|
|
2987
|
+
* A rule line's failing reasons, one per `close: <stage>: …` line of a check's detail, each with that prefix (and the `line N: ` the check row
|
|
2988
|
+
* already names) removed. A detail with no `close:` lines is one reason: its own first line.
|
|
2989
|
+
* @param {string|null|undefined} detail
|
|
2990
|
+
* @returns {string[]}
|
|
2991
|
+
*/
|
|
2992
|
+
function reasonsOf(detail) {
|
|
2993
|
+
if (typeof detail !== 'string' || detail.trim() === '') return [];
|
|
2994
|
+
const lines = detail.split('\n').map((l) => l.trim()).filter((l) => l !== '');
|
|
2995
|
+
const closeLines = lines.filter((l) => l.startsWith('close: '));
|
|
2996
|
+
if (closeLines.length === 0) return [lines[0]];
|
|
2997
|
+
return closeLines.map((l) => l.replace(/^close: [^:]+: /, '').replace(/^line \d+: /, ''));
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
/**
|
|
3001
|
+
* The try line's gap: the first failing reason, and how many more there are (PRD v1.106 Part 5 C). ONE function, beside {@link stageQuestionText}.
|
|
3002
|
+
* @param {string|null|undefined} detail
|
|
3003
|
+
* @returns {{ line: string, more: number }|null}
|
|
3004
|
+
*/
|
|
3005
|
+
export function gapOneLine(detail) {
|
|
3006
|
+
const reasons = reasonsOf(detail);
|
|
3007
|
+
return reasons.length === 0 ? null : { line: reasons[0], more: reasons.length - 1 };
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
/**
|
|
3011
|
+
* "from line 1 ~ under $450; not a red-eye" — the job line(s) a check serves and the `~` rules written under them (the signed `jobLines`).
|
|
3012
|
+
* `null` when the stage serves no job line (a mandatory guard).
|
|
3013
|
+
* @param {number[]} fromLine @param {any} spec
|
|
3014
|
+
* @returns {string|null}
|
|
3015
|
+
*/
|
|
3016
|
+
export function fromLineText(fromLine, spec) {
|
|
3017
|
+
if (!Array.isArray(fromLine) || fromLine.length === 0) return null;
|
|
3018
|
+
const lines = Array.isArray(spec?.jobLines) ? spec.jobLines : [];
|
|
3019
|
+
return fromLine.map((n) => {
|
|
3020
|
+
const rule = lines.find((l) => l && l.n === n)?.rule;
|
|
3021
|
+
return `from line ${n}` + (typeof rule === 'string' && rule !== '' ? ` ~ ${rule}` : '');
|
|
3022
|
+
}).join(' · ');
|
|
3023
|
+
}
|
|
3024
|
+
|
|
3025
|
+
/**
|
|
3026
|
+
* One try's checks in the order they RAN (choice 6), worded: `{n, type, name, question, pass, detail, gap, more, reasons, from}`.
|
|
3027
|
+
* A `tree-changed` result is the declared `files-changed` stage; a `check-passes` result is the stage its `check-run` named.
|
|
3028
|
+
* @param {Array<{type: string, pass: boolean, detail: string|null, name: string|null}>} checks
|
|
3029
|
+
* @param {Map<string, any>|null} kindMeta @param {any} spec
|
|
3030
|
+
*/
|
|
3031
|
+
function tryChecksOf(checks, kindMeta, spec) {
|
|
3032
|
+
const metas = kindMeta ? [...kindMeta] : [];
|
|
3033
|
+
const filesChanged = metas.find(([, m]) => m.kind === 'files-changed') ?? null;
|
|
3034
|
+
const others = metas.filter(([, m]) => m.kind !== 'files-changed');
|
|
3035
|
+
return checks.map((c, i) => {
|
|
3036
|
+
let name = c.name;
|
|
3037
|
+
let meta = name && kindMeta ? kindMeta.get(name) ?? null : null;
|
|
3038
|
+
if (c.type === 'tree-changed' && filesChanged) { name = filesChanged[0]; meta = filesChanged[1]; }
|
|
3039
|
+
if (c.type === 'check-passes' && !meta && others.length === 1) { name = others[0][0]; meta = others[0][1]; }
|
|
3040
|
+
const reasons = c.pass ? [] : reasonsOf(c.detail);
|
|
3041
|
+
const gap = c.pass ? null : gapOneLine(c.detail);
|
|
3042
|
+
return {
|
|
3043
|
+
n: i + 1,
|
|
3044
|
+
type: c.type,
|
|
3045
|
+
name: name ?? c.type,
|
|
3046
|
+
question: c.type === 'tree-changed' ? FILES_CHANGED_QUESTION : (meta?.question ?? null),
|
|
3047
|
+
pass: c.pass,
|
|
3048
|
+
// a files-changed result names its count; a failing check-passes names its reasons (folded on the page), a passing one nothing
|
|
3049
|
+
note: c.type === 'tree-changed' ? c.detail : (c.pass ? null : `${reasons.length} reason${reasons.length === 1 ? '' : 's'}`),
|
|
3050
|
+
gap: gap ? gap.line : null,
|
|
3051
|
+
more: gap ? gap.more : 0,
|
|
3052
|
+
moreText: gap && gap.more > 0 ? `(+${gap.more} more)` : null,
|
|
3053
|
+
reasonsLabel: reasons.length > 1 ? `the ${reasons.length} reasons` : null,
|
|
3054
|
+
reasons,
|
|
3055
|
+
from: meta ? fromLineText(meta.fromLine, spec) : null,
|
|
3056
|
+
};
|
|
3057
|
+
});
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
/**
|
|
3061
|
+
* "output judged: output/steps/1.json · 2.1 KB · sha256 674b…30" — the file a try's check read (PRD v1.106 Part 5 choice 14). ONE place owns
|
|
3062
|
+
* these words. `size` is bytes under 1 KB, else KB with one decimal; the hash shows its first 4 and last 2 characters.
|
|
3063
|
+
* @param {{path: string|null, bytes: number|null, sha256: string|null}} o
|
|
3064
|
+
* @returns {string}
|
|
3065
|
+
*/
|
|
3066
|
+
export function judgedFileLine(o) {
|
|
3067
|
+
const size = typeof o.bytes === 'number' ? (o.bytes < 1024 ? `${o.bytes} B` : `${(o.bytes / 1024).toFixed(1)} KB`) : null;
|
|
3068
|
+
const sha = typeof o.sha256 === 'string' && o.sha256.length >= 8 ? `sha256 ${o.sha256.slice(0, 4)}…${o.sha256.slice(-2)}` : null;
|
|
3069
|
+
return ['output judged:', [o.path ?? 'a file', size, sha].filter(Boolean).join(' · ')].join(' ');
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
/** a worded check as the `{name, verdict, question}` stage shape the attempt headline reads */
|
|
3073
|
+
function stageOfCheck(c) {
|
|
3074
|
+
return { name: c.name, verdict: c.pass ? 'satisfied' : 'needs_revision', question: c.question };
|
|
2352
3075
|
}
|
|
2353
3076
|
|
|
2354
3077
|
/**
|
|
@@ -2504,7 +3227,7 @@ function jobsDir() {
|
|
|
2504
3227
|
* @returns {any|null}
|
|
2505
3228
|
*/
|
|
2506
3229
|
export function getRunJob(runid, opts = {}) {
|
|
2507
|
-
const body = getRunJobBody(runid, opts);
|
|
3230
|
+
const body = withThinkingRow(getRunJobBody(runid, opts), runid, opts);
|
|
2508
3231
|
if (!body || !opts.schedules) return body;
|
|
2509
3232
|
try {
|
|
2510
3233
|
const door = /** @type {ReturnType<typeof createScheduleDoor>} */ (opts.schedules);
|
|
@@ -2525,6 +3248,23 @@ export function getRunJob(runid, opts = {}) {
|
|
|
2525
3248
|
} catch { return body; }
|
|
2526
3249
|
}
|
|
2527
3250
|
|
|
3251
|
+
/**
|
|
3252
|
+
* PRD v1.106 Part 3 — the Job tab's `Thinking` row, worded by src/thinking.js and ALWAYS present (hamr 2026-10-18). The rule shown is
|
|
3253
|
+
* the job's signed "Thinking on" tick for a run whose spine recorded thinking decisions, `—` for a run from before the thinking rule; a tick that cannot be read counts as not ticked (the default), never an empty row.
|
|
3254
|
+
* @param {any|null} body the Job tab body @param {string} runid @param {{ home?: string }} opts
|
|
3255
|
+
* @returns {any|null}
|
|
3256
|
+
*/
|
|
3257
|
+
function withThinkingRow(body, runid, opts) {
|
|
3258
|
+
if (!body) return body;
|
|
3259
|
+
const { thinkingTick, ...rest } = body;
|
|
3260
|
+
let recorded = false;
|
|
3261
|
+
try {
|
|
3262
|
+
const row = findRunRow(runid, opts);
|
|
3263
|
+
recorded = !!row && existsSync(row.spine) && parseJsonl(row.spine).records.some((/** @type {any} */ r) => r?.type === 'thinking-decision');
|
|
3264
|
+
} catch { /* an unreadable spine has no records: the row reads — */ }
|
|
3265
|
+
return { ...rest, thinking: thinkingJobText(thinkingTick === true, recorded) };
|
|
3266
|
+
}
|
|
3267
|
+
|
|
2528
3268
|
/** @param {string} runid @param {{ home?: string }} opts */
|
|
2529
3269
|
function getRunJobBody(runid, opts) {
|
|
2530
3270
|
const row = findRunRow(runid, opts);
|
|
@@ -2600,6 +3340,7 @@ function getRunJobBody(runid, opts) {
|
|
|
2600
3340
|
guardrails: notRecorded(guardrailsFromSpec(spec)),
|
|
2601
3341
|
tools: notRecorded(toolsFromSpec(spec)),
|
|
2602
3342
|
toolsList: toolsListFromSpec(spec),
|
|
3343
|
+
thinkingTick: spec.thinking === true,
|
|
2603
3344
|
note,
|
|
2604
3345
|
});
|
|
2605
3346
|
const none = () => ({
|
|
@@ -2621,6 +3362,7 @@ function getRunJobBody(runid, opts) {
|
|
|
2621
3362
|
guardrails: 'not recorded',
|
|
2622
3363
|
tools: 'not recorded',
|
|
2623
3364
|
toolsList: null,
|
|
3365
|
+
thinkingTick: false,
|
|
2624
3366
|
note: 'no resolvable spec for this run (only bareloop-run bundle-layout runs carry one; a run-u run has none on disk)',
|
|
2625
3367
|
});
|
|
2626
3368
|
|
|
@@ -3088,7 +3830,7 @@ function sendText(res, code, text) {
|
|
|
3088
3830
|
* only `createPanelServer` below always supplies one.
|
|
3089
3831
|
* @param {import('node:http').IncomingMessage} req
|
|
3090
3832
|
* @param {import('node:http').ServerResponse} res
|
|
3091
|
-
* @param {{ home?: string, packageJsonPath?: string, port: number, token?: string, authorRoutes?: ReturnType<typeof createAuthorRoutes>, settingsRoutes?: ReturnType<typeof createSettingsRoutes>, runRoutes?: ReturnType<typeof createRunRoutes>, importRoutes?: ReturnType<typeof createImportRoutes>, schedules?: ReturnType<typeof createScheduleDoor> }} opts
|
|
3833
|
+
* @param {{ home?: string, sessionsRoot?: string, packageJsonPath?: string, port: number, token?: string, authorRoutes?: ReturnType<typeof createAuthorRoutes>, settingsRoutes?: ReturnType<typeof createSettingsRoutes>, runRoutes?: ReturnType<typeof createRunRoutes>, importRoutes?: ReturnType<typeof createImportRoutes>, schedules?: ReturnType<typeof createScheduleDoor> }} opts
|
|
3092
3834
|
*/
|
|
3093
3835
|
export function handleRequest(req, res, opts) {
|
|
3094
3836
|
/** the ONE writable route family for a path, or null — `/api/author/*` (authoring) or
|
|
@@ -3097,7 +3839,7 @@ export function handleRequest(req, res, opts) {
|
|
|
3097
3839
|
function routesFor(o, p) {
|
|
3098
3840
|
if (o.authorRoutes && p.startsWith('/api/author')) return o.authorRoutes;
|
|
3099
3841
|
if (o.settingsRoutes && p.startsWith('/api/settings')) return o.settingsRoutes;
|
|
3100
|
-
if (o.runRoutes && /^\/api\/runs\/[^/]+\/(resume|stop|accept|needs-remove)$/.test(p)) return o.runRoutes;
|
|
3842
|
+
if (o.runRoutes && /^\/api\/runs\/[^/]+\/(resume|stop|accept|rerun|pause|needs-remove)$/.test(p)) return o.runRoutes;
|
|
3101
3843
|
if (o.importRoutes && (p === '/api/fs/list' || p === '/api/imports' || p.startsWith('/api/imports/'))) return o.importRoutes;
|
|
3102
3844
|
return null;
|
|
3103
3845
|
}
|
|
@@ -3204,7 +3946,7 @@ export function handleRequest(req, res, opts) {
|
|
|
3204
3946
|
|
|
3205
3947
|
if (pathname === '/api/runs') { send(200, runsListPayload({ home: opts.home, schedules: opts.schedules })); return; }
|
|
3206
3948
|
|
|
3207
|
-
const runMatch = /^\/api\/runs\/([^/]+)(\/(audit|job|rounds))?$/.exec(pathname);
|
|
3949
|
+
const runMatch = /^\/api\/runs\/([^/]+)(\/(audit|job|rounds|result|download))?$/.exec(pathname);
|
|
3208
3950
|
if (runMatch) {
|
|
3209
3951
|
const rawRunid = runMatch[1];
|
|
3210
3952
|
const sub = runMatch[3] ?? null;
|
|
@@ -3226,10 +3968,31 @@ export function handleRequest(req, res, opts) {
|
|
|
3226
3968
|
send(200, result);
|
|
3227
3969
|
return;
|
|
3228
3970
|
}
|
|
3971
|
+
if (sub === 'result') {
|
|
3972
|
+
const result = getRunResult(runid, { home: opts.home, sessionsRoot: opts.sessionsRoot });
|
|
3973
|
+
if (!result) { sendText(res, 404, 'no such run, or it has no result tab'); return; }
|
|
3974
|
+
send(200, result);
|
|
3975
|
+
return;
|
|
3976
|
+
}
|
|
3977
|
+
if (sub === 'download') {
|
|
3978
|
+
const got = getRunDownload(runid, url.searchParams.get('i'), { home: opts.home });
|
|
3979
|
+
if (!got.ok) { sendText(res, got.code, got.error); return; }
|
|
3980
|
+
// the name is the delivered file's own basename; quotes and control characters never reach the header
|
|
3981
|
+
const plain = got.name.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
3982
|
+
res.writeHead(200, {
|
|
3983
|
+
'content-type': `${got.type}; charset=utf-8`,
|
|
3984
|
+
'content-length': got.buf.length,
|
|
3985
|
+
'content-disposition': `attachment; filename="${plain}"; filename*=UTF-8''${encodeURIComponent(got.name)}`,
|
|
3986
|
+
'x-content-type-options': 'nosniff',
|
|
3987
|
+
});
|
|
3988
|
+
if (method === 'HEAD') res.end(); else res.end(got.buf);
|
|
3989
|
+
return;
|
|
3990
|
+
}
|
|
3229
3991
|
if (sub === 'rounds') {
|
|
3230
3992
|
/** @param {string|null} v @returns {number|undefined} */
|
|
3231
3993
|
const num = (v) => (v !== null && /^\d+$/.test(v) ? Number(v) : undefined);
|
|
3232
3994
|
const query = {
|
|
3995
|
+
all: url.searchParams.get('all') === '1',
|
|
3233
3996
|
part: num(url.searchParams.get('part')),
|
|
3234
3997
|
attempt: num(url.searchParams.get('attempt')),
|
|
3235
3998
|
offset: num(url.searchParams.get('offset')),
|
|
@@ -3298,7 +4061,7 @@ export function createPanelServer(opts = {}) {
|
|
|
3298
4061
|
const server = createServer((req, res) => {
|
|
3299
4062
|
try {
|
|
3300
4063
|
handleRequest(req, res, {
|
|
3301
|
-
home, port: boundPort, token, authorRoutes, settingsRoutes, runRoutes, importRoutes, packageJsonPath: opts.packageJsonPath, schedules: schedulesRef,
|
|
4064
|
+
home, sessionsRoot: opts.sessionsRoot, port: boundPort, token, authorRoutes, settingsRoutes, runRoutes, importRoutes, packageJsonPath: opts.packageJsonPath, schedules: schedulesRef,
|
|
3302
4065
|
});
|
|
3303
4066
|
} catch (e) {
|
|
3304
4067
|
sendText(res, 500, `internal error: ${/** @type {Error} */ (e).message}`);
|