bareloop 0.45.0 → 0.46.1
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 +48 -0
- package/bareloop.context.md +34 -7
- package/package.json +1 -1
- package/src/attemptdiff.js +116 -0
- package/src/authorreadout.js +55 -14
- package/src/authorscout.js +1 -1
- package/src/bundle.js +90 -6
- package/src/close-integrity.js +39 -5
- package/src/config.js +5 -2
- package/src/inputlook.js +1 -47
- package/src/judged.js +3 -3
- package/src/labelscan.js +13 -4
- package/src/ladder.js +2 -2
- package/src/learn.js +943 -0
- package/src/learnstore.js +154 -0
- package/src/modellist.js +1 -1
- package/src/pagedry.js +2 -3
- package/src/pagefetch.js +2 -1
- package/src/pagerules.js +27 -4
- package/src/panel/authorroutes.js +4 -3
- package/src/panel/authorsession.js +5 -7
- package/src/panel/index.html +108 -0
- package/src/panel/schedulestart.js +5 -0
- package/src/panel/server.js +16 -134
- package/src/panel/status.js +11 -0
- package/src/planrun.js +40 -6
- package/src/providerrows.js +3 -3
- package/src/run.js +4 -2
- package/src/schedulewords.js +1 -1
- package/src/source.js +1 -1
- package/src/stagewords.js +145 -0
- package/src/text.js +4 -2
- package/src/userrun.js +28 -0
- package/src/validate.js +10 -5
- package/src/worktree.js +8 -2
- package/types/attemptdiff.d.ts +38 -0
- package/types/authorreadout.d.ts +7 -0
- package/types/config.d.ts +1 -1
- package/types/inputlook.d.ts +0 -23
- package/types/labelscan.d.ts +6 -0
- package/types/learn.d.ts +575 -0
- package/types/learnstore.d.ts +107 -0
- package/types/pagerules.d.ts +7 -1
- package/types/panel/status.d.ts +8 -0
- package/types/planrun.d.ts +13 -3
- package/types/reuse.d.ts +1 -1
- package/types/run.d.ts +8 -2
- package/types/stagewords.d.ts +91 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// The thin reader/writer around src/learn.js (cross-run learnings, ITEM33-BUILD.md rev 2). All file IO lives here and
|
|
2
|
+
// the pure rules live in learn.js; nothing in either file can reach a model.
|
|
3
|
+
//
|
|
4
|
+
// ONE ledger per user: `<keys home>/learn/ledger.json` (~/.config/bareloop/learn/ledger.json in production; tests inject a
|
|
5
|
+
// scratch home and never create the real directory). It lives outside every person's repo, is written atomically
|
|
6
|
+
// (temp file + rename) and mode 600, and every string in it passes the one secret inventory before it is written.
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync, chmodSync } from 'node:fs';
|
|
9
|
+
import { join, dirname } from 'node:path';
|
|
10
|
+
import { keysHome } from './keysfile.js';
|
|
11
|
+
import { readConfig } from './config.js';
|
|
12
|
+
import { emptyLedger, redactDeep, countLedger, holdPending, releasePending, recordHealth, buildCards, placeOf, renderHints } from './learn.js';
|
|
13
|
+
import { stageQuestionsByName } from './stagewords.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `<home>/learn/ledger.json`.
|
|
17
|
+
* @param {string} [home] test seam; production never passes it
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
export function ledgerPath(home) {
|
|
21
|
+
return join(keysHome(home), 'learn', 'ledger.json');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read the ledger. A missing file is an empty ledger; an unreadable or wrong-shaped one is reported as `problem` (and the
|
|
26
|
+
* writer then refuses to replace it: an append-only record is never silently reset).
|
|
27
|
+
* @param {string} [home]
|
|
28
|
+
* @returns {{ledger: import('./learn.js').Ledger, exists: boolean, problem: string|null}}
|
|
29
|
+
*/
|
|
30
|
+
export function readLedger(home) {
|
|
31
|
+
const file = ledgerPath(home);
|
|
32
|
+
if (!existsSync(file)) return { ledger: emptyLedger(), exists: false, problem: null };
|
|
33
|
+
try {
|
|
34
|
+
const d = JSON.parse(readFileSync(file, 'utf8'));
|
|
35
|
+
if (!d || typeof d !== 'object' || d.version !== 1 || !Array.isArray(d.rows) || !Array.isArray(d.pending)) {
|
|
36
|
+
return { ledger: emptyLedger(), exists: true, problem: `${file} is not a version-1 learnings ledger — fix or remove it` };
|
|
37
|
+
}
|
|
38
|
+
return { ledger: { ...emptyLedger(), ...d }, exists: true, problem: null };
|
|
39
|
+
} catch (/** @type {any} */ e) {
|
|
40
|
+
return { ledger: emptyLedger(), exists: true, problem: `${file} is not readable JSON (${e?.code ?? 'parse error'}) — fix or remove it` };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Write the ledger atomically (temp + rename, mode 600), every string masked through the one secret inventory.
|
|
46
|
+
* @param {import('./learn.js').Ledger} ledger @param {string} [home]
|
|
47
|
+
* @returns {string} the file written
|
|
48
|
+
*/
|
|
49
|
+
export function writeLedger(ledger, home) {
|
|
50
|
+
const file = ledgerPath(home);
|
|
51
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
52
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
53
|
+
writeFileSync(tmp, `${JSON.stringify(redactDeep(ledger), null, 1)}\n`, { mode: 0o600 });
|
|
54
|
+
chmodSync(tmp, 0o600);
|
|
55
|
+
renameSync(tmp, file);
|
|
56
|
+
return file;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Fold ONE finished run into the ledger on disk: a deterministic job's cards COUNT now; a rubric job's cards are HELD
|
|
61
|
+
* until a human Accept (they are written to `pending`, never counted, never readable). The run's health line is recorded
|
|
62
|
+
* either way. Never throws on a bad ledger file: it reports `problem` and writes nothing.
|
|
63
|
+
* @param {{home?: string, runid: string, build: import('./learn.js').CardBuild, workflow: string, place: string|null, specHash: string|null, trust: 'deterministic'|'rubric', at: string, now?: Date}} o
|
|
64
|
+
* @returns {{wrote: boolean, counted: number, held: number, problem: string|null}}
|
|
65
|
+
*/
|
|
66
|
+
export function foldRunIntoLedger({ home, runid, build, workflow, place, specHash, trust, at, now = new Date() }) {
|
|
67
|
+
const cur = readLedger(home);
|
|
68
|
+
if (cur.problem) return { wrote: false, counted: 0, held: 0, problem: cur.problem };
|
|
69
|
+
let L = cur.ledger;
|
|
70
|
+
let counted = 0;
|
|
71
|
+
let held = 0;
|
|
72
|
+
if (trust === 'rubric') {
|
|
73
|
+
L = holdPending(L, { runid, cards: build.cards, workflow, place, specHash, at }, now);
|
|
74
|
+
held = build.cards.length;
|
|
75
|
+
} else {
|
|
76
|
+
const before = L.rows.reduce((n, r) => n + r.runIds.filter((x) => x === runid).length, 0);
|
|
77
|
+
L = countLedger(L, { runid, cards: build.cards, workflow, place, specHash, trust, now });
|
|
78
|
+
counted = L.rows.reduce((n, r) => n + r.runIds.filter((x) => x === runid).length, 0) - before;
|
|
79
|
+
}
|
|
80
|
+
L = recordHealth(L, runid, build, at, now);
|
|
81
|
+
writeLedger(L, home);
|
|
82
|
+
return { wrote: true, counted, held, problem: null };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The human Accept at the review door: release the run's held rubric cards so they count (only forward, only here).
|
|
87
|
+
* @param {{home?: string, runid: string, now?: Date}} o
|
|
88
|
+
* @returns {{released: number, problem: string|null}}
|
|
89
|
+
*/
|
|
90
|
+
export function releaseRun({ home, runid, now = new Date() }) {
|
|
91
|
+
const cur = readLedger(home);
|
|
92
|
+
if (cur.problem) return { released: 0, problem: cur.problem };
|
|
93
|
+
const r = releasePending(cur.ledger, runid, now);
|
|
94
|
+
if (r.ledger !== cur.ledger) writeLedger(r.ledger, home);
|
|
95
|
+
return { released: r.released, problem: null };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The ONE setting: `learningHints` in config.json. Ships OFF (absent = off). OFF still writes the tally ($0) and the planner
|
|
100
|
+
* is simply handed nothing, so an ON/OFF contrast has data at once. An unreadable config reads as OFF.
|
|
101
|
+
* @param {string} [home]
|
|
102
|
+
* @returns {boolean}
|
|
103
|
+
*/
|
|
104
|
+
export function learningHintsOn(home) {
|
|
105
|
+
const c = readConfig({ home });
|
|
106
|
+
return c.problem === null && c.config.learningHints === true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** @param {string} p @returns {any[]} the parsed rows of a JSONL file; missing or torn lines are skipped */
|
|
110
|
+
function readJsonl(p) {
|
|
111
|
+
if (!existsSync(p)) return [];
|
|
112
|
+
/** @type {any[]} */
|
|
113
|
+
const rows = [];
|
|
114
|
+
for (const l of readFileSync(p, 'utf8').split('\n')) {
|
|
115
|
+
if (!l) continue;
|
|
116
|
+
try { rows.push(JSON.parse(l)); } catch { /* a torn line is skipped, never a crash */ }
|
|
117
|
+
}
|
|
118
|
+
return rows;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** a path resolved to its real location when it exists, else as written @param {string} p */
|
|
122
|
+
const realOr = (p) => { try { return realpathSync(p); } catch { return p; } };
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* THE RUN-END TALLY ($0, code only, no model): turn the finished run's whole record into cards and fold them into the
|
|
126
|
+
* ledger (a rubric job's are held until Accept). Called once from the one engine's run-end block (src/userrun.js
|
|
127
|
+
* `execute`, shared by the panel, the CLI and the bundle runner), inside its own try/catch: it is a record, never a verdict,
|
|
128
|
+
* and can never change a run's outcome, exit code or spend. It runs whether the hints setting is on or off.
|
|
129
|
+
* @param {{home?: string, runid: string, events: any[], auditFiles?: string[], spec: any, specHash: string|null, workdir: string|null, trust: 'deterministic'|'rubric', now?: Date}} o
|
|
130
|
+
* @returns {{wrote: boolean, counted: number, held: number, problem: string|null, noise: string|null, cards: number}}
|
|
131
|
+
*/
|
|
132
|
+
export function learnAtRunEnd({ home, runid, events, auditFiles = [], spec, specHash, workdir, trust, now = new Date() }) {
|
|
133
|
+
const audit = auditFiles.flatMap(readJsonl);
|
|
134
|
+
const build = buildCards({ runid, events, audit, workdir, spec, questions: stageQuestionsByName(spec) });
|
|
135
|
+
const at = events.findLast((e) => e?.type === 'job-end')?.ts ?? now.toISOString();
|
|
136
|
+
const r = foldRunIntoLedger({
|
|
137
|
+
home, runid, build, workflow: String(spec.job), place: placeOf(spec, realOr), specHash, trust, at, now,
|
|
138
|
+
});
|
|
139
|
+
return { ...r, noise: build.noise, cards: build.cards.length };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The block the planner is handed for this job, made ONCE at run start from the ledger as it stood — empty when the
|
|
144
|
+
* setting is off, when there is no ledger, or when nothing is readable. The one `renderHints` render, never a second.
|
|
145
|
+
* @param {{home?: string, spec: any, specHash: string|null, now?: Date}} o
|
|
146
|
+
* @returns {{text: string, ids: string[]}|null}
|
|
147
|
+
*/
|
|
148
|
+
export function hintsForRun({ home, spec, specHash, now = new Date() }) {
|
|
149
|
+
if (!learningHintsOn(home)) return null;
|
|
150
|
+
const cur = readLedger(home);
|
|
151
|
+
if (cur.problem) return null;
|
|
152
|
+
const r = renderHints(cur.ledger, { workflow: String(spec.job), place: placeOf(spec, realOr), specHash, now });
|
|
153
|
+
return r.text ? r : null;
|
|
154
|
+
}
|
package/src/modellist.js
CHANGED
|
@@ -55,7 +55,7 @@ export function idsOf(shape, body) {
|
|
|
55
55
|
* @returns {Promise<ListResult>}
|
|
56
56
|
*/
|
|
57
57
|
export async function listModels({ shape, baseUrl, key, fetchImpl = fetch, timeoutMs = LIST_TIMEOUT_MS }) {
|
|
58
|
-
const base = typeof baseUrl === 'string' ? baseUrl.trim().replace(
|
|
58
|
+
const base = typeof baseUrl === 'string' ? baseUrl.trim().replace(/(?<!\/)\/+$/, '') : '';
|
|
59
59
|
if (base === '') return { ok: false, why: 'Type a Base URL.', status: 'no-base-url' };
|
|
60
60
|
const url = `${base}/models`;
|
|
61
61
|
const started = Date.now();
|
package/src/pagedry.js
CHANGED
|
@@ -7,14 +7,13 @@
|
|
|
7
7
|
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
9
|
import { gradeStep } from './rubricstep.js';
|
|
10
|
-
import { linesOf } from './pagerules.js';
|
|
10
|
+
import { linesOf, numberAtEnd } from './pagerules.js';
|
|
11
11
|
|
|
12
12
|
/** @typedef {import('./pagerules.js').KeptFact} KeptFact @typedef {import('./pagerules.js').Dropped} Dropped */
|
|
13
13
|
|
|
14
14
|
/** a number as the page writes it, thousands commas allowed */
|
|
15
15
|
const NUM = '\\d[\\d,]*(?:\\.\\d+)?';
|
|
16
16
|
const AFTER_LABEL = new RegExp(`^\\s*(?:[$€£]\\s*)?(${NUM})`);
|
|
17
|
-
const BEFORE_UNIT = new RegExp(`(${NUM})\\s*$`);
|
|
18
17
|
/** the label every dry-read claim is made under (the checker only needs one name; the fact's own is not read) */
|
|
19
18
|
const CLAIM = 'price';
|
|
20
19
|
const SOURCE = 'page';
|
|
@@ -39,7 +38,7 @@ export function moneyResultLines(text, k) {
|
|
|
39
38
|
}
|
|
40
39
|
} else {
|
|
41
40
|
for (const u of k.unit ?? []) {
|
|
42
|
-
for (let at = ln.indexOf(u); at !== -1 && amount === undefined; at = ln.indexOf(u, at + u.length)) amount =
|
|
41
|
+
for (let at = ln.indexOf(u); at !== -1 && amount === undefined; at = ln.indexOf(u, at + u.length)) amount = numberAtEnd(ln.slice(0, at));
|
|
43
42
|
if (amount !== undefined) break;
|
|
44
43
|
}
|
|
45
44
|
}
|
package/src/pagefetch.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// fetch is a plain-words stop, never a silent empty input. Nothing here touches a model.
|
|
11
11
|
|
|
12
12
|
import { SECRET_PATTERNS } from './validate.js';
|
|
13
|
+
import { TAG } from './labelscan.js';
|
|
13
14
|
|
|
14
15
|
/** the session options (incognito: no auth ever enters; blockPrivateNetwork: SSRF guard); ONE frozen object so a test can pin them */
|
|
15
16
|
export const CONNECT_OPTIONS = Object.freeze({ incognito: true, blockPrivateNetwork: true });
|
|
@@ -59,7 +60,7 @@ export function pageContentChars(text) {
|
|
|
59
60
|
skipIndent = -1;
|
|
60
61
|
}
|
|
61
62
|
if (m && CHROME_ROLES.has(m[2])) { skipIndent = indent; continue; }
|
|
62
|
-
n += raw.replace(
|
|
63
|
+
n += raw.replace(TAG, ' ').replace(/^\s*-\s*/, '').replace(/\s+/g, ' ').trim().length;
|
|
63
64
|
}
|
|
64
65
|
return n;
|
|
65
66
|
}
|
package/src/pagerules.js
CHANGED
|
@@ -14,7 +14,7 @@ import { normLabel } from './labelscan.js';
|
|
|
14
14
|
* @typedef {{facts: MapFact[]}} PageMap
|
|
15
15
|
* @typedef {{line: number, name: string, shape: string, from: string, words: string[], unit?: string[], currency?: string[], forRule?: number, reading?: Reading}} KeptFact
|
|
16
16
|
* `words` = what the page proves: the labels of a money/number/time fact, or the alternative phrases of a phrase fact (a place is a phrase)
|
|
17
|
-
* @typedef {{line: number, rule?: number, fact: string, word?: string, reason: string, other?: string}} Dropped `other` = what the drop is compared to: the first label (R1), the primary form (R1L), the fact it would have repeated (S1)
|
|
17
|
+
* @typedef {{line: number, rule?: number, fact: string, word?: string, reason: string, other?: string}} Dropped `other` = what the drop is compared to: the first label (R1), the primary form (R1L), the fact it would have repeated (S1), the phrase it looked for (phrase-no-result)
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
/** @param {unknown} v @returns {v is Record<string, any>} */
|
|
@@ -99,7 +99,30 @@ const sameSet = (a, b) => a.size === b.size && [...a].every((x) => b.has(x));
|
|
|
99
99
|
|
|
100
100
|
/** a number as the page writes it: digits, thousands commas, optional decimals (a currency sign next to it is not part of it) */
|
|
101
101
|
const NUM = '\\d[\\d,]*(?:\\.\\d+)?';
|
|
102
|
-
|
|
102
|
+
/**
|
|
103
|
+
* The number written at the very end of `s` (only whitespace after it): the leftmost match of NUM followed by `\s*$`. Shared with src/pagedry.js (one copy). Scans back from the end, so a
|
|
104
|
+
* long run of digits and commas costs O(n) where a backtracking regex is quadratic.
|
|
105
|
+
* @param {string} s @returns {string|undefined}
|
|
106
|
+
*/
|
|
107
|
+
export function numberAtEnd(s) {
|
|
108
|
+
let e = s.length;
|
|
109
|
+
while (e > 0 && /\s/.test(s[e - 1])) e--;
|
|
110
|
+
/** the first digit of the digit/comma run ending at `end`, or -1 if the run holds none; a start never follows a digit, so it is the run's first digit */
|
|
111
|
+
const firstDigit = (/** @type {number} */ end) => {
|
|
112
|
+
let l = end;
|
|
113
|
+
while (l > 0 && /[\d,]/.test(s[l - 1])) l--;
|
|
114
|
+
while (l < end && s[l] === ',') l++;
|
|
115
|
+
return l < end ? l : -1;
|
|
116
|
+
};
|
|
117
|
+
let d = e;
|
|
118
|
+
while (d > 0 && /\d/.test(s[d - 1])) d--;
|
|
119
|
+
if (d < e && s[d - 1] === '.') {
|
|
120
|
+
const p = firstDigit(d - 1);
|
|
121
|
+
if (p !== -1) return s.slice(p, e);
|
|
122
|
+
}
|
|
123
|
+
const p = firstDigit(e);
|
|
124
|
+
return p === -1 ? undefined : s.slice(p, e);
|
|
125
|
+
}
|
|
103
126
|
const AFTER = new RegExp(`^\\s*(?:[$€£]\\s*)?(${NUM})`);
|
|
104
127
|
const AFTER_CUR = new RegExp(`^\\s*(${NUM})`);
|
|
105
128
|
const stripCommas = (/** @type {string} */ s) => s.replace(/,/g, '');
|
|
@@ -121,13 +144,13 @@ function beforeAfter(lines, label) {
|
|
|
121
144
|
let B = 0;
|
|
122
145
|
let A = 0;
|
|
123
146
|
for (const [l, i] of occs(lines, label)) {
|
|
124
|
-
if (
|
|
147
|
+
if (numberAtEnd(l.slice(0, i)) !== undefined) B += 1;
|
|
125
148
|
if (AFTER.test(l.slice(i + label.length))) A += 1;
|
|
126
149
|
}
|
|
127
150
|
return { B, A };
|
|
128
151
|
}
|
|
129
152
|
/** R1L: the numbers a UNIT form reads - the one right before each occurrence @param {string[]} lines @param {string} u @returns {Set<string>} */
|
|
130
|
-
const unitSet = (lines, u) => new Set(occs(lines, u).map(([l, i]) =>
|
|
153
|
+
const unitSet = (lines, u) => new Set(occs(lines, u).map(([l, i]) => numberAtEnd(l.slice(0, i))).filter((v) => typeof v === 'string').map((v) => stripCommas(/** @type {string} */ (v))));
|
|
131
154
|
/** R1L: the numbers a CURRENCY form reads - the one right after each occurrence @param {string[]} lines @param {string} c @returns {Set<string>} */
|
|
132
155
|
const curSet = (lines, c) => new Set(occs(lines, c).map(([l, i]) => AFTER_CUR.exec(l.slice(i + c.length))?.[1]).filter((v) => typeof v === 'string').map((v) => stripCommas(/** @type {string} */ (v))));
|
|
133
156
|
|
|
@@ -25,6 +25,7 @@ import { readFileSync } from 'node:fs';
|
|
|
25
25
|
import { planMatchesClose } from '../rubricstep.js';
|
|
26
26
|
import { createSession, validateJobCard, validateReuseCard, jobBoxesChanged, buildReuseSpec, cardFields, nextFreeJobName, startDestinationProblem, STEP_LABELS } from './authorsession.js';
|
|
27
27
|
import { parseInputs } from '../jobblock.js';
|
|
28
|
+
import { isReusableGreen } from './status.js';
|
|
28
29
|
import { proveInputFiles, looksLikeRubricInput, webFetchState } from '../source.js';
|
|
29
30
|
import { validateJob } from '../job.js';
|
|
30
31
|
|
|
@@ -282,7 +283,7 @@ export function createAuthorRoutes(opts) {
|
|
|
282
283
|
ok: true, origin: pre.origin, card, from: pre.from, note: pre.note,
|
|
283
284
|
// ONE door (v1.101): `green` = the origin earned a real green (an imported job counts), so an unchanged job reuses the signed plan;
|
|
284
285
|
// `nextName` = the Job name box once a job box changes (the box shows the origin's name until then). Nothing is locked.
|
|
285
|
-
green: pre.origin.importId !== undefined || pre.outcome
|
|
286
|
+
green: pre.origin.importId !== undefined || isReusableGreen(pre.outcome),
|
|
286
287
|
nextName: nextFreeJobName(pre.origin.job, opts.runJobNames ? opts.runJobNames() : [], { jobsDir: opts.jobsDir }),
|
|
287
288
|
specHash: pre.specHash, workflowKey: pre.workflowKey, trackRecord: pre.trackRecord,
|
|
288
289
|
line: pre.line, specNamesModel: pre.specNamesModel, specWorker: pre.specWorker,
|
|
@@ -357,7 +358,7 @@ export function createAuthorRoutes(opts) {
|
|
|
357
358
|
// a scheduled run's green waits for the person's Accept (quarantine): its plan is neither reused nor re-drafted until then
|
|
358
359
|
if (pre.outcome === 'green-held') { send(409, { ok: false, error: 'This run was started by a schedule and is waiting for your Accept. Accept it first, then it can be reused.' }); return true; }
|
|
359
360
|
card.jobName = pre.origin.job; // an unchanged job keeps its name; a typed one is ignored
|
|
360
|
-
if (fromImport !== null || pre.outcome
|
|
361
|
+
if (fromImport !== null || isReusableGreen(pre.outcome)) {
|
|
361
362
|
const rv = validateReuseCard(card, { rows });
|
|
362
363
|
if (!rv.ok) { send(400, { ok: false, error: rv.error }); return true; }
|
|
363
364
|
const spec = buildReuseSpec(pre.spec, card, rows);
|
|
@@ -422,7 +423,7 @@ export function createAuthorRoutes(opts) {
|
|
|
422
423
|
}
|
|
423
424
|
if (sub === 'sign') {
|
|
424
425
|
const claimedHash = String(body?.specHash ?? '');
|
|
425
|
-
const r = signRun(session, claimedHash, { env: envNow(), spawnFn, bareloopBin, sessionsRoot: opts.sessionsRoot, home: opts.home
|
|
426
|
+
const r = signRun(session, claimedHash, { env: envNow(), spawnFn, bareloopBin, sessionsRoot: opts.sessionsRoot, home: opts.home });
|
|
426
427
|
send(r.ok ? 200 : 400, r);
|
|
427
428
|
return true;
|
|
428
429
|
}
|
|
@@ -1146,13 +1146,11 @@ export function createSession(card, deps = {}) {
|
|
|
1146
1146
|
const no = state.steps.findLastIndex((/** @type {any} */ x) => x.status === 'running');
|
|
1147
1147
|
appendDraftLog(outDir, { kind: 'step-reds', no: no === -1 ? state.steps.length - 1 : no, stop: authored.stop ?? 'authoring-failed', reds: redsRecord(stopReds), at: new Date().toISOString() });
|
|
1148
1148
|
}
|
|
1149
|
-
refuse(
|
|
1150
|
-
|
|
1151
|
-
:
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
wholeJob: RUBRIC_FILES,
|
|
1155
|
-
}), authored.stop === 'confirm-abandoned' ? 'abandoned' : 'refused');
|
|
1149
|
+
refuse(draftStopText({
|
|
1150
|
+
stop: String(authored.stop ?? 'authoring-failed'), reds: stopReds, refusal: authored.refusal,
|
|
1151
|
+
spend: { knownUsd: state.draftSpentUsd, spendComplete: state.draftSpendComplete },
|
|
1152
|
+
wholeJob: RUBRIC_FILES,
|
|
1153
|
+
}), authored.stop === 'confirm-abandoned' ? 'abandoned' : 'refused');
|
|
1156
1154
|
return;
|
|
1157
1155
|
}
|
|
1158
1156
|
|
package/src/panel/index.html
CHANGED
|
@@ -926,6 +926,10 @@
|
|
|
926
926
|
<button class="chip" aria-pressed="false" data-view="flat" data-testid="audit-view-flat">Flat</button>
|
|
927
927
|
</div>
|
|
928
928
|
</div>
|
|
929
|
+
<div id="audit-learnings" class="audit-part" data-testid="audit-learnings" hidden>
|
|
930
|
+
<button type="button" id="audit-learnings-summary" class="btn audit-part-toggle" aria-expanded="false" data-testid="audit-learnings-summary">What the planner was told from earlier runs</button>
|
|
931
|
+
<div id="audit-learnings-list" class="audit-part-body" data-testid="audit-learnings-list" hidden></div>
|
|
932
|
+
</div>
|
|
929
933
|
<div id="audit-groups" data-testid="audit-groups"></div>
|
|
930
934
|
<div id="audit-flat-wrap" hidden>
|
|
931
935
|
<div id="audit-empty" class="hint" data-testid="audit-empty" style="font-size:13px;padding:24px 0;">select a run first</div>
|
|
@@ -2828,6 +2832,108 @@
|
|
|
2828
2832
|
// fetch, wired separately in selectRun below).
|
|
2829
2833
|
lastLegDividers = Array.isArray(detail.legDividers) ? detail.legDividers : [];
|
|
2830
2834
|
renderAuditGroups(detail);
|
|
2835
|
+
renderAuditLearnings(detail);
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
// Cross-run learnings, READ-ONLY: what the planner was handed, one group per injection (the draft, the first replan). ONE fold for the
|
|
2839
|
+
// whole section (the <details> in the Audit tab); opened, every hint shows at once as a title line + labelled rows, no inner toggles.
|
|
2840
|
+
// The fields come from the server (learn.js parseHintBlock, the one reader of the block) - this page never splits the prose.
|
|
2841
|
+
// No edit, no Reject, no button. Absent (the setting was off, or nothing was readable) = the section stays hidden.
|
|
2842
|
+
// one row of a hint's table: the same label cell + value cell the schedule section body uses
|
|
2843
|
+
function learnRow(tbody, testid, label, lines){
|
|
2844
|
+
var tr = document.createElement("tr");
|
|
2845
|
+
tr.setAttribute("data-testid", testid);
|
|
2846
|
+
var lab = document.createElement("td");
|
|
2847
|
+
lab.className = "hint";
|
|
2848
|
+
lab.style.cssText = "white-space:nowrap";
|
|
2849
|
+
lab.textContent = label;
|
|
2850
|
+
var val = document.createElement("td");
|
|
2851
|
+
val.style.cssText = "overflow-wrap:anywhere;white-space:pre-wrap";
|
|
2852
|
+
val.textContent = lines.join("\n");
|
|
2853
|
+
tr.appendChild(lab);
|
|
2854
|
+
tr.appendChild(val);
|
|
2855
|
+
tbody.appendChild(tr);
|
|
2856
|
+
}
|
|
2857
|
+
function learnHintLabel(h){
|
|
2858
|
+
var bits = [h.n, h.level, "seen " + h.count + (h.count === 1 ? " time" : " times")];
|
|
2859
|
+
if(h.check) bits.push(h.check);
|
|
2860
|
+
if(h.kinds && h.kinds.length) bits.push(h.kinds.join(", "));
|
|
2861
|
+
bits.push(h.fixed ? "fixed" : "never fixed");
|
|
2862
|
+
return bits.join(" \u00B7 ");
|
|
2863
|
+
}
|
|
2864
|
+
function renderAuditLearnings(detail){
|
|
2865
|
+
var box = document.getElementById("audit-learnings");
|
|
2866
|
+
var list = document.getElementById("audit-learnings-list");
|
|
2867
|
+
var sum = document.getElementById("audit-learnings-summary");
|
|
2868
|
+
if(!box || !list) return;
|
|
2869
|
+
var items = detail && Array.isArray(detail.learnings) ? detail.learnings : [];
|
|
2870
|
+
list.innerHTML = "";
|
|
2871
|
+
var head = "What the planner was told from earlier runs";
|
|
2872
|
+
items.forEach(function(it, gi){
|
|
2873
|
+
var hints = Array.isArray(it.hints) ? it.hints : [];
|
|
2874
|
+
var when = it.phase === "replan" ? "first replan" : "draft";
|
|
2875
|
+
if(items.length > 1){
|
|
2876
|
+
var g = document.createElement("div");
|
|
2877
|
+
g.className = "hint";
|
|
2878
|
+
g.style.cssText = "margin-top:10px;font-weight:600;";
|
|
2879
|
+
g.setAttribute("data-testid", "audit-learnings-group-" + it.phase);
|
|
2880
|
+
g.textContent = it.phase === "replan" ? "At the first replan" : "At the draft";
|
|
2881
|
+
list.appendChild(g);
|
|
2882
|
+
}
|
|
2883
|
+
var note = document.createElement("div");
|
|
2884
|
+
note.className = "hint";
|
|
2885
|
+
note.style.cssText = "margin:6px 0;overflow-wrap:anywhere;";
|
|
2886
|
+
note.textContent = (gi === 0 ? "Read-only. " : "") + "Given to the planner at the " + when + " (" + Number(it.chars).toLocaleString("en-US") + " characters)." + (gi === 0 ? " No worker sees it." : "");
|
|
2887
|
+
list.appendChild(note);
|
|
2888
|
+
hints.forEach(function(h){
|
|
2889
|
+
var hint = document.createElement("div");
|
|
2890
|
+
hint.className = "audit-table-scroll";
|
|
2891
|
+
hint.setAttribute("data-testid", "audit-learnings-hint-" + it.phase + "-" + h.n);
|
|
2892
|
+
var table = document.createElement("table");
|
|
2893
|
+
table.className = "rounds-table";
|
|
2894
|
+
var tbody = document.createElement("tbody");
|
|
2895
|
+
var trT = document.createElement("tr");
|
|
2896
|
+
var title = document.createElement("td");
|
|
2897
|
+
title.setAttribute("colspan", "2");
|
|
2898
|
+
title.style.cssText = "font-weight:600;overflow-wrap:anywhere";
|
|
2899
|
+
title.setAttribute("data-testid", "audit-learnings-hint-" + it.phase + "-" + h.n + "-title");
|
|
2900
|
+
title.textContent = learnHintLabel(h);
|
|
2901
|
+
trT.appendChild(title);
|
|
2902
|
+
tbody.appendChild(trT);
|
|
2903
|
+
var t = "audit-learnings-hint-" + it.phase + "-" + h.n + "-";
|
|
2904
|
+
if(h.said) learnRow(tbody, t + "said", "It said:", [h.said]);
|
|
2905
|
+
if(h.touched) learnRow(tbody, t + "touched", "Fix touched:", [h.touched]);
|
|
2906
|
+
if(h.changed && h.changed.length) learnRow(tbody, t + "changed", "What changed:", h.changed.concat(h.moreChanged ? ["(+" + h.moreChanged + " more)"] : []));
|
|
2907
|
+
table.appendChild(tbody);
|
|
2908
|
+
hint.appendChild(table);
|
|
2909
|
+
list.appendChild(hint);
|
|
2910
|
+
});
|
|
2911
|
+
});
|
|
2912
|
+
if(sum){
|
|
2913
|
+
// count DISTINCT hints across the draft and replan blocks: the same ledger row (ids[i], parallel to hints[i]) handed twice is one hint
|
|
2914
|
+
var seen = {};
|
|
2915
|
+
var first = 0;
|
|
2916
|
+
items.forEach(function(it){
|
|
2917
|
+
var hs = Array.isArray(it.hints) ? it.hints : [];
|
|
2918
|
+
var byId = Array.isArray(it.ids) && it.ids.length === hs.length;
|
|
2919
|
+
hs.forEach(function(h, i){
|
|
2920
|
+
var key = byId ? "id:" + it.ids[i] : "n:" + it.phase + ":" + h.n;
|
|
2921
|
+
if(!seen[key]){ seen[key] = true; first += 1; }
|
|
2922
|
+
});
|
|
2923
|
+
});
|
|
2924
|
+
var label = items.length ? head + " \u00B7 " + first + (first === 1 ? " hint" : " hints") : head;
|
|
2925
|
+
// no arrow (hamr 2026-10-23): the state chip leads; aria-expanded carries open/closed. The same `[✓] word` state markup the schedule/step headers wear (stateDotHTML), then the plain-words title
|
|
2926
|
+
var paint = function(){ sum.innerHTML = stateDotHTML(glyphClass("\u2713"), "learnings", "audit-learnings-state") + " " + escapeXml(label); };
|
|
2927
|
+
paint();
|
|
2928
|
+
// ONE fold, same as the other Audit sections: the header button toggles the body (wired once)
|
|
2929
|
+
sum.onclick = function(){
|
|
2930
|
+
var open = sum.getAttribute("aria-expanded") === "true";
|
|
2931
|
+
sum.setAttribute("aria-expanded", open ? "false" : "true");
|
|
2932
|
+
list.hidden = open;
|
|
2933
|
+
paint();
|
|
2934
|
+
};
|
|
2935
|
+
}
|
|
2936
|
+
box.hidden = items.length === 0;
|
|
2831
2937
|
}
|
|
2832
2938
|
|
|
2833
2939
|
// ONE spelling of a step's state: its dot + the bold state word — read by the Run tab's step cards AND the Audit tab's grouped rows
|
|
@@ -4843,6 +4949,8 @@
|
|
|
4843
4949
|
var suffix = reach.modelListed === false ? " (model not listed)" : "";
|
|
4844
4950
|
return { keyOk: true, ok: true, text: "[✓] " + r.envKey + " found · " + modelId + " reachable" + suffix };
|
|
4845
4951
|
}
|
|
4952
|
+
// a blank Base URL is nothing to probe, not flakiness: say the server's own plain sentence (src/modellist.js owns it)
|
|
4953
|
+
if(reach.status === "no-base-url" && reach.note) return { keyOk: true, ok: false, text: "[✗] " + reach.note };
|
|
4846
4954
|
return { keyOk: true, ok: false, text: reachStatusText(reach.status) };
|
|
4847
4955
|
}
|
|
4848
4956
|
function refreshModelStatus(){
|
|
@@ -46,6 +46,11 @@ export function perFireSpec(schedule, cause) {
|
|
|
46
46
|
if (jobSpecHash(schedule.spec) !== schedule.specHash) return { ok: false, why: 'The saved schedule no longer matches what you signed. Nothing started.' };
|
|
47
47
|
const spec = JSON.parse(JSON.stringify(schedule.spec));
|
|
48
48
|
if (cause.kind === 'file') {
|
|
49
|
+
// the card text is `label: value` lines: a name holding a line break would read back as ANOTHER Inputs line, and edge whitespace
|
|
50
|
+
// is trimmed to a path that does not exist (a leading space in the file name sits mid-path and is harmless). Refused with its reason, never rewritten.
|
|
51
|
+
if (/[\r\n]/.test(cause.path) || cause.path !== cause.path.trim()) {
|
|
52
|
+
return { ok: false, why: `The file name ${JSON.stringify(cause.path)} has a line break or a space at its end, which the job form cannot carry. Rename the file. Nothing started.` };
|
|
53
|
+
}
|
|
49
54
|
const first = Array.isArray(spec.inputs) ? spec.inputs[0] : null;
|
|
50
55
|
if (!first) return { ok: false, why: 'The saved schedule has no input for the file to replace. Nothing started.' };
|
|
51
56
|
spec.inputs = [{ ...first, value: cause.path }, ...spec.inputs.slice(1)];
|