bareloop 0.41.0 → 0.43.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +109 -1
  2. package/README.md +13 -21
  3. package/bareloop.context.md +97 -34
  4. package/package.json +4 -3
  5. package/src/authorflow.js +337 -29
  6. package/src/authoring.js +92 -7
  7. package/src/authorjob.js +84 -16
  8. package/src/authorreadout.js +89 -5
  9. package/src/authorrun.js +14 -5
  10. package/src/authorscout.js +108 -0
  11. package/src/declaredclose.js +25 -14
  12. package/src/facts.js +317 -0
  13. package/src/factwords.js +129 -0
  14. package/src/inputlook.js +301 -0
  15. package/src/interviewrun.js +0 -1
  16. package/src/job.js +40 -12
  17. package/src/jobblock.js +7 -2
  18. package/src/kinds.js +84 -3
  19. package/src/labelscan.js +170 -0
  20. package/src/pagefetch.js +125 -0
  21. package/src/panel/authorroutes.js +121 -34
  22. package/src/panel/authorsession.js +154 -45
  23. package/src/panel/index.html +845 -490
  24. package/src/panel/jobplan.js +34 -11
  25. package/src/panel/runroutes.js +163 -5
  26. package/src/panel/scheduler.js +156 -0
  27. package/src/panel/schedules.js +667 -0
  28. package/src/panel/schedulestart.js +243 -0
  29. package/src/panel/server.js +467 -48
  30. package/src/planrun.js +42 -9
  31. package/src/providerrows.js +3 -3
  32. package/src/reviewdoor.js +37 -0
  33. package/src/rubricstep.js +804 -0
  34. package/src/runlist.js +35 -0
  35. package/src/schedulewords.js +296 -0
  36. package/src/source.js +166 -1
  37. package/src/trigger.js +48 -0
  38. package/src/triggerclock.js +127 -0
  39. package/src/triggerfiles.js +215 -0
  40. package/src/triggerledger.js +79 -0
  41. package/src/u-readout.js +1 -1
  42. package/src/userrun.js +20 -9
  43. package/types/authorflow.d.ts +267 -13
  44. package/types/authoring.d.ts +29 -1
  45. package/types/authorjob.d.ts +7 -5
  46. package/types/authorreadout.d.ts +27 -2
  47. package/types/authorscout.d.ts +70 -0
  48. package/types/declaredclose.d.ts +5 -3
  49. package/types/facts.d.ts +71 -0
  50. package/types/factwords.d.ts +42 -0
  51. package/types/inputlook.d.ts +238 -0
  52. package/types/job.d.ts +8 -1
  53. package/types/jobblock.d.ts +8 -1
  54. package/types/labelscan.d.ts +38 -0
  55. package/types/pagefetch.d.ts +71 -0
  56. package/types/panel/authorroutes.d.ts +34 -1
  57. package/types/panel/authorsession.d.ts +55 -14
  58. package/types/panel/jobplan.d.ts +12 -4
  59. package/types/panel/runroutes.d.ts +32 -1
  60. package/types/panel/scheduler.d.ts +17 -0
  61. package/types/panel/schedules.d.ts +412 -0
  62. package/types/panel/schedulestart.d.ts +106 -0
  63. package/types/panel/server.d.ts +145 -14
  64. package/types/planrun.d.ts +2 -0
  65. package/types/providerrows.d.ts +2 -2
  66. package/types/reviewdoor.d.ts +27 -0
  67. package/types/rubricstep.d.ts +286 -0
  68. package/types/runlist.d.ts +20 -0
  69. package/types/schedulewords.d.ts +135 -0
  70. package/types/source.d.ts +94 -0
  71. package/types/trigger.d.ts +37 -0
  72. package/types/triggerclock.d.ts +52 -0
  73. package/types/triggerfiles.d.ts +93 -0
  74. package/types/triggerledger.d.ts +35 -0
  75. package/types/userrun.d.ts +11 -0
@@ -0,0 +1,127 @@
1
+ // T Part 1 (docs/product/PANEL-BUILD.md, "T: triggers build spec", C.1): the clock trigger's schedule maths.
2
+ // Rewritten from fwdloop src/trigger-clock.js (its readRows/appendOnce are replaced by triggerledger.js).
3
+ //
4
+ // A schedule is a typed object, never cron text:
5
+ // { kind:'daily'|'weekdays', hour, minute } | { kind:'weekly', day:'mon'|..., hour, minute }
6
+ // Times are LOCAL. One slot per calendar day at most (built with the local Date constructor), so a DST
7
+ // change day can neither lose nor double a slot: a 02:30 that does not exist shifts to 03:30, one that
8
+ // happens twice is the first. Missed slots (panel off or machine asleep) collapse to ONE catch-up.
9
+ //
10
+ // Rows (in the job's ledger.jsonl, every row carrying `sig`):
11
+ // { id:'armed:<ms>', kind:'armed', at } the trigger was set: the starting point
12
+ // { id:'fire:<slotMs>', kind:'fire', slot, at, catchUp, missed } one run is starting for that slot
13
+ // RULED 2026-10-14: `recordFire` is the CONSUMING row; the scheduler's starter calls it only after busy,
14
+ // monthly and wouldAccept have all said yes, so a refused or busy slot stays due and retries.
15
+ import { appendRow, withoutReleased } from './triggerledger.js';
16
+
17
+ /** @typedef {{kind:'daily'|'weekdays', hour:number, minute:number} | {kind:'weekly', day:string, hour:number, minute:number}} Schedule */
18
+ /** @typedef {import('./triggerledger.js').LedgerRow} ClockRow */
19
+ /** @typedef {{fire:boolean, catchUp:boolean, missed:number, slot?:number}} Due */
20
+
21
+ /** A slot seen MORE than this after its set time is a catch-up. Exactly this late is on time. */
22
+ export const GRACE_MS = 120_000;
23
+
24
+ export const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
25
+
26
+ /**
27
+ * The only way a clock schedule enters the system. Refuses anything else, naming the field.
28
+ * @param {unknown} s
29
+ * @returns {Schedule}
30
+ */
31
+ export function validateSchedule(s) {
32
+ if (!s || typeof s !== 'object' || Array.isArray(s)) throw new Error('schedule must be an object like { kind, hour, minute }, not text');
33
+ const o = /** @type {Record<string, unknown>} */ (s);
34
+ const kinds = ['daily', 'weekdays', 'weekly'];
35
+ if (!kinds.includes(/** @type {string} */ (o.kind))) throw new Error(`schedule kind "${o.kind}" is not one of ${kinds.join(', ')}`);
36
+ const allowed = o.kind === 'weekly' ? ['kind', 'day', 'hour', 'minute'] : ['kind', 'hour', 'minute'];
37
+ for (const k of Object.keys(o)) if (!allowed.includes(k)) throw new Error(`schedule field "${k}" is not allowed on a ${o.kind} schedule`);
38
+ const hour = o.hour; const minute = o.minute;
39
+ if (!Number.isInteger(hour) || /** @type {number} */ (hour) < 0 || /** @type {number} */ (hour) > 23) throw new Error('schedule hour must be a whole number 0-23');
40
+ if (!Number.isInteger(minute) || /** @type {number} */ (minute) < 0 || /** @type {number} */ (minute) > 59) throw new Error('schedule minute must be a whole number 0-59');
41
+ if (o.kind === 'weekly') {
42
+ if (typeof o.day !== 'string' || !DAYS.includes(o.day)) throw new Error(`schedule day must be one of ${DAYS.join(', ')}`);
43
+ return { kind: 'weekly', day: o.day, hour: /** @type {number} */ (hour), minute: /** @type {number} */ (minute) };
44
+ }
45
+ return { kind: /** @type {'daily'|'weekdays'} */ (o.kind), hour: /** @type {number} */ (hour), minute: /** @type {number} */ (minute) };
46
+ }
47
+
48
+ /**
49
+ * Slots (epoch ms) in (afterMs, untilMs], oldest first.
50
+ * @param {Schedule} sc
51
+ * @param {number} afterMs
52
+ * @param {number} untilMs
53
+ * @returns {number[]}
54
+ */
55
+ function slotsBetween(sc, afterMs, untilMs) {
56
+ const out = [];
57
+ const s = new Date(afterMs);
58
+ for (let i = 0; ; i++) {
59
+ const d = new Date(s.getFullYear(), s.getMonth(), s.getDate() + i);
60
+ if (d.getTime() > untilMs) break;
61
+ const dow = d.getDay();
62
+ const ok = sc.kind === 'daily'
63
+ || (sc.kind === 'weekdays' && dow >= 1 && dow <= 5)
64
+ || (sc.kind === 'weekly' && dow === DAYS.indexOf(/** @type {{day:string}} */ (sc).day));
65
+ if (!ok) continue;
66
+ const slot = new Date(d.getFullYear(), d.getMonth(), d.getDate(), sc.hour, sc.minute).getTime();
67
+ if (slot > afterMs && slot <= untilMs) out.push(slot);
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /**
73
+ * The starting point: the latest of the arm time(s) and every consumed fire slot. No arm row = null.
74
+ * @param {ClockRow[]} rows
75
+ */
76
+ function lastPoint(rows) {
77
+ let armed = false; let last = -Infinity;
78
+ for (const r of withoutReleased(rows)) {
79
+ if (r.kind === 'armed') { armed = true; last = Math.max(last, /** @type {number} */ (r.at)); } else if (r.kind === 'fire') last = Math.max(last, /** @type {number} */ (r.slot));
80
+ }
81
+ return armed ? last : null;
82
+ }
83
+
84
+ /**
85
+ * Is a run due at `now`? Slots missed (panel off) collapse to ONE fire, for the latest slot, marked catch-up.
86
+ * Catch-up = more than one slot was missed, or the latest slot is more than GRACE_MS old.
87
+ * @param {Schedule} schedule
88
+ * @param {ClockRow[]} rows
89
+ * @param {number} now epoch ms
90
+ * @returns {Due}
91
+ */
92
+ export function due(schedule, rows, now) {
93
+ const last = lastPoint(rows);
94
+ if (last === null) return { fire: false, catchUp: false, missed: 0 };
95
+ const slots = slotsBetween(validateSchedule(schedule), last, now);
96
+ if (!slots.length) return { fire: false, catchUp: false, missed: 0 };
97
+ const slot = slots[slots.length - 1];
98
+ const catchUp = slots.length > 1 || now - slot > GRACE_MS;
99
+ return { fire: true, catchUp, missed: catchUp ? slots.length : 0, slot };
100
+ }
101
+
102
+ /**
103
+ * The next slot time strictly after `afterMs` (epoch ms). Words are the caller's (schedulewords.js).
104
+ * @param {Schedule} schedule
105
+ * @param {number} afterMs
106
+ * @returns {number}
107
+ */
108
+ export function nextSlot(schedule, afterMs) {
109
+ const sc = validateSchedule(schedule);
110
+ // a week plus a day always holds a slot of any kind
111
+ const found = slotsBetween(sc, afterMs, afterMs + 9 * 86_400_000)[0];
112
+ return /** @type {number} */ (found);
113
+ }
114
+
115
+ /** The trigger was set at `now` under signature `sig`: no slot before this ever fires. @param {string} file @param {number} now @param {string} sig */
116
+ export function armTrigger(file, now, sig) {
117
+ appendRow(file, { id: `armed:${now}`, kind: 'armed', at: now, sig }, { once: true });
118
+ }
119
+
120
+ /**
121
+ * Record that the run for this due slot is starting (the consuming row). Write-once per slot.
122
+ * @param {string} file @param {Due} d the result of `due` with fire true @param {number} now @param {string} sig
123
+ */
124
+ export function recordFire(file, d, now, sig) {
125
+ if (!d.fire || d.slot === undefined) throw new Error('recordFire needs a due result with fire true');
126
+ appendRow(file, { id: `fire:${d.slot}`, kind: 'fire', slot: d.slot, at: now, catchUp: d.catchUp, missed: d.missed, sig }, { once: true });
127
+ }
@@ -0,0 +1,215 @@
1
+ // T Part 2 (docs/product/PANEL-BUILD.md, "T: triggers build spec", C.2): the file triggers.
2
+ // Rewritten from fwdloop src/trigger-files.js. Poll-based, NOT fs.watch (EMFILE, and it misses atomic renames).
3
+ // This file only says WHAT to run ({path, sha, size}); starting the run, pinning the input and the busy,
4
+ // monthly and wouldAccept checks are the caller's (src/panel/schedules.js `fire`).
5
+ //
6
+ // Modes: 'updated' — one file; 'new' — one folder, top level only, regular files only.
7
+ // Symlinks are refused, checked at use time (realpathSync on the folder, lstat on the file), never lexically.
8
+ // A file is taken only after its size AND mtime were unchanged for STABLE_POLLS consecutive polls.
9
+ //
10
+ // Seen rows live in the job's ONE ledger (triggerledger.js reads and appends; every row carries `sig`):
11
+ // { id, kind:'baseline'|'arrived'|'ran'|'dropped', sha, name, size, at, sig } (+ `path` on arrived, `why` and `of` on dropped)
12
+ // 'baseline' = present when the trigger was armed: never runs. 'arrived' = a finished file with a never-seen sha was
13
+ // seen (a 2nd arrival of a dropped sha gets id `arrived:<sha>:<n>`). 'ran' = the CONSUMING row, written by the
14
+ // starter's `beforeSpawn` hook only AFTER busy, monthly and wouldAccept said yes (T16, RULED 2026-10-14): a refused
15
+ // start writes no `ran`, so the file stays at the head of the queue and the next tick tries again. A `released`
16
+ // row (a spawn error after `ran`) cancels the `ran`, and the file is retryable. 'dropped' = deleted or changed
17
+ // before its turn. A sha with a live `ran` row never arrives again, whatever its name or mtime.
18
+ // poll() writes nothing: it only reports finished files whose sha is new.
19
+ import {
20
+ existsSync, lstatSync, readFileSync, readdirSync, realpathSync,
21
+ } from 'node:fs';
22
+ import { basename, dirname, join } from 'node:path';
23
+ import { createHash } from 'node:crypto';
24
+ import { appendRow, readLedger, withoutReleased } from './triggerledger.js';
25
+
26
+ /** @typedef {{id:string, kind:'baseline'|'arrived'|'ran'|'dropped', sha:string, name:string, size:number, at:number, sig?:string, path?:string, why?:string, of?:string}} SeenRow */
27
+ /** @typedef {{path:string, sha:string, size:number}} ToRun */
28
+ /** @typedef {{path:string, why:string}} Refused */
29
+
30
+ /** Consecutive polls with the same size and mtime before a file is taken. (Two matches in a row = taken on the third sighting.) */
31
+ export const STABLE_POLLS = 2;
32
+
33
+ const SEEN_KINDS = ['baseline', 'arrived', 'ran', 'dropped'];
34
+
35
+ /** The file-kind rows of the job's ledger that still count (a released `ran` does not). @param {string} file @returns {SeenRow[]} */
36
+ export function readSeen(file) {
37
+ return /** @type {SeenRow[]} */ (withoutReleased(readLedger(file).rows).filter((r) => SEEN_KINDS.includes(r.kind)));
38
+ }
39
+
40
+ /** The ONE appender here: write-once by live id (a second row with the same id is not written). @param {string} file @param {SeenRow} row @returns {boolean} written */
41
+ function appendSeen(file, row) {
42
+ if (readSeen(file).some((r) => r.id === row.id)) return false;
43
+ appendRow(file, row);
44
+ return true;
45
+ }
46
+ /** Rows of one sha, file order. @param {SeenRow[]} rows @param {string} sha */
47
+ const ofSha = (rows, sha) => rows.filter((r) => r.sha === sha);
48
+ /** An arrived row is still waiting: neither its sha ran nor a dropped row names it. @param {SeenRow[]} rows @param {SeenRow} a */
49
+ const waiting = (rows, a) => a.kind === 'arrived'
50
+ && !rows.some((r) => (r.kind === 'ran' && r.sha === a.sha) || (r.kind === 'dropped' && ((/** @type {any} */ (r)).of ?? `arrived:${r.sha}`) === a.id));
51
+
52
+ /**
53
+ * Does this finished version arrive? Item 6 + am2: only a sha that already RAN never arrives again; one already arrived and still queued does not
54
+ * arrive twice; one that was dropped (never ran) may. A `baseline` blocks only while it is still the starting point: file-updated = until the file
55
+ * has changed away (any later arrived/ran/dropped row of ANOTHER sha); new-file = for that sha for good (hamr 2026-10-13: content present at arm never runs, so a renamed or copied baseline file is not new).
56
+ * @param {SeenRow[]} rows @param {{sha:string}} it @param {'new'|'updated'} mode
57
+ */
58
+ function arrives(rows, it, mode) {
59
+ const mine = ofSha(rows, it.sha);
60
+ if (mine.some((r) => r.kind === 'ran' || waiting(rows, r))) return false;
61
+ const bi = rows.findLastIndex((r) => r.kind === 'baseline' && r.sha === it.sha);
62
+ if (bi < 0) return true;
63
+ return mode === 'updated' && rows.slice(bi + 1).some((r) => r.kind !== 'baseline' && r.sha !== it.sha);
64
+ }
65
+
66
+ /** Arrived rows are never overwritten: the 2nd arrival of a sha (after a drop) gets its own id. @param {string} ledger @param {ToRun & {name?:string}} item @param {number} at the scheduler's clock, never the file's mtime @param {string} sig */
67
+ export function recordArrived(ledger, item, at, sig) {
68
+ const rows = readSeen(ledger);
69
+ if (rows.some((r) => r.sha === item.sha && (r.kind === 'ran' || waiting(rows, r)))) return false; // one writer decides: ran or already queued
70
+ const n = rows.filter((r) => r.kind === 'arrived' && r.sha === item.sha).length;
71
+ return appendSeen(ledger, { id: n ? `arrived:${item.sha}:${n}` : `arrived:${item.sha}`, kind: 'arrived', sha: item.sha, name: basename(item.path), size: item.size, path: item.path, at, sig });
72
+ }
73
+ /** The consuming row. Called ONLY by the starter's `beforeSpawn` hook, after the starter agreed (T16). @param {string} ledger @param {{sha:string, path:string, size:number}} item @param {number} at @param {string} sig */
74
+ export const recordRan = (ledger, item, at, sig) => appendSeen(ledger, { id: `ran:${item.sha}`, kind: 'ran', sha: item.sha, name: basename(item.path), size: item.size, at, sig });
75
+ /** `of` = the arrived row it cancels (item.id when a queue row is passed). @param {string} ledger @param {{sha:string, path:string, size:number, id?:string}} item @param {string} why a plain reason: deleted, changed, schedule-changed, schedule-cancelled @param {number} at @param {string} sig */
76
+ export function recordDropped(ledger, item, why, at, sig) {
77
+ const of = item.id ?? `arrived:${item.sha}`;
78
+ return appendSeen(ledger, { id: `dropped:${of}`, kind: 'dropped', sha: item.sha, name: basename(item.path), size: item.size, why, at, of, sig });
79
+ }
80
+
81
+ /**
82
+ * The queue: arrived and neither ran nor dropped, oldest arrival first. Identical arrival times: by name, then by sha (deterministic).
83
+ * @param {SeenRow[]} rows @returns {(SeenRow & {path:string})[]}
84
+ */
85
+ export function queueOf(rows) {
86
+ const cmp = (/** @type {string} */ x, /** @type {string} */ y) => (x < y ? -1 : x > y ? 1 : 0);
87
+ return /** @type {any} */ (rows.filter((r) => waiting(rows, r)))
88
+ .sort((x, y) => x.at - y.at || cmp(x.name, y.name) || cmp(x.sha, y.sha));
89
+ }
90
+
91
+ /**
92
+ * At a queued file's turn, look at it again, at use time: lstat (no symlink, a regular file), realpath of its folder, re-hash.
93
+ * @param {{path:string, sha:string}} item
94
+ * @returns {{state:'ok', path:string, sha:string, size:number} | {state:'deleted', say:string} | {state:'changed', sha:string}}
95
+ */
96
+ export function recheck(item) {
97
+ let parent;
98
+ try { parent = realpathSync(dirname(item.path)); } catch { return { state: 'deleted', say: 'its folder is gone' }; }
99
+ const p = join(parent, basename(item.path));
100
+ let st;
101
+ try { st = lstatSync(p); } catch { return { state: 'deleted', say: 'it was deleted' }; }
102
+ if (st.isSymbolicLink() || !st.isFile()) return { state: 'deleted', say: 'it is no longer a regular file (a symlink is refused)' };
103
+ let buf;
104
+ try { buf = readFileSync(p); } catch { return { state: 'deleted', say: 'it was deleted' }; }
105
+ const sha = sha256(buf);
106
+ return sha === item.sha ? { state: 'ok', path: p, sha, size: buf.length } : { state: 'changed', sha };
107
+ }
108
+
109
+ /** @param {Buffer} buf */
110
+ const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
111
+
112
+ /**
113
+ * The target is usable NOW, or an Error naming why: a file that is not a symlink and not something else (a file that does not exist yet
114
+ * is allowed: it may arrive), a folder that exists, resolved with realpathSync. The one rule `arm` and a door's set-time check share.
115
+ * @param {'new'|'updated'} mode @param {string} target
116
+ */
117
+ export function checkTarget(mode, target) {
118
+ if (mode === 'updated') {
119
+ if (existsSync(target) && lstatSync(target).isSymbolicLink()) throw new Error(`file to watch ${target} is a symlink; a symlink is refused`);
120
+ if (existsSync(target) && !lstatSync(target).isFile()) throw new Error(`${target} is not a file`);
121
+ } else {
122
+ let real;
123
+ try { real = realpathSync(target); } catch { throw new Error(`folder to watch ${target} does not exist`); }
124
+ if (!lstatSync(real).isDirectory()) throw new Error(`${target} is not a folder`);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * @param {{mode:'new'|'updated', target:string, ledger:string, sig:string, now?:() => number}} opts target = folder (new) or file (updated)
130
+ */
131
+ export function createFileTrigger({ mode, target, ledger, sig, now = Date.now }) {
132
+ if (mode !== 'new' && mode !== 'updated') throw new Error(`file trigger mode "${mode}" is not one of new, updated`);
133
+ /** @type {Map<string, {key:string, same:number, takenKey:string|null}>} */
134
+ const track = new Map();
135
+ const toldRefused = new Set();
136
+
137
+ /**
138
+ * The regular-file candidates right now, re-resolved at use time.
139
+ * @returns {{files:string[], refused:Refused[]}}
140
+ */
141
+ function candidates() {
142
+ /** @type {Refused[]} */ const refused = [];
143
+ if (mode === 'updated') {
144
+ let parent;
145
+ try { parent = realpathSync(dirname(target)); } catch { return { files: [], refused }; }
146
+ const p = join(parent, basename(target));
147
+ let st;
148
+ try { st = lstatSync(p); } catch { return { files: [], refused }; }
149
+ if (st.isSymbolicLink()) return { files: [], refused: [{ path: p, why: 'a symlink is refused' }] };
150
+ if (!st.isFile()) return { files: [], refused: [{ path: p, why: 'not a regular file' }] };
151
+ return { files: [p], refused };
152
+ }
153
+ let real;
154
+ try { real = realpathSync(target); } catch { return { files: [], refused }; }
155
+ const files = [];
156
+ for (const name of readdirSync(real)) {
157
+ const p = join(real, name);
158
+ let st;
159
+ try { st = lstatSync(p); } catch { continue; }
160
+ if (st.isSymbolicLink()) refused.push({ path: p, why: 'a symlink is refused' });
161
+ else if (st.isFile()) files.push(p);
162
+ }
163
+ return { files, refused };
164
+ }
165
+
166
+ /** Validate the target now and record what is already there as the baseline: it never runs. */
167
+ function arm() {
168
+ checkTarget(mode, target);
169
+ for (const p of candidates().files) {
170
+ const buf = readFileSync(p);
171
+ const sha = sha256(buf);
172
+ appendSeen(ledger, { id: `baseline:${sha}:${basename(p)}`, kind: 'baseline', sha, name: basename(p), size: buf.length, at: now(), sig }); // write-once: re-arming adds nothing
173
+ }
174
+ }
175
+
176
+ /** @param {string} p @param {{key:string}} t @returns {ToRun|null|'changed'} null = this sha already ran */
177
+ function take(p, t) {
178
+ const buf = readFileSync(p);
179
+ const st2 = lstatSync(p);
180
+ if (`${st2.size}:${st2.mtimeMs}` !== t.key) return 'changed'; // changed while reading: retry next poll
181
+ const sha = sha256(buf);
182
+ if (!arrives(readSeen(ledger), { sha }, mode)) return null; // ran, still queued, or still the starting point
183
+ return { path: p, sha, size: buf.length };
184
+ }
185
+
186
+ /**
187
+ * One poll. Synchronous, so a caller drives it from setInterval and a test drives it by hand.
188
+ * @returns {{arrived: ToRun[], refused: Refused[]}} finished files with a never-seen sha (the caller records them); refused paths are told once per trigger object
189
+ */
190
+ function poll() {
191
+ const { files, refused } = candidates();
192
+ const arrived = [];
193
+ const present = new Set(files);
194
+ for (const p of [...track.keys()]) if (!present.has(p)) track.delete(p);
195
+ for (const p of files) {
196
+ let st;
197
+ try { st = lstatSync(p); } catch { continue; }
198
+ const key = `${st.size}:${st.mtimeMs}`;
199
+ let t = track.get(p);
200
+ if (!t) { t = { key, same: 0, takenKey: null }; track.set(p, t); } else if (t.key !== key) { t.key = key; t.same = 0; } else t.same++;
201
+ if (t.same >= STABLE_POLLS && t.takenKey !== key) {
202
+ let got;
203
+ try { got = take(p, t); } catch { continue; } // vanished while reading
204
+ if (got === 'changed') continue;
205
+ t.takenKey = key;
206
+ if (got) arrived.push(got);
207
+ }
208
+ }
209
+ const told = refused.filter((r) => !toldRefused.has(r.path));
210
+ for (const r of told) toldRefused.add(r.path);
211
+ return { arrived, refused: told };
212
+ }
213
+
214
+ return { arm, poll };
215
+ }
@@ -0,0 +1,79 @@
1
+ // T Part 1 (docs/product/PANEL-BUILD.md, "T: triggers build spec", C.1 and E.2): the ONE reader and the ONE
2
+ // appender of a job's trigger ledger, `<home>/triggers/<job>/ledger.jsonl`. Append-only JSONL, one file for
3
+ // every row kind, every row carrying `sig` (the signature hash it was written under).
4
+ // Rewritten from fwdloop's trigger-clock.js readRows/appendOnce, with two fixes landed together (T17): a line
5
+ // that does not parse (a torn append) is SKIPPED and counted, and an append starts on a fresh line when the
6
+ // file does not end in a newline, so a torn line can never glue onto the next row. One half without the other
7
+ // is worse than neither: skip-only loses the next good row into the torn line, fresh-line-only throws on read.
8
+ import {
9
+ appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync,
10
+ } from 'node:fs';
11
+ import { dirname } from 'node:path';
12
+
13
+ /** @typedef {{id?: string, kind: string, sig?: string, at?: number, [k: string]: unknown}} LedgerRow */
14
+
15
+ /**
16
+ * Every row that parses, oldest first, and how many lines did not. A missing file is no rows.
17
+ * @param {string} file
18
+ * @returns {{rows: LedgerRow[], skipped: number}}
19
+ */
20
+ export function readLedger(file) {
21
+ if (!existsSync(file)) return { rows: [], skipped: 0 };
22
+ const rows = [];
23
+ let skipped = 0;
24
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
25
+ if (!line) continue;
26
+ let row;
27
+ try { row = JSON.parse(line); } catch { skipped++; continue; }
28
+ if (!row || typeof row !== 'object' || Array.isArray(row)) { skipped++; continue; }
29
+ rows.push(row);
30
+ }
31
+ return { rows, skipped };
32
+ }
33
+
34
+ /** @param {string} file @returns {boolean} the file is non-empty and its last byte is not a newline (a torn tail). */
35
+ function endsTorn(file) {
36
+ if (!existsSync(file)) return false;
37
+ const fd = openSync(file, 'r');
38
+ try {
39
+ const size = fstatSync(fd).size;
40
+ if (size === 0) return false;
41
+ const b = Buffer.alloc(1);
42
+ readSync(fd, b, 0, 1, size - 1);
43
+ return b[0] !== 0x0a;
44
+ } finally { closeSync(fd); }
45
+ }
46
+
47
+ /**
48
+ * Append one row (dir 0700, file 0600). A torn tail gets a newline first. `once` refuses a row whose `id` is
49
+ * already on disk (write-once ids), by throwing.
50
+ * @param {string} file
51
+ * @param {LedgerRow} row
52
+ * @param {{once?: boolean}} [opts]
53
+ */
54
+ export function appendRow(file, row, opts = {}) {
55
+ if (opts.once) {
56
+ if (row.id === undefined) throw new Error('a write-once ledger row needs an id');
57
+ if (withoutReleased(readLedger(file).rows).some((r) => r.id === row.id)) throw new Error(`ledger row ${row.id} already written`);
58
+ }
59
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
60
+ const lead = endsTorn(file) ? '\n' : '';
61
+ appendFileSync(file, `${lead}${JSON.stringify(row)}\n`, { mode: 0o600 });
62
+ }
63
+
64
+ /**
65
+ * Walk the rows in order: a `released` row (`of` = an id) cancels the latest earlier live row with that id, and
66
+ * is itself dropped. A released `fire` or `ran` is not consumed, so its slot or file is retryable (Part 4) and
67
+ * the same id may be written again.
68
+ * @param {LedgerRow[]} rows
69
+ * @returns {LedgerRow[]}
70
+ */
71
+ export function withoutReleased(rows) {
72
+ /** @type {LedgerRow[]} */
73
+ const live = [];
74
+ for (const r of rows) {
75
+ if (r.kind !== 'released') { live.push(r); continue; }
76
+ for (let i = live.length - 1; i >= 0; i--) if (live[i].id === r.of) { live.splice(i, 1); break; }
77
+ }
78
+ return live;
79
+ }
package/src/u-readout.js CHANGED
@@ -202,7 +202,7 @@ export function reviewDoorPackage({ door, diff }) {
202
202
  const out = [];
203
203
  out.push(`REVIEW DOOR — this run's close minted ${String(d.outcome ?? 'a verdict')} and it STANDS. Nothing you do here changes it.`);
204
204
  if (held) {
205
- out.push(' held this green was rendered by the judged floor, so it has earned NO reuse and NO learning credit until you accept it');
205
+ out.push(' held this is a rubric (soft-green) job, so its green earns NO reuse and NO learning credit until you accept it, whether or not a judge ran');
206
206
  }
207
207
  // the body: what the machine judged, what moved, and the lines themselves —
208
208
  // rendered by the SAME code the pause screen uses, off the same record shape.
package/src/userrun.js CHANGED
@@ -42,7 +42,7 @@ import { makeSpine } from './spine.js';
42
42
  // signed into the job spec — a job is a repeatable SHAPE, the source/
43
43
  // destination a per-run value, exactly like `bareloop run <bundle> --repo
44
44
  // <path>` varies the repo without touching the bundle's signature).
45
- import { readSourceManifest, frontDoorFromManifest, proveDestination, copyOut } from './source.js';
45
+ import { readSourceManifest, frontDoorFromManifest, proveDestination, copyOut, refreezeUrlInputs } from './source.js';
46
46
  import { scanSecrets, redactSecrets } from './validate.js';
47
47
  import { runBehaviour, formatBehaviour } from './behaviour.js';
48
48
  // the three doors' SEMANTICS live in the library; this script surfaces them
@@ -54,7 +54,8 @@ import { resolveJudge } from './judged.js';
54
54
  // path uses (never a second one) and keeps its patient the way it left it.
55
55
  import { readResume, resumeTreeGate, checkpointAgeGate, writeRunGreenRow, CHECKPOINT_OUTCOMES, PAUSE_TTL_MS } from './reuse.js';
56
56
  // PRD item 28's provider factory (src/providers.js): the WORKER provider's
57
- // ctor/envKey/tier-table/param-gating come from here now — one seam instead
57
+ // ctor/param-gating come from here now (its key variable and price come from the
58
+ // Settings row, src/providerrows.js) — one seam instead
58
59
  // of an `if (provider === …)` scattered across this script. PRD item 32.2
59
60
  // moved the JUDGE onto this SAME factory: it used to be separately pinned
60
61
  // below to a hardcoded Anthropic constructor at the judged tier because the
@@ -235,6 +236,7 @@ class ExitSignal extends Error {
235
236
  * @property {any} [provider] the TEST SEAM: supplying this skips the real-key
236
237
  * check entirely, the same shape `src/cli.js`'s `main(argv, deps)` uses.
237
238
  * @property {(tier: string) => any} [providerFor]
239
+ * @property {(url: string) => Promise<{ok: true, text: string, url: string}|{ok: false, error: string}>} [fetchUrl] PART 1c - a test seam: how a web input is read at run start (production: barebrowse)
238
240
  * @property {any} [judgeProvider]
239
241
  * @property {string} [runlistHome] F196 — a test seam for `appendRun`'s
240
242
  * (src/runlist.js) `home` override, the same shape `deps.provider` already
@@ -506,10 +508,8 @@ async function execute(ctx) {
506
508
  // and tested apart from this script. The JUDGE's model is resolved separately
507
509
  // (`resolveJudge`, below the key block): it defaults to this same resolved
508
510
  // worker model and a signed `judge` override wins over it.
509
- // The provider's OWN tier table (src/providers.js) supplies the default —
510
- // not the anthropic-only DEFAULT_TIER_MODELS above (that map is the
511
- // `--model` FLAG's vocabulary only, sonnet/haiku, unrelated to which
512
- // provider the spec names). `spec.provider` is not validateJob'd until
511
+ // There is no default model: a spec or flag that names none dies below with
512
+ // REFUSE_NAME_MODEL (the model is one ticked in Settings). `spec.provider` is not validateJob'd until
513
513
  // runJob below, so an unrecognized value dies here with the same operator-
514
514
  // facing message resolveWorkerModel's own errors use, rather than an
515
515
  // unhandled throw.
@@ -1404,8 +1404,8 @@ async function execute(ctx) {
1404
1404
  // token against. That made "provider-agnostic" half-true: you could run on
1405
1405
  // another provider only if you also held an Anthropic account.
1406
1406
  //
1407
- // The WORKER's key comes from the provider's own `envKey` (the factory's, so
1408
- // there is one table saying which provider reads which variable) and is
1407
+ // The WORKER's key comes from its Settings row's own env name (`keyValueOf`, src/providerrows.js,
1408
+ // so there is one place saying which row reads which variable) and is
1409
1409
  // ALWAYS required — the worker always runs.
1410
1410
  //
1411
1411
  // The JUDGE's key is required only when the close actually judges
@@ -2592,13 +2592,24 @@ export async function main(argv, deps = {}) {
2592
2592
  + 'with exactly one source-*/ sibling.');
2593
2593
  }
2594
2594
  const into = candidates[0];
2595
- const manifestRead = await readSourceManifest(into);
2595
+ let manifestRead = await readSourceManifest(into);
2596
2596
  if (manifestRead.stop !== null) {
2597
2597
  die(`--spec ${specPath}: ${manifestRead.code} — ${manifestRead.stop}`);
2598
2598
  }
2599
2599
  if (!manifestRead.present || !manifestRead.manifest) {
2600
2600
  die(`--spec ${specPath}: ${into} carries no source.json — not a prepared copy this runner can trust`);
2601
2601
  }
2602
+ // PART 1c (RF22): a FRESH run of a rubric job with web inputs fetches each address again and makes that text the run's frozen
2603
+ // input BEFORE the worker starts (the drafting copy was only for the look). A resume / door / decision keeps the copy its
2604
+ // first start froze. A refusal is plain words, $0.
2605
+ if (argFlag('resume') === null && argFlag('door') === null && argFlag('decide') === null) {
2606
+ const fresh = await refreezeUrlInputs({ into, ...(deps.fetchUrl ? { fetchUrl: deps.fetchUrl } : {}) });
2607
+ if (!fresh.ok) die(`--spec ${specPath}: ${fresh.error}`);
2608
+ if (fresh.refreshed.length > 0) {
2609
+ manifestRead = await readSourceManifest(into);
2610
+ if (manifestRead.stop !== null || !manifestRead.manifest) die(`--spec ${specPath}: the run copy's record could not be read after the fresh fetch`);
2611
+ }
2612
+ }
2602
2613
  const seed = manifestRead.manifest.seed;
2603
2614
  if (typeof seed !== 'string' || !seed) {
2604
2615
  die(`--spec ${specPath}: ${into}/source.json carries no seed commit — not a prepared copy this runner can trust`);