bareloop 0.42.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.
- package/CHANGELOG.md +46 -0
- package/bareloop.context.md +53 -30
- package/package.json +1 -1
- package/src/authorjob.js +6 -0
- package/src/declaredclose.js +2 -2
- package/src/interviewrun.js +0 -1
- package/src/job.js +38 -11
- package/src/panel/authorroutes.js +107 -33
- package/src/panel/authorsession.js +59 -27
- package/src/panel/index.html +813 -484
- package/src/panel/runroutes.js +163 -5
- package/src/panel/scheduler.js +156 -0
- package/src/panel/schedules.js +667 -0
- package/src/panel/schedulestart.js +243 -0
- package/src/panel/server.js +451 -48
- package/src/planrun.js +39 -7
- package/src/providerrows.js +3 -3
- package/src/reviewdoor.js +37 -0
- package/src/runlist.js +35 -0
- package/src/schedulewords.js +296 -0
- package/src/trigger.js +48 -0
- package/src/triggerclock.js +127 -0
- package/src/triggerfiles.js +215 -0
- package/src/triggerledger.js +79 -0
- package/src/userrun.js +6 -7
- package/types/declaredclose.d.ts +1 -1
- package/types/job.d.ts +8 -1
- package/types/panel/authorroutes.d.ts +34 -1
- package/types/panel/authorsession.d.ts +37 -12
- package/types/panel/runroutes.d.ts +32 -1
- package/types/panel/scheduler.d.ts +17 -0
- package/types/panel/schedules.d.ts +412 -0
- package/types/panel/schedulestart.d.ts +106 -0
- package/types/panel/server.d.ts +143 -14
- package/types/planrun.d.ts +2 -0
- package/types/providerrows.d.ts +2 -2
- package/types/reviewdoor.d.ts +27 -0
- package/types/runlist.d.ts +20 -0
- package/types/schedulewords.d.ts +135 -0
- package/types/trigger.d.ts +37 -0
- package/types/triggerclock.d.ts +52 -0
- package/types/triggerfiles.d.ts +93 -0
- package/types/triggerledger.d.ts +35 -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/userrun.js
CHANGED
|
@@ -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/
|
|
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
|
|
@@ -507,10 +508,8 @@ async function execute(ctx) {
|
|
|
507
508
|
// and tested apart from this script. The JUDGE's model is resolved separately
|
|
508
509
|
// (`resolveJudge`, below the key block): it defaults to this same resolved
|
|
509
510
|
// worker model and a signed `judge` override wins over it.
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
// `--model` FLAG's vocabulary only, sonnet/haiku, unrelated to which
|
|
513
|
-
// 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
|
|
514
513
|
// runJob below, so an unrecognized value dies here with the same operator-
|
|
515
514
|
// facing message resolveWorkerModel's own errors use, rather than an
|
|
516
515
|
// unhandled throw.
|
|
@@ -1405,8 +1404,8 @@ async function execute(ctx) {
|
|
|
1405
1404
|
// token against. That made "provider-agnostic" half-true: you could run on
|
|
1406
1405
|
// another provider only if you also held an Anthropic account.
|
|
1407
1406
|
//
|
|
1408
|
-
// The WORKER's key comes from
|
|
1409
|
-
// there is one
|
|
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
|
|
1410
1409
|
// ALWAYS required — the worker always runs.
|
|
1411
1410
|
//
|
|
1412
1411
|
// The JUDGE's key is required only when the close actually judges
|
package/types/declaredclose.d.ts
CHANGED
|
@@ -187,7 +187,7 @@ export const CLOSE_DECL_FIELDS: readonly string[];
|
|
|
187
187
|
/** what a stored calibration set carries, ENUMERATED: the frozen cases and the judge
|
|
188
188
|
* tier that graded them. Both are inside the spec, so `jobSpecHash` covers both — which
|
|
189
189
|
* is the whole mechanism behind "a judge-model bump forces a full recalibration"
|
|
190
|
-
* (src/judged.js, `
|
|
190
|
+
* (src/judged.js, `resolveJudge`): the field moves, the hash moves, the signature dies,
|
|
191
191
|
* and the re-sign re-runs the gate. Anything else in there is a field nobody reads. */
|
|
192
192
|
export const CALIBRATION_FIELDS: readonly string[];
|
|
193
193
|
/**
|
package/types/job.d.ts
CHANGED
|
@@ -99,7 +99,8 @@ export function jobSpecHash(job: object): string;
|
|
|
99
99
|
* share a workflow key iff they ran the same goal, checks, guardrails and close — whatever source they ran on, wherever they wrote, whatever caps they had.
|
|
100
100
|
* It is NOT a signature: {@link jobSpecHash} (which covers the caps and the fence) stays what the person signs, and
|
|
101
101
|
* a reuse lands on a NEW jobSpecHash with the SAME workflowKey. Same never-throws contract as `jobSpecHash`, and
|
|
102
|
-
* the same resolved-`tools` form, so an omitted-`tools` spec and its spelled-out menu share a key.
|
|
102
|
+
* the same resolved-`tools` form, so an omitted-`tools` spec and its spelled-out menu share a key. A soft-green spec keys on its
|
|
103
|
+
* RESOLVED judge identity (an absent `judge` is its worker's), so pinning the default judge never changes the key.
|
|
103
104
|
* @param {object} job
|
|
104
105
|
* @returns {string} sha256 hex
|
|
105
106
|
*/
|
|
@@ -277,6 +278,12 @@ export const CONDITION_KEYS: readonly string[];
|
|
|
277
278
|
* reuse (so what the key ignores is exactly what the person may edit, never two spellings).
|
|
278
279
|
*/
|
|
279
280
|
export const REUSE_OPEN_SPEC_FIELDS: readonly string[];
|
|
281
|
+
/**
|
|
282
|
+
* The signed spec's schedule fields: {@link workflowKey} ignores them too, so a scheduled spec keeps the workflow
|
|
283
|
+
* key of the green run it was scheduled from (else Reuse would refuse every scheduled spec). They stay inside
|
|
284
|
+
* {@link jobSpecHash}. Kept apart from {@link REUSE_OPEN_SPEC_FIELDS}, which names the Reuse boxes.
|
|
285
|
+
*/
|
|
286
|
+
export const SCHEDULE_SPEC_FIELDS: readonly string[];
|
|
280
287
|
/**
|
|
281
288
|
* The worker-identity fields {@link workflowKey} ALSO ignores (hamr 2026-10-04: Model is open on a reuse; the Name
|
|
282
289
|
* chosen in Settings carries its provider and baseUrl with it). They stay inside {@link jobSpecHash}, so what the person
|
|
@@ -41,6 +41,9 @@ export function checkHumanGuard(req: import("node:http").IncomingMessage, { toke
|
|
|
41
41
|
* @param {{ port: number, token: string, env?: Record<string,string|undefined>,
|
|
42
42
|
* sessionsRoot?: string, spawnFn?: typeof realSpawn, bareloopBin?: string,
|
|
43
43
|
* jobsDir?: string, fetchImpl?: typeof fetch, home?: string,
|
|
44
|
+
* schedules?: ReturnType<typeof import('./schedules.js').createScheduleDoor>,
|
|
45
|
+
* sessionDeps?: Record<string, any>,
|
|
46
|
+
* runJobNames?: () => string[],
|
|
44
47
|
* startFrom?: {get: (runid: string, o?: {model?: string}) => any, getImport: (id: string, o?: {model?: string}) => any, listJobs?: (o?: {model?: string}) => any[]} }} opts
|
|
45
48
|
*/
|
|
46
49
|
export function createAuthorRoutes(opts: {
|
|
@@ -53,6 +56,9 @@ export function createAuthorRoutes(opts: {
|
|
|
53
56
|
jobsDir?: string;
|
|
54
57
|
fetchImpl?: typeof fetch;
|
|
55
58
|
home?: string;
|
|
59
|
+
schedules?: ReturnType<typeof import("./schedules.js").createScheduleDoor>;
|
|
60
|
+
sessionDeps?: Record<string, any>;
|
|
61
|
+
runJobNames?: () => string[];
|
|
56
62
|
startFrom?: {
|
|
57
63
|
get: (runid: string, o?: {
|
|
58
64
|
model?: string;
|
|
@@ -67,6 +73,26 @@ export function createAuthorRoutes(opts: {
|
|
|
67
73
|
}): {
|
|
68
74
|
handle: (req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse, pathname: string, body: any) => boolean;
|
|
69
75
|
sessions: Map<string, any>;
|
|
76
|
+
headless: Map<string, any>;
|
|
77
|
+
startSession: (card: any, reuse: {
|
|
78
|
+
spec: any;
|
|
79
|
+
workflowKey: string;
|
|
80
|
+
} | null, inputs: import("../jobblock.js").JobInput[], o?: {
|
|
81
|
+
headless?: boolean;
|
|
82
|
+
filesJob?: boolean;
|
|
83
|
+
}) => Promise<{
|
|
84
|
+
ok: true;
|
|
85
|
+
session: ReturnType<typeof createSession>;
|
|
86
|
+
} | {
|
|
87
|
+
ok: false;
|
|
88
|
+
status: number;
|
|
89
|
+
error: string;
|
|
90
|
+
}>;
|
|
91
|
+
envNow: () => Record<string, string | undefined>;
|
|
92
|
+
spawnFn: typeof realSpawn;
|
|
93
|
+
bareloopBin: string;
|
|
94
|
+
/** a headless session the starter has removed from disk leaves its map too @param {string} id */
|
|
95
|
+
forgetHeadless: (id: string) => void;
|
|
70
96
|
};
|
|
71
97
|
/**
|
|
72
98
|
* The panel's OWN 2-decimal money render for a chat line — hamr's ruling
|
|
@@ -89,7 +115,7 @@ export function panelMoney2(n: number): string;
|
|
|
89
115
|
* it to sign"), and this is where it is checked, not trusted.
|
|
90
116
|
* @param {ReturnType<typeof createSession>} session
|
|
91
117
|
* @param {string} claimedHash
|
|
92
|
-
* @param {{env: any, spawnFn: typeof realSpawn, bareloopBin: string, sessionsRoot?: string, home?: string}} o
|
|
118
|
+
* @param {{env: any, spawnFn: typeof realSpawn, bareloopBin: string, sessionsRoot?: string, home?: string, reviewDoor?: boolean, onSpawnError?: (e: Error) => void, onExit?: (code: number|null) => void}} o
|
|
93
119
|
* @returns {{ok: boolean, error?: string, job?: string}}
|
|
94
120
|
*/
|
|
95
121
|
export function signRun(session: ReturnType<typeof createSession>, claimedHash: string, o: {
|
|
@@ -98,10 +124,17 @@ export function signRun(session: ReturnType<typeof createSession>, claimedHash:
|
|
|
98
124
|
bareloopBin: string;
|
|
99
125
|
sessionsRoot?: string;
|
|
100
126
|
home?: string;
|
|
127
|
+
reviewDoor?: boolean;
|
|
128
|
+
onSpawnError?: (e: Error) => void;
|
|
129
|
+
onExit?: (code: number | null) => void;
|
|
101
130
|
}): {
|
|
102
131
|
ok: boolean;
|
|
103
132
|
error?: string;
|
|
104
133
|
job?: string;
|
|
105
134
|
};
|
|
135
|
+
/** plain words for a run whose saved job no longer passes today's `validateJob` (RULED 2026-10-15): never the raw red codes */
|
|
136
|
+
export const OLD_JOB_REUSE_WORDS: "This run was made by an older bareloop. Its saved job doesn't pass today's checks, so it can't be reused. Start a new job instead.";
|
|
137
|
+
export const OLD_JOB_RESUME_WORDS: "This run was made by an older bareloop. Its saved job doesn't pass today's checks, so it can't be resumed. Use Reuse workflow on a newer run, or start a new job.";
|
|
138
|
+
export const OLD_JOB_ACCEPT_WORDS: "This run was made by an older bareloop. Its saved job doesn't pass today's checks, so it can't be accepted. Use Reuse workflow on a newer run, or start a new job.";
|
|
106
139
|
import { spawn as realSpawn } from 'node:child_process';
|
|
107
140
|
import { createSession } from './authorsession.js';
|