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.
- package/CHANGELOG.md +109 -1
- package/README.md +13 -21
- package/bareloop.context.md +97 -34
- package/package.json +4 -3
- package/src/authorflow.js +337 -29
- package/src/authoring.js +92 -7
- package/src/authorjob.js +84 -16
- package/src/authorreadout.js +89 -5
- package/src/authorrun.js +14 -5
- package/src/authorscout.js +108 -0
- package/src/declaredclose.js +25 -14
- package/src/facts.js +317 -0
- package/src/factwords.js +129 -0
- package/src/inputlook.js +301 -0
- package/src/interviewrun.js +0 -1
- package/src/job.js +40 -12
- package/src/jobblock.js +7 -2
- package/src/kinds.js +84 -3
- package/src/labelscan.js +170 -0
- package/src/pagefetch.js +125 -0
- package/src/panel/authorroutes.js +121 -34
- package/src/panel/authorsession.js +154 -45
- package/src/panel/index.html +845 -490
- package/src/panel/jobplan.js +34 -11
- 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 +467 -48
- package/src/planrun.js +42 -9
- package/src/providerrows.js +3 -3
- package/src/reviewdoor.js +37 -0
- package/src/rubricstep.js +804 -0
- package/src/runlist.js +35 -0
- package/src/schedulewords.js +296 -0
- package/src/source.js +166 -1
- 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/u-readout.js +1 -1
- package/src/userrun.js +20 -9
- package/types/authorflow.d.ts +267 -13
- package/types/authoring.d.ts +29 -1
- package/types/authorjob.d.ts +7 -5
- package/types/authorreadout.d.ts +27 -2
- package/types/authorscout.d.ts +70 -0
- package/types/declaredclose.d.ts +5 -3
- package/types/facts.d.ts +71 -0
- package/types/factwords.d.ts +42 -0
- package/types/inputlook.d.ts +238 -0
- package/types/job.d.ts +8 -1
- package/types/jobblock.d.ts +8 -1
- package/types/labelscan.d.ts +38 -0
- package/types/pagefetch.d.ts +71 -0
- package/types/panel/authorroutes.d.ts +34 -1
- package/types/panel/authorsession.d.ts +55 -14
- package/types/panel/jobplan.d.ts +12 -4
- 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 +145 -14
- package/types/planrun.d.ts +2 -0
- package/types/providerrows.d.ts +2 -2
- package/types/reviewdoor.d.ts +27 -0
- package/types/rubricstep.d.ts +286 -0
- package/types/runlist.d.ts +20 -0
- package/types/schedulewords.d.ts +135 -0
- package/types/source.d.ts +94 -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
- package/types/userrun.d.ts +11 -0
package/src/runlist.js
CHANGED
|
@@ -128,6 +128,41 @@ export function appendRunEvent(event, opts = {}) {
|
|
|
128
128
|
try { chmodSync(path, 0o600); } catch { /* best-effort perms */ }
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* RULED 2026-10-15 ("Remove from Needs you"): the person took a stopped run's card out of the Needs-you list. ONE row
|
|
133
|
+
* `{runid, type:'needs-removed', at}` appended beside the run rows; it is not a run, a claim or an event ({@link readRunList}
|
|
134
|
+
* ignores it, so the monthly limit, the run counter and the scheduler never see it). Nothing is rewritten.
|
|
135
|
+
* @param {string} runid
|
|
136
|
+
* @param {{ home?: string, now?: () => number }} [opts]
|
|
137
|
+
* @returns {string} the `at` written
|
|
138
|
+
*/
|
|
139
|
+
export function appendNeedsRemoved(runid, opts = {}) {
|
|
140
|
+
const at = new Date((opts.now ?? Date.now)()).toISOString();
|
|
141
|
+
const path = runlistPath(opts.home);
|
|
142
|
+
mkdirSync(runlistHome(opts.home), { recursive: true, mode: 0o700 });
|
|
143
|
+
appendFileSync(path, `${JSON.stringify({ runid, type: 'needs-removed', at })}\n`);
|
|
144
|
+
try { chmodSync(path, 0o600); } catch { /* best-effort perms */ }
|
|
145
|
+
return at;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The newest `needs-removed` time (ms) per run id. An absent file is an empty map; a row with no readable time is not a removal.
|
|
150
|
+
* @param {{ home?: string }} [opts]
|
|
151
|
+
* @returns {Map<string, number>}
|
|
152
|
+
*/
|
|
153
|
+
export function readNeedsRemovals(opts = {}) {
|
|
154
|
+
/** @type {Map<string, number>} */
|
|
155
|
+
const out = new Map();
|
|
156
|
+
const path = runlistPath(opts.home);
|
|
157
|
+
if (!existsSync(path)) return out;
|
|
158
|
+
for (const r of parseJsonl(path).records) {
|
|
159
|
+
if (!r || r.type !== 'needs-removed' || typeof r.runid !== 'string') continue;
|
|
160
|
+
const t = typeof r.at === 'string' ? Date.parse(r.at) : NaN;
|
|
161
|
+
if (Number.isFinite(t) && t >= (out.get(r.runid) ?? -Infinity)) out.set(r.runid, t);
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
131
166
|
/**
|
|
132
167
|
* @typedef {{ type: 'leg-start', runid: string, leg: number, pid?: number, capUsd?: number, at: string }} LegStart
|
|
133
168
|
* A resumed run's NEW claim (P5-R, hamr 2026-10-02: one run, one id, one row): appended beside the run's row
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// T Part 3 (PANEL-BUILD.md T addendum, C.3 / C.6): the WORDS of a schedule. ONE owner of these strings. Pure
|
|
2
|
+
// functions: typed facts in, a fixed sentence out. No model text ever reaches a sentence (the only slot-fills are
|
|
3
|
+
// numbers, a file or folder name and the person's own path). `home` is injected (never a default inside a test).
|
|
4
|
+
// Rewritten from fwdloop src/summary-words.js (`scheduledWords`, `slotWords`, `nextTimeWords`, `nextInWords`,
|
|
5
|
+
// `scheduledNextFor`, `catchUpWords`), minus `endedWords` and every ask (bareloop has neither:
|
|
6
|
+
// its ENDED block is `endedFor` in src/panel/server.js).
|
|
7
|
+
import { basename } from 'node:path';
|
|
8
|
+
import { panelMoney2 } from './panel/authorroutes.js';
|
|
9
|
+
|
|
10
|
+
/** @typedef {import('./trigger.js').Trigger} Trigger */
|
|
11
|
+
|
|
12
|
+
const DAY_PLURAL = { mon: 'Mondays', tue: 'Tuesdays', wed: 'Wednesdays', thu: 'Thursdays', fri: 'Fridays', sat: 'Saturdays', sun: 'Sundays' };
|
|
13
|
+
const DAY_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
14
|
+
const MONTH_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
15
|
+
|
|
16
|
+
/** @param {number} n */
|
|
17
|
+
const two = (n) => String(n).padStart(2, '0');
|
|
18
|
+
/** @param {{hour: number, minute: number}} s */
|
|
19
|
+
const hhmm = (s) => `${two(s.hour)}:${two(s.minute)}`;
|
|
20
|
+
|
|
21
|
+
/** A path with the home folder shown as `~`. @param {string} p @param {string|undefined} home */
|
|
22
|
+
function tilde(p, home) {
|
|
23
|
+
if (home && (p === home || p.startsWith(`${home}/`))) return `~${p.slice(home.length)}`;
|
|
24
|
+
return p;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Every schedule button/word the page shows lives here, so a reword is ONE edit (hamr will reword on his walk).
|
|
29
|
+
* The server sends this table with the run detail and the schedule card; the page reads every label from it.
|
|
30
|
+
*/
|
|
31
|
+
export const SCHEDULE_WORDS = Object.freeze({
|
|
32
|
+
byHand: 'By hand',
|
|
33
|
+
cancelSchedule: 'Cancel schedule',
|
|
34
|
+
cancelWords: 'Cancel schedule',
|
|
35
|
+
// the Run tab's buttons (never relabelled in place: a different state is a different button)
|
|
36
|
+
scheduleRun: 'Schedule run',
|
|
37
|
+
cancelModify: 'Cancel / modify schedule',
|
|
38
|
+
// the one main button of the schedule card
|
|
39
|
+
signSchedule: 'Sign schedule',
|
|
40
|
+
nothingChanged: 'Nothing changed yet. This card only sets or adjusts the schedule; it never runs the job.',
|
|
41
|
+
confirmPrefix: 'Confirm: ',
|
|
42
|
+
// the When field and the blocks
|
|
43
|
+
whenLabel: 'When',
|
|
44
|
+
nowPrefix: 'Now: ',
|
|
45
|
+
scheduledLabel: 'SCHEDULE',
|
|
46
|
+
nextLabel: 'NEXT',
|
|
47
|
+
options: Object.freeze({
|
|
48
|
+
hand: 'By hand',
|
|
49
|
+
daily: 'Every day at HH:MM',
|
|
50
|
+
weekdays: 'Weekdays at HH:MM',
|
|
51
|
+
weekly: 'Every <day> at HH:MM',
|
|
52
|
+
'file-updated': 'When <file> is updated',
|
|
53
|
+
'new-file': 'When a new file arrives in <folder>',
|
|
54
|
+
}),
|
|
55
|
+
// RULED 2026-10-15: the Needs-you pill of a schedule that gave up (hamr may reword)
|
|
56
|
+
gaveUpPill: 'Check',
|
|
57
|
+
// RULED 2026-10-15 (aligned with fwdloop M9 am7): the SCHEDULE row's second line when the last fire started nothing
|
|
58
|
+
problemNext: 'next: fix it in Cancel / modify schedule',
|
|
59
|
+
monthNext: 'next: raise the limit in Settings, or it starts on the 1st',
|
|
60
|
+
acceptWaiting: 'waiting for your Accept (the next run does not wait)',
|
|
61
|
+
scheduleGroup: 'schedule',
|
|
62
|
+
notGreen: 'This job has not had a good run yet. Run it by hand and get it right first; then you can set when it runs. A rubric job also needs your Accept once. Nothing was changed.',
|
|
63
|
+
stale: 'What you were shown is not what is on the card now. Nothing was signed. Look again, then sign.',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What starts the job, in words. `null` (by hand) has no sentence.
|
|
68
|
+
* @param {Trigger|null|undefined} t @param {string} [home]
|
|
69
|
+
* @returns {string|null}
|
|
70
|
+
*/
|
|
71
|
+
export function scheduledWords(t, home) {
|
|
72
|
+
if (!t) return null;
|
|
73
|
+
if (t.kind === 'daily') return `Every day at ${hhmm(t)}`;
|
|
74
|
+
if (t.kind === 'weekdays') return `Weekdays at ${hhmm(t)}`;
|
|
75
|
+
if (t.kind === 'weekly') return `${DAY_PLURAL[/** @type {keyof typeof DAY_PLURAL} */ (t.day)] ?? t.day} at ${hhmm(t)}`;
|
|
76
|
+
if (t.kind === 'file-updated') return `When ${basename(t.path)} is updated`;
|
|
77
|
+
const folder = tilde(/** @type {{folder: string}} */ (t).folder, home);
|
|
78
|
+
return `When a new file arrives in ${folder.endsWith('/') ? folder : `${folder}/`}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** "Mon 13 Oct, 09:00" — local time, fixed English names (never the machine's locale). @param {number} ms */
|
|
82
|
+
export function slotWords(ms) {
|
|
83
|
+
const d = new Date(ms);
|
|
84
|
+
return `${DAY_SHORT[d.getDay()]} ${d.getDate()} ${MONTH_SHORT[d.getMonth()]}, ${two(d.getHours())}:${two(d.getMinutes())}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The Scheduled card's first line: "today 17:00", "tomorrow 09:00", else the date the NEXT line already says
|
|
89
|
+
* ("Tue 20 Oct, 09:05"). Local time, by calendar day. @param {number} ms the next run @param {number} now
|
|
90
|
+
*/
|
|
91
|
+
export function nextTimeWords(ms, now) {
|
|
92
|
+
const d = new Date(ms);
|
|
93
|
+
const day = (/** @type {Date} */ x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
|
94
|
+
const apart = Math.round((day(d) - day(new Date(now))) / 86_400_000);
|
|
95
|
+
if (apart === 0) return `today ${two(d.getHours())}:${two(d.getMinutes())}`;
|
|
96
|
+
if (apart === 1) return `tomorrow ${two(d.getHours())}:${two(d.getMinutes())}`;
|
|
97
|
+
return slotWords(ms);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** "next in 20m" / "next in 3h" / "next in 3d" (whole units, rounded down, never "0m"). @param {number} ms @param {number} now */
|
|
101
|
+
export function nextInWords(ms, now) {
|
|
102
|
+
const mins = Math.max(1, Math.floor((ms - now) / 60_000));
|
|
103
|
+
if (mins < 60) return `next in ${mins}m`;
|
|
104
|
+
const hours = Math.floor(mins / 60);
|
|
105
|
+
return hours < 48 ? `next in ${hours}h` : `next in ${Math.floor(hours / 24)}d`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* NEXT of a scheduled job: one sentence, first match wins.
|
|
110
|
+
* 1. a run of this job is waiting for the person's Accept (the next scheduled run does NOT wait for it, T7);
|
|
111
|
+
* 2. the latest outcome this month is `monthly-exhausted` (amounts from the row), RED;
|
|
112
|
+
* 3. a clock kind with a next slot; 4. a file kind that is armed; 5. not armed yet.
|
|
113
|
+
* A last outcome of `refused` goes in front of 3-5, so a trigger that cannot start is never silent.
|
|
114
|
+
* `red` is the typed flag for "the person must act": the page paints it and never guesses from the text.
|
|
115
|
+
* @param {{trigger: Trigger, armed: boolean, nextSlotMs: number|null, lastOutcome: any, waitingRunNo: number|null, now: number, retryInMs?: number|null, gaveUp?: any}} f
|
|
116
|
+
* `lastOutcome` = the newest outcome row of the CURRENT signing (`kind`, `at` in ms, `why`, `leftUsd`, `needUsd`, `atLeast`)
|
|
117
|
+
* @returns {{text: string, red: boolean}}
|
|
118
|
+
*/
|
|
119
|
+
export function scheduledNextFor(f) {
|
|
120
|
+
const { trigger, armed, nextSlotMs, lastOutcome, waitingRunNo, now, retryInMs = null, gaveUp = null } = f;
|
|
121
|
+
/** @param {string} text @param {boolean} [red] */
|
|
122
|
+
const say = (text, red = false) => ({ text, red });
|
|
123
|
+
if (waitingRunNo !== null) return say(`Run ${waitingRunNo} is waiting for your Accept. The next scheduled run does not wait for it.`);
|
|
124
|
+
if (gaveUp && typeof gaveUp === 'object') return say(gaveUpNextWords(gaveUp.why), true);
|
|
125
|
+
const last = lastOutcome && typeof lastOutcome === 'object' ? lastOutcome : null;
|
|
126
|
+
const sameMonth = last !== null && sameMonthAs(last.at, now);
|
|
127
|
+
if (last !== null && last.kind === 'monthly-exhausted' && sameMonth && typeof last.leftUsd === 'number' && typeof last.needUsd === 'number') {
|
|
128
|
+
const left = last.atLeast === true ? `at most ${panelMoney2(last.leftUsd)} left` : `${panelMoney2(last.leftUsd)} left`;
|
|
129
|
+
return say(`Paused: not enough money left this month (${left}, a run needs up to ${panelMoney2(last.needUsd)}). Raise the monthly limit in Settings, or it starts again on the 1st.`, true);
|
|
130
|
+
}
|
|
131
|
+
const lead = last !== null && last.kind === 'refused' && typeof last.why === 'string' && last.why !== '' ? `The last try did not start: ${last.why} ${retryWords(retryInMs)}` : '';
|
|
132
|
+
const red = lead !== '';
|
|
133
|
+
if (!armed) return say(`${lead}Not armed yet: the panel arms it on its next check.`, red);
|
|
134
|
+
if (trigger.kind === 'file-updated') return say(`${lead}Watching for it. A run starts when its contents change.`, red);
|
|
135
|
+
if (trigger.kind === 'new-file') return say(`${lead}Watching for it. A run starts for each new file.`, red);
|
|
136
|
+
if (nextSlotMs === null) return say(`${lead}Not armed yet: the panel arms it on its next check.`, red);
|
|
137
|
+
return say(`${lead}Next run ${slotWords(nextSlotMs)}.`, red);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Is `atMs` in the same local calendar month as `nowMs`? A non-number is never. @param {unknown} atMs @param {number} nowMs */
|
|
141
|
+
export function sameMonthAs(atMs, nowMs) {
|
|
142
|
+
if (typeof atMs !== 'number' || !Number.isFinite(atMs)) return false;
|
|
143
|
+
const a = new Date(atMs); const n = new Date(nowMs);
|
|
144
|
+
return a.getFullYear() === n.getFullYear() && a.getMonth() === n.getMonth();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** RULED 2026-10-15: the red NEXT of a schedule that gave up. @param {unknown} why the refusal's own words */
|
|
148
|
+
export function gaveUpNextWords(why) {
|
|
149
|
+
const w = typeof why === 'string' ? why.trim().replace(/\.$/, '') : '';
|
|
150
|
+
return `Stopped trying after 60 minutes: ${w}. Fix it, then Cancel / modify schedule to start again.`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The give-up date on the Needs-you card, local `MM/DD HH:MM`. @param {number} ms */
|
|
154
|
+
export function gaveUpDateWords(ms) {
|
|
155
|
+
const d = new Date(ms);
|
|
156
|
+
return `${two(d.getMonth() + 1)}/${two(d.getDate())} ${two(d.getHours())}:${two(d.getMinutes())}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** RULED 2026-10-15: after an install-needed refusal, the wait that is left, in whole minutes rounded up; '' when no wait. @param {number|null|undefined} ms */
|
|
160
|
+
export function retryWords(ms) {
|
|
161
|
+
if (typeof ms !== 'number' || !(ms > 0)) return '';
|
|
162
|
+
const m = Math.ceil(ms / 60_000);
|
|
163
|
+
return `Trying again in ${m} minute${m === 1 ? '' : 's'}. `;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The fixed catch-up sentence from a run's `trigger.json`. @param {any} rec */
|
|
167
|
+
export function catchUpWords(rec) {
|
|
168
|
+
return rec && typeof rec === 'object' && rec.catchUp === true ? 'This run was a catch-up: it started more than 2 minutes after its set time (the panel was off).' : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A spawn error AFTER the consuming row: `tries` releases so far, `max` the bound. Under the bound it will be tried again;
|
|
173
|
+
* at the bound it stays consumed and says so. @param {string} why @param {number} tries @param {number} max
|
|
174
|
+
*/
|
|
175
|
+
export function lateRefusalWords(why, tries, max) {
|
|
176
|
+
return tries < max ? `${why} It will be tried again.` : `${why} It was tried ${max} times and is given up on.`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------------------------------------------------------
|
|
180
|
+
// RULED 2026-10-15 (aligned with fwdloop M9 am7): the schedule on the Run, Job and Audit tabs. ONE owner of these sentences.
|
|
181
|
+
|
|
182
|
+
/** "10/15 13:40" — local time. @param {number} ms */
|
|
183
|
+
export const dateTimeWords = (ms) => gaveUpDateWords(ms);
|
|
184
|
+
|
|
185
|
+
/** @param {string} s */
|
|
186
|
+
export const lowerFirst = (s) => (s === '' ? s : s[0].toLowerCase() + s.slice(1));
|
|
187
|
+
|
|
188
|
+
/** "Thu 10/16 08:15" — local time, fixed English day names. @param {number} ms */
|
|
189
|
+
export function nextSlotShort(ms) {
|
|
190
|
+
const d = new Date(ms);
|
|
191
|
+
return `${DAY_SHORT[d.getDay()]} ${two(d.getMonth() + 1)}/${two(d.getDate())} ${two(d.getHours())}:${two(d.getMinutes())}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** @param {any} rec a run's `trigger.json` @returns {boolean} */
|
|
195
|
+
function isNewFile(rec) {
|
|
196
|
+
if (rec.triggerKind) return rec.triggerKind === 'new-file';
|
|
197
|
+
return typeof rec.trigger === 'string' && rec.trigger.startsWith('When a new file arrives');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Job tab, "Started": how the run began, then its local start time. `rec` = the run's `trigger.json` (null on a hand run).
|
|
202
|
+
* @param {any} rec @param {number|null} atMs the run's start
|
|
203
|
+
*/
|
|
204
|
+
export function startedWords(rec, atMs) {
|
|
205
|
+
const when = atMs === null ? '' : ` · ${dateTimeWords(atMs)}`;
|
|
206
|
+
if (!rec || typeof rec !== 'object') return `${SCHEDULE_WORDS.byHand}${when}`;
|
|
207
|
+
const trig = typeof rec.trigger === 'string' ? rec.trigger : '';
|
|
208
|
+
let how;
|
|
209
|
+
if (rec.kind === 'file') {
|
|
210
|
+
const name = basename(String(rec.file ?? ''));
|
|
211
|
+
how = isNewFile(rec) ? `By a new file: ${name}` : `By a file update: ${name}`;
|
|
212
|
+
} else {
|
|
213
|
+
how = `By the schedule: ${lowerFirst(trig)}`;
|
|
214
|
+
if (rec.catchUp === true) {
|
|
215
|
+
const min = typeof rec.lateMs === 'number' && rec.lateMs > 0 ? Math.round(rec.lateMs / 60_000) : null;
|
|
216
|
+
how += min === null ? ' · catch-up' : ` · catch-up, ${min} min late`;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return `${how}${when}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Audit, first row of a run a schedule started. @param {any} rec `trigger.json` @param {number|null} originNo the origin run's number
|
|
224
|
+
*/
|
|
225
|
+
export function startedAuditWords(rec, originNo) {
|
|
226
|
+
const from = originNo === null ? '' : ` · from run-${originNo}`;
|
|
227
|
+
if (rec.kind === 'file') return `started by the schedule · ${isNewFile(rec) ? 'new file' : 'file update'} ${basename(String(rec.file ?? ''))}${from}`;
|
|
228
|
+
const slot = typeof rec.slot === 'number' ? ` · slot ${two(new Date(rec.slot).getHours())}:${two(new Date(rec.slot).getMinutes())}` : '';
|
|
229
|
+
return `started by the schedule · ${lowerFirst(String(rec.trigger ?? ''))}${slot}${from}${rec.catchUp === true ? ' · catch-up' : ''}`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** a refusal's own words without its trailing "Nothing started." @param {unknown} why */
|
|
233
|
+
function reasonOf(why) {
|
|
234
|
+
return String(why ?? '').replace(/\s*Nothing (started|spent)\.\s*$/, '').trim().replace(/\.$/, '');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* A fire that started nothing, in plain words. `short` = the SCHEDULE row's `last` reason; else the Audit group's line.
|
|
239
|
+
* @param {any} row a ledger row (`busy` | `monthly-exhausted` | `refused` | `gave-up`) @param {boolean} [short]
|
|
240
|
+
*/
|
|
241
|
+
export function skippedWords(row, short = false) {
|
|
242
|
+
if (row.kind === 'busy') return short ? 'busy: a run of this job was still working' : 'busy: a run of this job was still working. Nothing started.';
|
|
243
|
+
if (row.kind === 'monthly-exhausted') {
|
|
244
|
+
const left = typeof row.leftUsd === 'number' ? `${row.atLeast === true ? 'at most ' : ''}${panelMoney2(row.leftUsd)}` : 'little';
|
|
245
|
+
const need = typeof row.needUsd === 'number' ? panelMoney2(row.needUsd) : 'more';
|
|
246
|
+
return short ? `month used up: ${left} left, needs ${need}` : `month used up: ${left} left, needs ${need}. Nothing spent.`;
|
|
247
|
+
}
|
|
248
|
+
const r = lowerFirst(reasonOf(row.why));
|
|
249
|
+
return short ? `refused: ${r}` : `refused: ${r}. Nothing started.`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Job tab, "Schedule": the job's schedule NOW and what it is bound to. The first part IS the Run tab's line ({@link scheduledWords}),
|
|
254
|
+
* first letter lowered, so the two can never disagree.
|
|
255
|
+
* @param {{trigger: Trigger|null, originNo: number|null, model: string|null, destination: string|null, home?: string}} f
|
|
256
|
+
*/
|
|
257
|
+
export function jobScheduleWords(f) {
|
|
258
|
+
const w = f.trigger ? scheduledWords(f.trigger, f.home) : null;
|
|
259
|
+
if (w === null) return 'none';
|
|
260
|
+
const parts = [lowerFirst(w)];
|
|
261
|
+
if (f.originNo !== null) parts.push(`from run-${f.originNo}`);
|
|
262
|
+
if (f.model) parts.push(f.model);
|
|
263
|
+
if (f.destination) parts.push(tilde(f.destination, f.home));
|
|
264
|
+
return parts.join(' · ');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** RULED 2026-10-15: the SCHEDULE row's `next` while an install-needed refusal is waiting to be tried again. @param {number} ms */
|
|
268
|
+
export function retryNextWords(ms) {
|
|
269
|
+
const m = Math.max(1, Math.ceil(ms / 60_000));
|
|
270
|
+
return `next: tries again in ${m} min`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* RULED 2026-10-15: the sign on the head of the Audit's `schedule` section. ONE small rule (hamr may still adjust it): [▶] only while a
|
|
275
|
+
* run of the job is running (as everywhere in the panel), [✗] when the SCHEDULE row is red, else [·] (on, waiting).
|
|
276
|
+
* @param {{running: boolean, red: boolean}} f @returns {{sign: string}}
|
|
277
|
+
*/
|
|
278
|
+
export function scheduleSignFor(f) {
|
|
279
|
+
return { sign: f.running ? '▶' : f.red ? '✗' : '·' };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Audit schedule section, the `this run` words of a run the schedule started (the head already says which schedule and from where). @param {any} rec `trigger.json` */
|
|
283
|
+
export function sectionThisRunWords(rec) {
|
|
284
|
+
if (rec.kind === 'file') return `started by the schedule · ${isNewFile(rec) ? 'new file' : 'file update'} ${basename(String(rec.file ?? ''))}`;
|
|
285
|
+
let late = '';
|
|
286
|
+
if (rec.catchUp === true) {
|
|
287
|
+
const min = typeof rec.lateMs === 'number' && rec.lateMs > 0 ? Math.round(rec.lateMs / 60_000) : null;
|
|
288
|
+
late = min === null ? ' · catch-up' : ` · catch-up, ${min} min late`;
|
|
289
|
+
}
|
|
290
|
+
return `started by the schedule · ${lowerFirst(String(rec.trigger ?? ''))}${late}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Audit schedule section, folded tail: `1 try started nothing` / `N tries started nothing`; '' for none. @param {number} n */
|
|
294
|
+
export function triesTailWords(n) {
|
|
295
|
+
return n > 0 ? `${n} ${n === 1 ? 'try' : 'tries'} started nothing` : '';
|
|
296
|
+
}
|
package/src/source.js
CHANGED
|
@@ -23,14 +23,16 @@
|
|
|
23
23
|
// on rather than catch.
|
|
24
24
|
|
|
25
25
|
import { createHash } from 'node:crypto';
|
|
26
|
+
import { tmpdir } from 'node:os';
|
|
26
27
|
import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
27
|
-
import { mkdir, readdir, readFile, writeFile, lstat, stat, chmod, access, copyFile, cp, open, rm, realpath, readlink, symlink, constants as fsConstants } from 'node:fs/promises';
|
|
28
|
+
import { mkdir, mkdtemp, readdir, readFile, writeFile, lstat, stat, chmod, access, copyFile, cp, open, rm, realpath, readlink, symlink, constants as fsConstants } from 'node:fs/promises';
|
|
28
29
|
import { dirname, join, relative, resolve, sep, basename, isAbsolute } from 'node:path';
|
|
29
30
|
import { git } from './kinds.js';
|
|
30
31
|
import { addWorktree, hideBareloopDir } from './worktree.js';
|
|
31
32
|
import { MAX_BUFFER } from './kinds.js';
|
|
32
33
|
import { PROVIDER_TIMEOUT_MS } from './clock.js';
|
|
33
34
|
import { scanSecrets, SECRET_PATTERNS, SECRET_PATTERN_NAMES } from './validate.js';
|
|
35
|
+
import { fetchPageText, isWebUrl } from './pagefetch.js';
|
|
34
36
|
|
|
35
37
|
/** @typedef {{stop: string, code: string}} SourceRefusal a named, non-throwing stop */
|
|
36
38
|
|
|
@@ -1223,3 +1225,166 @@ export async function proveInputFiles(inputs) {
|
|
|
1223
1225
|
}
|
|
1224
1226
|
return { ok: true, repo, root };
|
|
1225
1227
|
}
|
|
1228
|
+
|
|
1229
|
+
/** the per-file ceiling for a rubric input: bareguard's `cited` check searches a quote in a source only up to this size
|
|
1230
|
+
* (its `quoteSourceMaxBytes`); a bigger file could never be cited from, so the door refuses it at $0 */
|
|
1231
|
+
export const RUBRIC_INPUT_MAX_BYTES = 5 * 1024 * 1024;
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* Does this source manifest belong to a rubric job on local files (`proveRubricInputs` wrote it)? One spelling for both
|
|
1235
|
+
* doors (the panel session and the CLI author run): a non-repo source that froze numbered, labelled input files.
|
|
1236
|
+
* @param {Record<string, any>|null|undefined} manifest
|
|
1237
|
+
* @returns {boolean}
|
|
1238
|
+
*/
|
|
1239
|
+
export function isRubricFilesManifest(manifest) {
|
|
1240
|
+
return !!manifest && manifest.kind !== 'repo' && Array.isArray(manifest.inputs) && manifest.inputs.length > 0;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* PART 1a (docs/product/ITEM33-BUILD.md, "Addendum 2026-10-12"): the front door for a RUBRIC job whose inputs are the
|
|
1245
|
+
* person's own LOCAL FILES, not a repo. Each numbered `label: /absolute/file` line is proven at $0 and frozen into the
|
|
1246
|
+
* same hidden-git tree a plain folder gets (`prepareSource`, which also refuses a secret shape, an environment file
|
|
1247
|
+
* and a symlink), under `input/`; the manifest then also records `inputs: [{label, rel, sha256}]` — the bytes the
|
|
1248
|
+
* rubric's `cited` check will search a quote in, hashed once, here. A line that is a web address is fetched ONCE with
|
|
1249
|
+
* barebrowse (PART 1c, read only) and its text frozen under `input/` like a file (the manifest row also records its `url`,
|
|
1250
|
+
* so run start can fetch it again); a `search:` line is a named refusal (a later build); so is a relative path, a missing file, a folder, a file over
|
|
1251
|
+
* {@link RUBRIC_INPUT_MAX_BYTES}, and text that is not valid UTF-8. Every refusal is plain words naming the line and
|
|
1252
|
+
* ends "Nothing spent." — nothing here touches a model.
|
|
1253
|
+
* @param {{inputs: {n: number, label: string, value: string}[], into: string, destination?: string, fetchUrl?: (url: string) => Promise<{ok: true, text: string, url: string}|{ok: false, error: string}>}} o `fetchUrl` - how a web address is read (default barebrowse via `fetchPageText`; a test injects a stub, never the network)
|
|
1254
|
+
* @returns {Promise<{ok: true, prep: {stop: null, into: string, tree: string, manifestPath: string, manifest: Record<string, any>}}|{ok: false, error: string, code: string}>}
|
|
1255
|
+
*/
|
|
1256
|
+
export async function proveRubricInputs({ inputs, into, destination, fetchUrl = (u) => fetchPageText(u) }) {
|
|
1257
|
+
/** @param {string} code @param {number} n @param {string} msg @returns {{ok: false, error: string, code: string}} */
|
|
1258
|
+
const bad = (code, n, msg) => ({ ok: false, code, error: `line ${n}: ${msg} Nothing spent.` });
|
|
1259
|
+
if (!Array.isArray(inputs) || inputs.length === 0) return { ok: false, code: 'inputs-missing', error: 'Inputs is required. Nothing spent.' };
|
|
1260
|
+
/** @type {Set<string>} */
|
|
1261
|
+
const labels = new Set();
|
|
1262
|
+
/** @type {{label: string, name: string, buf: Buffer, url?: string}[]} */
|
|
1263
|
+
const staged = [];
|
|
1264
|
+
/** @type {Set<string>} */
|
|
1265
|
+
const names = new Set();
|
|
1266
|
+
const utf8 = new TextDecoder('utf-8', { fatal: true });
|
|
1267
|
+
for (const inp of inputs) {
|
|
1268
|
+
const { n, label, value } = inp;
|
|
1269
|
+
if (/^search$/i.test(label) || (/^[a-z][a-z0-9+.-]*:\/\//i.test(value) && !isWebUrl(value))) {
|
|
1270
|
+
return bad('web-input', n, 'a search, or an address that is not http or https, is not an input yet.');
|
|
1271
|
+
}
|
|
1272
|
+
if (labels.has(label.toLowerCase())) return bad('label-repeat', n, `the label "${label}" is already used by an earlier line.`);
|
|
1273
|
+
labels.add(label.toLowerCase());
|
|
1274
|
+
if (isWebUrl(value)) {
|
|
1275
|
+
const got = await fetchUrl(value.trim());
|
|
1276
|
+
if (!got.ok) return bad('input-url-unreadable', n, `${got.error.replace(/[.\s]+$/, '')}.`);
|
|
1277
|
+
const pageBuf = Buffer.from(got.text, 'utf8');
|
|
1278
|
+
const leak = secretPatternNames(got.text);
|
|
1279
|
+
if (leak.length) return bad('input-url-secret', n, `the page at ${got.url} carries a known secret shape (${leak.join(', ')}) - refused, secrets never enter a job.`);
|
|
1280
|
+
const stem = label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'page';
|
|
1281
|
+
const pageName = names.has(`${stem}.txt`) ? `${n}-${stem}.txt` : `${stem}.txt`;
|
|
1282
|
+
names.add(pageName);
|
|
1283
|
+
staged.push({ label, name: pageName, buf: pageBuf, url: got.url });
|
|
1284
|
+
continue;
|
|
1285
|
+
}
|
|
1286
|
+
if (!isAbsolute(value)) return bad('input-not-absolute', n, `${value} must be the full path of a file, like /home/me/notes.md.`);
|
|
1287
|
+
const abs = resolve(value);
|
|
1288
|
+
let ls;
|
|
1289
|
+
try { ls = await lstat(abs); } catch { return bad('input-missing', n, `${abs} does not exist.`); }
|
|
1290
|
+
if (ls.isSymbolicLink()) return bad('input-symlink', n, `${abs} is a symlink — refused, never followed.`);
|
|
1291
|
+
if (!ls.isFile()) return bad('input-not-file', n, `${abs} is not a file — each input line is one file.`);
|
|
1292
|
+
const base = basename(abs);
|
|
1293
|
+
if (ENV_FILE.test(base)) return bad('input-env-file', n, `${abs} is an environment file — refused by name, whatever it contains.`);
|
|
1294
|
+
if (ls.size > RUBRIC_INPUT_MAX_BYTES) return bad('input-oversize', n, `${abs} is over the ${RUBRIC_INPUT_MAX_BYTES / (1024 * 1024)} MiB limit for a quoted input.`);
|
|
1295
|
+
const buf = await readFile(abs);
|
|
1296
|
+
try { utf8.decode(buf); } catch { return bad('input-not-utf8', n, `${abs} is not UTF-8 text — text files only for now.`); }
|
|
1297
|
+
if (hasNulByte(buf)) return bad('input-not-text', n, `${abs} is not a text file — text files only for now.`);
|
|
1298
|
+
// two files with one name keep the line number in front, so neither overwrites the other under input/
|
|
1299
|
+
const name = names.has(base) ? `${n}-${base}` : base;
|
|
1300
|
+
names.add(name);
|
|
1301
|
+
staged.push({ label, name, buf });
|
|
1302
|
+
}
|
|
1303
|
+
const stage = await mkdtemp(join(tmpdir(), 'bareloop-rubric-inputs-'));
|
|
1304
|
+
try {
|
|
1305
|
+
for (const f of staged) await writeFile(join(stage, f.name), f.buf);
|
|
1306
|
+
const prep = await prepareSource({ source: stage, into, ...(destination === undefined ? {} : { destination }) });
|
|
1307
|
+
if (prep.stop !== null) return { ok: false, code: prep.code, error: prep.stop };
|
|
1308
|
+
// `source` names the person's own first file, never the scratch staging folder that was already removed
|
|
1309
|
+
const manifest = {
|
|
1310
|
+
...prep.manifest,
|
|
1311
|
+
source: isWebUrl(inputs[0].value) ? inputs[0].value.trim() : resolve(inputs[0].value),
|
|
1312
|
+
inputs: staged.map((f) => ({ label: f.label, rel: `input/${f.name}`, sha256: sha256Hex(f.buf), ...(f.url === undefined ? {} : { url: f.url }) })),
|
|
1313
|
+
};
|
|
1314
|
+
await writeFile(prep.manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
1315
|
+
return { ok: true, prep: { ...prep, manifest } };
|
|
1316
|
+
} finally {
|
|
1317
|
+
await rm(stage, { recursive: true, force: true });
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* Is this Inputs value a path to an existing regular FILE on this machine (not a repo folder, not a web address, not a
|
|
1323
|
+
* missing path)? The $0 peek that tells a rubric job on local files from a rubric job on a repo before a session exists;
|
|
1324
|
+
* `proveRubricInputs` makes the authoritative, line-by-line proof.
|
|
1325
|
+
* @param {string} value
|
|
1326
|
+
* @returns {boolean}
|
|
1327
|
+
*/
|
|
1328
|
+
export function looksLikeLocalFile(value) {
|
|
1329
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value) || !isAbsolute(value)) return false;
|
|
1330
|
+
try { return lstatSync(resolve(value)).isFile(); } catch { return false; }
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* Is this Inputs value one a rubric job can work on without a repo: a regular FILE on this machine, or a web address (PART 1c)?
|
|
1335
|
+
* @param {string} value
|
|
1336
|
+
* @returns {boolean}
|
|
1337
|
+
*/
|
|
1338
|
+
export function looksLikeRubricInput(value) {
|
|
1339
|
+
return isWebUrl(value) || looksLikeLocalFile(value);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/**
|
|
1343
|
+
* RUN START for a rubric job with web inputs (PART 1c, RF22): fetch every web input AGAIN and make that text the run's frozen input
|
|
1344
|
+
* BEFORE the worker starts. The drafting copy was only for the look (prices change); the close grades, and the worker reads, THIS
|
|
1345
|
+
* copy. It is written into the prepared tree, committed as the new seed (so the page is never read as a worker write), and
|
|
1346
|
+
* `source.json` is rewritten (new sha256 per input, new seed, `draftSha256` keeps what the look saw). Nothing but the text of a
|
|
1347
|
+
* recorded web address is ever refetched; a job with only file inputs returns at once and changes nothing.
|
|
1348
|
+
* @param {{into: string, fetchUrl?: (url: string) => Promise<{ok: true, text: string, url: string}|{ok: false, error: string}>}} o `into` - the prepared copy (source.json + tree/)
|
|
1349
|
+
* @returns {Promise<{ok: true, refreshed: string[], seed: string|null}|{ok: false, error: string}>}
|
|
1350
|
+
*/
|
|
1351
|
+
export async function refreezeUrlInputs({ into, fetchUrl = (u) => fetchPageText(u) }) {
|
|
1352
|
+
const read = await readSourceManifest(into);
|
|
1353
|
+
if (read.stop !== null) return { ok: false, error: read.stop };
|
|
1354
|
+
if (!read.present || !read.manifest) return { ok: true, refreshed: [], seed: null };
|
|
1355
|
+
const manifest = read.manifest;
|
|
1356
|
+
const rows = Array.isArray(manifest.inputs) ? manifest.inputs.filter((/** @type {any} */ r) => r && typeof r.url === 'string' && r.url !== '') : [];
|
|
1357
|
+
if (rows.length === 0) return { ok: true, refreshed: [], seed: null };
|
|
1358
|
+
const tree = join(resolve(into), 'tree');
|
|
1359
|
+
/** @type {{row: any, buf: Buffer}[]} */
|
|
1360
|
+
const fresh = [];
|
|
1361
|
+
for (const row of rows) {
|
|
1362
|
+
const got = await fetchUrl(row.url);
|
|
1363
|
+
if (!got.ok) return { ok: false, error: `Input "${row.label}": ${got.error}. The run did not start. Nothing spent.` };
|
|
1364
|
+
const leak = secretPatternNames(got.text);
|
|
1365
|
+
if (leak.length) return { ok: false, error: `Input "${row.label}": the page at ${got.url} carries a known secret shape (${leak.join(', ')}) - refused, secrets never enter a job. The run did not start. Nothing spent.` };
|
|
1366
|
+
fresh.push({ row, buf: Buffer.from(got.text, 'utf8') });
|
|
1367
|
+
}
|
|
1368
|
+
for (const f of fresh) await writeFile(join(tree, f.row.rel), f.buf);
|
|
1369
|
+
const add = await git(tree, [...NO_HOOKS, 'add', '-A', '-f']);
|
|
1370
|
+
if (!add.ok) return { ok: false, error: `the fresh page copy could not be staged: ${add.err}. The run did not start. Nothing spent.` };
|
|
1371
|
+
const commit = await git(tree, [...GIT_IDENTITY, ...NO_HOOKS, 'commit', '-q', '--allow-empty', '-m', 'bareloop: run start - fresh copy of the web inputs']);
|
|
1372
|
+
if (!commit.ok) return { ok: false, error: `the fresh page copy could not be committed: ${commit.err}. The run did not start. Nothing spent.` };
|
|
1373
|
+
const head = await git(tree, ['rev-parse', 'HEAD']);
|
|
1374
|
+
if (!head.ok) return { ok: false, error: `git rev-parse HEAD failed in ${tree}: ${head.err}. The run did not start. Nothing spent.` };
|
|
1375
|
+
const seed = head.out.trim();
|
|
1376
|
+
const byLabel = new Map(fresh.map((f) => [f.row.label, f.buf]));
|
|
1377
|
+
const inputs = manifest.inputs.map((/** @type {any} */ r) => {
|
|
1378
|
+
const buf = byLabel.get(r.label);
|
|
1379
|
+
return buf === undefined || typeof r.url !== 'string' ? r : { ...r, draftSha256: r.draftSha256 ?? r.sha256, sha256: sha256Hex(buf) };
|
|
1380
|
+
});
|
|
1381
|
+
const files = Array.isArray(manifest.files) ? manifest.files.map((/** @type {any} */ m) => {
|
|
1382
|
+
const hit = inputs.find((/** @type {any} */ r) => typeof r.url === 'string' && r.rel === `input/${m.path}`);
|
|
1383
|
+
if (!hit) return m;
|
|
1384
|
+
const buf = byLabel.get(hit.label);
|
|
1385
|
+
return buf === undefined ? m : { ...m, bytes: buf.length, sha256: sha256Hex(buf) };
|
|
1386
|
+
}) : manifest.files;
|
|
1387
|
+
const next = { ...manifest, inputs, files, seed, refrozenAt: new Date().toISOString() };
|
|
1388
|
+
await writeFile(join(resolve(into), 'source.json'), `${JSON.stringify(next, null, 2)}\n`);
|
|
1389
|
+
return { ok: true, refreshed: rows.map((/** @type {any} */ r) => r.label), seed };
|
|
1390
|
+
}
|
package/src/trigger.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// T Part 3 (docs/product/PANEL-BUILD.md, "T: triggers build spec", C.3): the `trigger` field of a signed spec,
|
|
2
|
+
// as typed data. Pure. Never cron text, never a model's word: the agent does not write this field (assembleSpec
|
|
3
|
+
// refuses a draft carrying it) and the interview never does either. By hand = the key is ABSENT (never null),
|
|
4
|
+
// so a by-hand spec hashes exactly as it did before triggers existed.
|
|
5
|
+
// {kind:'daily'|'weekdays', hour, minute}
|
|
6
|
+
// {kind:'weekly', day:'mon'..'sun', hour, minute}
|
|
7
|
+
// {kind:'new-file', folder} absolute path
|
|
8
|
+
// {kind:'file-updated', path} absolute path
|
|
9
|
+
// Rewritten from fwdloop src/trigger-line.js (its prose-line grammar is replaced: the signed thing is the JSON spec).
|
|
10
|
+
import { isAbsolute } from 'node:path';
|
|
11
|
+
import { validateSchedule } from './triggerclock.js';
|
|
12
|
+
|
|
13
|
+
/** @typedef {import('./triggerclock.js').Schedule | {kind:'new-file', folder:string} | {kind:'file-updated', path:string}} Trigger */
|
|
14
|
+
|
|
15
|
+
export const TRIGGER_KINDS = Object.freeze(['daily', 'weekdays', 'weekly', 'new-file', 'file-updated']);
|
|
16
|
+
export const CLOCK_KINDS = Object.freeze(['daily', 'weekdays', 'weekly']);
|
|
17
|
+
export const FILE_KINDS = Object.freeze(['new-file', 'file-updated']);
|
|
18
|
+
|
|
19
|
+
/** @param {unknown} t @returns {t is {kind:'daily'|'weekdays'|'weekly', hour:number, minute:number}} */
|
|
20
|
+
export const isClockTrigger = (t) => !!t && typeof t === 'object' && CLOCK_KINDS.includes(/** @type {any} */ (t).kind);
|
|
21
|
+
/** @param {unknown} t @returns {t is {kind:'new-file', folder:string} | {kind:'file-updated', path:string}} */
|
|
22
|
+
export const isFileTrigger = (t) => !!t && typeof t === 'object' && FILE_KINDS.includes(/** @type {any} */ (t).kind);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The only way a trigger enters the system. Returns the typed trigger, or says what to fix (and which field).
|
|
26
|
+
* @param {unknown} t
|
|
27
|
+
* @returns {{ok: true, trigger: Trigger} | {ok: false, say: string, code: 'invalid-value'|'unknown-field', path: string}}
|
|
28
|
+
*/
|
|
29
|
+
export function validateTrigger(t) {
|
|
30
|
+
/** @param {string} say @param {string} [path] @param {'invalid-value'|'unknown-field'} [code] */
|
|
31
|
+
const no = (say, path = 'trigger', code = 'invalid-value') => ({ ok: /** @type {const} */ (false), say, code, path });
|
|
32
|
+
if (!t || typeof t !== 'object' || Array.isArray(t)) return no('a trigger is an object like {kind, hour, minute}, not text');
|
|
33
|
+
const o = /** @type {Record<string, unknown>} */ (t);
|
|
34
|
+
if (!TRIGGER_KINDS.includes(/** @type {string} */ (o.kind))) return no(`trigger kind "${o.kind}" is not one of ${TRIGGER_KINDS.join(', ')}`, 'trigger.kind');
|
|
35
|
+
if (CLOCK_KINDS.includes(/** @type {string} */ (o.kind))) {
|
|
36
|
+
const allowed = o.kind === 'weekly' ? ['kind', 'day', 'hour', 'minute'] : ['kind', 'hour', 'minute'];
|
|
37
|
+
const extra = Object.keys(o).find((k) => !allowed.includes(k));
|
|
38
|
+
if (extra !== undefined) return no(`trigger field "${extra}" is not allowed on a ${o.kind} trigger`, `trigger.${extra}`, 'unknown-field');
|
|
39
|
+
try { return { ok: true, trigger: validateSchedule(o) }; } catch (e) { return no(/** @type {Error} */ (e).message.replace(/^schedule/, 'trigger')); }
|
|
40
|
+
}
|
|
41
|
+
const where = o.kind === 'new-file' ? 'folder' : 'path';
|
|
42
|
+
const extra = Object.keys(o).find((k) => k !== 'kind' && k !== where);
|
|
43
|
+
if (extra !== undefined) return no(`trigger field "${extra}" is not allowed on a ${o.kind} trigger (it takes a ${where})`, `trigger.${extra}`, 'unknown-field');
|
|
44
|
+
const p = o[where];
|
|
45
|
+
// eslint-disable-next-line no-control-regex
|
|
46
|
+
if (typeof p !== 'string' || p === '' || !isAbsolute(p) || /[\x00-\x1f\x7f]/.test(p)) return no(`the ${where} must be a full path on one line, like /home/you/inbox`, `trigger.${where}`);
|
|
47
|
+
return { ok: true, trigger: /** @type {Trigger} */ (o.kind === 'new-file' ? { kind: 'new-file', folder: p } : { kind: 'file-updated', path: p }) };
|
|
48
|
+
}
|