bareloop 0.42.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +88 -0
- package/bareloop.context.md +103 -41
- package/package.json +2 -2
- package/src/authorflow.js +26 -11
- package/src/authoring.js +1 -1
- package/src/authorjob.js +33 -15
- package/src/authorreadout.js +7 -6
- package/src/declaredclose.js +6 -3
- package/src/draftspend.js +20 -2
- package/src/facts.js +54 -9
- package/src/factwords.js +58 -6
- package/src/inputlook.js +38 -3
- package/src/interviewrun.js +0 -1
- package/src/job.js +39 -12
- package/src/kinds.js +5 -0
- package/src/pagedropwords.js +64 -0
- package/src/pagemap.js +264 -0
- package/src/pagerules.js +319 -0
- package/src/panel/authorroutes.js +138 -60
- package/src/panel/authorsession.js +88 -32
- package/src/panel/index.html +1251 -522
- package/src/panel/jobplan.js +4 -2
- package/src/panel/runroutes.js +188 -6
- 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 +1234 -68
- package/src/panel/status.js +33 -0
- package/src/panelmoney.js +29 -0
- package/src/planrun.js +92 -11
- package/src/promptregisters.js +4 -0
- package/src/providerrows.js +3 -3
- package/src/replay.js +118 -7
- package/src/reviewdoor.js +37 -0
- package/src/rubricstep.js +123 -11
- package/src/runlist.js +37 -1
- package/src/schedulewords.js +299 -0
- package/src/source.js +18 -0
- package/src/thinking.js +63 -2
- 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 +34 -21
- package/types/authorflow.d.ts +42 -6
- package/types/authorjob.d.ts +10 -2
- package/types/declaredclose.d.ts +3 -1
- package/types/draftspend.d.ts +27 -1
- package/types/facts.d.ts +15 -0
- package/types/factwords.d.ts +26 -3
- package/types/inputlook.d.ts +15 -0
- package/types/job.d.ts +8 -1
- package/types/kinds.d.ts +5 -0
- package/types/pagedropwords.d.ts +26 -0
- package/types/pagemap.d.ts +154 -0
- package/types/pagerules.d.ts +86 -0
- package/types/panel/authorroutes.d.ts +50 -14
- 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 +297 -15
- package/types/panel/status.d.ts +51 -0
- package/types/panelmoney.d.ts +5 -0
- package/types/planrun.d.ts +2 -0
- package/types/providerrows.d.ts +2 -2
- package/types/replay.d.ts +21 -1
- package/types/reviewdoor.d.ts +27 -0
- package/types/rubricstep.d.ts +57 -1
- package/types/runlist.d.ts +24 -1
- package/types/schedulewords.d.ts +137 -0
- package/types/source.d.ts +11 -0
- package/types/thinking.d.ts +39 -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 +4 -0
package/src/rubricstep.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { readFile, lstat } from 'node:fs/promises';
|
|
3
3
|
import { join, dirname } from 'node:path';
|
|
4
4
|
import { createRubric, checkStep, renderGaps, numbersInQuote } from 'bareguard';
|
|
5
|
+
import { dropRows, dropLine } from './pagedropwords.js';
|
|
5
6
|
import { factPartOf, factShapeReds, factDecides, factClaims, factRows, plainFactItem, factBrief } from './facts.js';
|
|
6
7
|
|
|
7
8
|
// PART 1a (docs/product/ITEM33-BUILD.md, "Addendum 2026-10-12"): the rubric job on LOCAL FILES — what one job line leaves
|
|
@@ -100,15 +101,35 @@ export function rubricGoal({ jobLines, files, confirmedGoal = null, closeDecl =
|
|
|
100
101
|
const inputs = files.map((f) => ` - ${f.label}: ${f.rel}`).join('\n');
|
|
101
102
|
const lines = jobLines.map((l) => `${l.n}. ${l.text}${l.rule ? `\n rule: ${l.rule}` : ''}`).join('\n');
|
|
102
103
|
return [
|
|
103
|
-
|
|
104
|
+
RUBRIC_GOAL_HEAD,
|
|
104
105
|
`The input files (label: file):\n${inputs}`,
|
|
105
106
|
stepFileContract(),
|
|
106
107
|
`The job, line by line:\n${lines}`,
|
|
107
108
|
...(closeDecl ? lineBriefs(closeDecl).map((b) => b.text) : []),
|
|
108
|
-
...(confirmedGoal ? [
|
|
109
|
+
...(confirmedGoal ? [`${CONFIRMED_GOAL_PREFIX}${confirmedGoal}`] : []),
|
|
109
110
|
].join('\n\n');
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
/**
|
|
114
|
+
* What a rubric job's goal text is: the worker brief `rubricGoal` builds. The Job tab's "AI's summary" is only the one
|
|
115
|
+
* sentence the person confirmed, never this brief, so the first line and the confirmed-sentence prefix have ONE spelling.
|
|
116
|
+
*/
|
|
117
|
+
export const RUBRIC_GOAL_HEAD = 'Do the job below from the input files, then write one JSON file per numbered line.';
|
|
118
|
+
const CONFIRMED_GOAL_PREFIX = 'The plan the person confirmed: ';
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The sentence the person confirmed, out of a goal text: for a rubric brief the rest of the `CONFIRMED_GOAL_PREFIX` line,
|
|
122
|
+
* or null when the brief has none; any other goal (a code job's) comes back unchanged.
|
|
123
|
+
* @param {string} goal
|
|
124
|
+
* @returns {string|null}
|
|
125
|
+
*/
|
|
126
|
+
export function confirmedGoalOf(goal) {
|
|
127
|
+
if (!goal.startsWith(RUBRIC_GOAL_HEAD)) return goal;
|
|
128
|
+
const line = goal.split('\n').find((l) => l.startsWith(CONFIRMED_GOAL_PREFIX));
|
|
129
|
+
const sentence = line ? line.slice(CONFIRMED_GOAL_PREFIX.length).trim() : '';
|
|
130
|
+
return sentence || null;
|
|
131
|
+
}
|
|
132
|
+
|
|
112
133
|
// ── the `rubric-step` stage: its parameters, and the rubric they build ───────────────────────────────────────────────
|
|
113
134
|
//
|
|
114
135
|
// A stage serves exactly ONE job line N and reads exactly one file, output/steps/N.json. Its parameters are
|
|
@@ -258,6 +279,46 @@ export function buildStepSpec(params, inputShas) {
|
|
|
258
279
|
/** the sha256 of text, hex @param {string|Buffer} b @returns {string} */
|
|
259
280
|
export const sha256Of = (b) => createHash('sha256').update(b).digest('hex');
|
|
260
281
|
|
|
282
|
+
/**
|
|
283
|
+
* The files one named check READS, tree-relative: a `rubric-step` stage's own step file (`output/steps/N.json`). Any other kind reads
|
|
284
|
+
* none that a try's output can be told by, so `[]`. From the signed declaration, never guessed.
|
|
285
|
+
* @param {any} closeDecl @param {string|null|undefined} checkName
|
|
286
|
+
* @returns {string[]}
|
|
287
|
+
*/
|
|
288
|
+
export function judgedFilesOf(closeDecl, checkName) {
|
|
289
|
+
const stages = Array.isArray(closeDecl?.stages) ? closeDecl.stages : [];
|
|
290
|
+
const st = stages.find((/** @type {any} */ x) => x && x.name === checkName && x.kind === 'rubric-step');
|
|
291
|
+
return st && Number.isInteger(st.params?.step) ? [stepFileRel(st.params.step)] : [];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* PRD v1.106 Part 5: what each file a try's `check-passes` checks read looked like WHEN it was judged: `{path, bytes, sha256}`. The step
|
|
296
|
+
* file is overwritten by the next try, so the spine's `output-judged` record is the only trace; the content itself is never written (hard
|
|
297
|
+
* line). A missing, non-regular or over-size file is skipped — nothing was judged there.
|
|
298
|
+
* @param {any} closeDecl @param {string} workdir @param {Array<{type?: string, name?: string}>} exits a step's declared exits
|
|
299
|
+
* @returns {Promise<Array<{path: string, bytes: number, sha256: string}>>}
|
|
300
|
+
*/
|
|
301
|
+
export async function outputJudgedRecords(closeDecl, workdir, exits) {
|
|
302
|
+
/** @type {Array<{path: string, bytes: number, sha256: string}>} */
|
|
303
|
+
const out = [];
|
|
304
|
+
const seen = new Set();
|
|
305
|
+
for (const e of Array.isArray(exits) ? exits : []) {
|
|
306
|
+
if (!e || e.type !== 'check-passes') continue;
|
|
307
|
+
for (const rel of judgedFilesOf(closeDecl, e.name)) {
|
|
308
|
+
if (seen.has(rel)) continue;
|
|
309
|
+
seen.add(rel);
|
|
310
|
+
try {
|
|
311
|
+
const abs = join(workdir, rel);
|
|
312
|
+
const ls = await lstat(abs);
|
|
313
|
+
if (!ls.isFile() || ls.size > STEP_MAX_BYTES) continue;
|
|
314
|
+
const buf = await readFile(abs);
|
|
315
|
+
out.push({ path: rel, bytes: buf.length, sha256: sha256Of(buf) });
|
|
316
|
+
} catch { /* not there: nothing was judged */ }
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
|
|
261
322
|
/**
|
|
262
323
|
* The step file's bytes as the close reads them: the parsed JSON object, or WHY there is none. A file cut off mid-write
|
|
263
324
|
* parses to nothing (reason `not-json`); text cut off INSIDE an otherwise valid JSON object cannot be told from a short
|
|
@@ -358,7 +419,9 @@ export function plainStepGap(step, why, g, params = null) {
|
|
|
358
419
|
* `inputs` maps each cited label to its frozen TEXT; its sha256 is taken here from that text, and bareguard compares it to
|
|
359
420
|
* the manifest's recorded sha via `inputShas`.
|
|
360
421
|
* @param {Record<string, string>} inputShas the manifest's recorded sha256 per cited label
|
|
361
|
-
* @returns {Promise<{verdict: 'green'|'red'|'stopped', gapLines: string[], fault: string|null, detail: Record<string, any
|
|
422
|
+
* @returns {Promise<{verdict: 'green'|'red'|'stopped', gapLines: string[], fault: string|null, detail: Record<string, any>,
|
|
423
|
+
* evidence?: any[], evidenceTotal?: number}>} `evidence`/`evidenceTotal` are bareguard's `full.evidence` (what each `cited` check read), carried out
|
|
424
|
+
* for the report-only `rubric-evidence` spine record; no verdict, gap or count above is derived from them
|
|
362
425
|
*/
|
|
363
426
|
export async function gradeStep({ params, bytes, outputBytes, inputs, factQuotes = {}, factMissing = [] }, inputShas) {
|
|
364
427
|
let rubric;
|
|
@@ -396,8 +459,12 @@ export async function gradeStep({ params, bytes, outputBytes, inputs, factQuotes
|
|
|
396
459
|
const redCount = extraCount + r.full.gaps.reduce((/** @type {number} */ n, /** @type {any} */ g) => n + (g.check === 'cited' && g.kind === 'unsupported' && typeof g.measured === 'number' ? g.measured : 1), 0);
|
|
397
460
|
const detail = { step: params.step, rubricSha: r.rubricSha, outputSha: r.outputSha, why: parsed.why, gaps: r.full.gaps.length, ...(r.verdict === 'red' || extra.length > 0 ? { redCount } : {}) };
|
|
398
461
|
if (r.verdict === 'stopped') return { verdict: 'stopped', gapLines: [], fault: r.fault ? `${r.fault.kind}: ${r.fault.detail}` : 'the rubric stopped', detail };
|
|
399
|
-
|
|
400
|
-
|
|
462
|
+
const seen = {
|
|
463
|
+
...(Array.isArray(r.full.evidence) && r.full.evidence.length > 0 ? { evidence: r.full.evidence } : {}),
|
|
464
|
+
...(typeof r.full.evidenceTotal === 'number' ? { evidenceTotal: r.full.evidenceTotal } : {}),
|
|
465
|
+
};
|
|
466
|
+
if (r.verdict === 'green' && extra.length === 0) return { verdict: 'green', gapLines: [], fault: null, detail, ...seen };
|
|
467
|
+
return { verdict: 'red', gapLines: [...extra, ...r.gaps.flatMap((g) => plainStepGap(params.step, parsed.why ?? 'empty', g, params).split('\n'))], fault: null, detail, ...seen };
|
|
401
468
|
}
|
|
402
469
|
|
|
403
470
|
// ── R11: the sorting rules, applied at DRAFTING ──────────────────────────────────────────────────────────────────────
|
|
@@ -552,6 +619,16 @@ const boundSpellings = (b) => {
|
|
|
552
619
|
return [s, s.replace(/^0+(?=\d)/, ''), s.replace(/\.0+$/, '')];
|
|
553
620
|
};
|
|
554
621
|
|
|
622
|
+
/**
|
|
623
|
+
* Is this rule bound in the words the person wrote? The ONE test for "a number the AI made" (`rubric-fact-unmarked`, the L3 settle, and
|
|
624
|
+
* the early ask of PRD v1.107 Part 5b all read it; never a second spelling).
|
|
625
|
+
* @param {unknown} b @param {string} said @returns {boolean}
|
|
626
|
+
*/
|
|
627
|
+
export const boundInWords = (b, said) => boundSpellings(b).some((sp) => said.includes(sp));
|
|
628
|
+
|
|
629
|
+
/** @param {any} jl one job line @returns {string} the words the person wrote on it: the line and its `~` rules */
|
|
630
|
+
export const wordsSaidOn = (jl) => `${jl.text ?? ''} ${lineRules(jl).join(' ')}`;
|
|
631
|
+
|
|
555
632
|
/**
|
|
556
633
|
* PART 1b, RF16 + RF17 — the rules that need the person's own job lines. Only a close built for REQUIRED FACTS is held to them (a
|
|
557
634
|
* close with a fact part, or any part that says which `~` rules it carries out): an older Part 1a close is left as it was signed.
|
|
@@ -581,12 +658,12 @@ export function checkRubricRules(stages, { jobLines = null, refused = null, red
|
|
|
581
658
|
const fp = factPartOf(st.params);
|
|
582
659
|
if (!fp || !Array.isArray(fp.facts)) continue;
|
|
583
660
|
const jl = jobLines.find((l) => isRec(l) && l.n === st.params.step);
|
|
584
|
-
const said = jl ?
|
|
661
|
+
const said = jl ? wordsSaidOn(jl) : null;
|
|
585
662
|
fp.facts.forEach((/** @type {any} */ f, /** @type {number} */ j) => {
|
|
586
663
|
if (!isRec(f) || !isRec(f.rule)) return;
|
|
587
664
|
const w = `stages[${i}].params.parts[${st.params.parts.indexOf(fp)}].facts[${j}]`;
|
|
588
665
|
const bounds = f.rule.op === 'between' ? [f.rule.lo, f.rule.hi] : [f.rule.value];
|
|
589
|
-
if (said !== null && !f.aiProposal && bounds.some((b) => !
|
|
666
|
+
if (said !== null && !f.aiProposal && bounds.some((b) => !boundInWords(b, said))) {
|
|
590
667
|
red('rubric-fact-unmarked', w, `line ${st.params.step}: the limit on fact "${f.name}" (${bounds.join(' to ')}) is not in the words the person wrote, so it is the AI's own number. Set "aiProposal": true on that fact so the card says "AI's proposal — check it". Nothing spent.`, { line: st.params.step });
|
|
591
668
|
}
|
|
592
669
|
if (f.aiProposal && f.shape === 'time' && f.rule.op !== 'between') {
|
|
@@ -620,12 +697,12 @@ export function settlePersonsWords(declaration, jobLines) {
|
|
|
620
697
|
const jl = jobLines.find((l) => isRec(l) && l.n === st.params.step);
|
|
621
698
|
if (!fp || !Array.isArray(fp.facts) || !jl) return st;
|
|
622
699
|
const rules = lineRules(jl);
|
|
623
|
-
const said =
|
|
700
|
+
const said = wordsSaidOn(jl);
|
|
624
701
|
const ranges = rules.filter((r) => !NEGATING_RE.test(r)).map((r) => TIME_RANGE_RE.exec(r)).filter((m) => m !== null);
|
|
625
702
|
const proposed = fp.facts.filter((/** @type {any} */ f) => isRec(f) && f.aiProposal === true && f.shape === 'time' && isRec(f.rule) && f.rule.op === 'between');
|
|
626
703
|
const facts = fp.facts.map((/** @type {any} */ f) => {
|
|
627
704
|
if (!isRec(f) || !isRec(f.rule) || f.aiProposal !== true) return f;
|
|
628
|
-
const own = (/** @type {unknown[]} */ bounds) => bounds.every((b) =>
|
|
705
|
+
const own = (/** @type {unknown[]} */ bounds) => bounds.every((b) => boundInWords(b, said));
|
|
629
706
|
const bounds = f.rule.op === 'between' ? [f.rule.lo, f.rule.hi] : [f.rule.value];
|
|
630
707
|
const { aiProposal: _drop, ...rest } = f;
|
|
631
708
|
if (own(bounds)) return rest;
|
|
@@ -667,6 +744,36 @@ export function withFactBindings(declaration) {
|
|
|
667
744
|
return { ...declaration, stages };
|
|
668
745
|
}
|
|
669
746
|
|
|
747
|
+
/**
|
|
748
|
+
* NO SILENT DROP (idea A core, spec 4.4): code writes the Not checked rows for what the page rules removed, before the declaration is validated.
|
|
749
|
+
* - a dropped FACT that carries a `~` rule no part carries and no `refused` entry names -> `closeDecl.refused` gets {line, rule, reason}
|
|
750
|
+
* (shown under that line in the person's own rule words by {@link ruleNotChecked});
|
|
751
|
+
* - any other drop (a fact from the job line itself, a rule another fact carries, a word dropped from a fact that is still checked, so a
|
|
752
|
+
* narrower check is still said) -> one line in `closeDecl.notes`, shown as Not checked (the AI's own reading).
|
|
753
|
+
* Never a line-level `refused` entry without `rule` (that would silence every other rule red on the line). A rule the drafter already listed
|
|
754
|
+
* itself is never listed twice, and running this twice adds nothing. Pure; the input is untouched.
|
|
755
|
+
* @param {any} declaration @param {import('./pagerules.js').Dropped[]|null|undefined} dropped @param {any[]|null|undefined} jobLines @returns {any}
|
|
756
|
+
*/
|
|
757
|
+
export function withDropNotes(declaration, dropped, jobLines) {
|
|
758
|
+
if (!isRec(declaration) || !Array.isArray(dropped) || dropped.length === 0) return declaration;
|
|
759
|
+
const stages = Array.isArray(declaration.stages) ? declaration.stages : [];
|
|
760
|
+
const carried = (/** @type {number} */ line, /** @type {number} */ n) => stages.some((/** @type {any} */ st) => isRec(st) && st.kind === 'rubric-step' && isRec(st.params) && st.params.step === line
|
|
761
|
+
&& Array.isArray(st.params.parts) && st.params.parts.some((/** @type {any} */ x) => isRec(x) && Array.isArray(x.rules) && x.rules.includes(n)));
|
|
762
|
+
const refused = Array.isArray(declaration.refused) ? [...declaration.refused] : [];
|
|
763
|
+
const notes = Array.isArray(declaration.notes) ? [...declaration.notes] : [];
|
|
764
|
+
for (const row of dropRows(dropped)) {
|
|
765
|
+
const named = row.rule !== undefined && refused.some((/** @type {any} */ r) => isRec(r) && r.line === row.line && r.rule === row.rule);
|
|
766
|
+
if (named) continue;
|
|
767
|
+
if (row.full && row.rule !== undefined && !carried(row.line, row.rule)) {
|
|
768
|
+
refused.push({ line: row.line, rule: row.rule, reason: row.sentences.join(' ') });
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
const text = dropLine(row, jobLines, lineRules);
|
|
772
|
+
if (!notes.includes(text)) notes.push(text);
|
|
773
|
+
}
|
|
774
|
+
return { ...declaration, ...(refused.length ? { refused } : {}), ...(notes.length ? { notes } : {}) };
|
|
775
|
+
}
|
|
776
|
+
|
|
670
777
|
/**
|
|
671
778
|
* The ~ rules the drafter listed under Not checked (`refused` entries that name a `rule`), each as one line in the person's own
|
|
672
779
|
* words: `line N: "<the rule as written>" — <the drafter's reason>` (PART 1b, RF6 / RF16).
|
|
@@ -686,9 +793,14 @@ export function ruleNotChecked(closeDecl, jobLines) {
|
|
|
686
793
|
* @param {any} closeDecl @returns {string[]}
|
|
687
794
|
*/
|
|
688
795
|
export function proseNotChecked(closeDecl) {
|
|
796
|
+
return proseNotCheckedItems(closeDecl).map((i) => i.text);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** the same items as {@link proseNotChecked} with the job line each belongs to (the stage's own `step`) @param {any} closeDecl @returns {{line: number, text: string}[]} */
|
|
800
|
+
export function proseNotCheckedItems(closeDecl) {
|
|
689
801
|
const stages = isRec(closeDecl) && Array.isArray(closeDecl.stages) ? closeDecl.stages : [];
|
|
690
802
|
return stages.filter((/** @type {any} */ st) => isRec(st) && st.kind === 'rubric-step' && isRec(st.params) && Array.isArray(st.params.parts) && !factPartOf(st.params))
|
|
691
|
-
.map((/** @type {any} */ st) => `line ${st.params.step}: what the text says — you judge it at Accept`);
|
|
803
|
+
.map((/** @type {any} */ st) => ({ line: st.params.step, text: `line ${st.params.step}: what the text says — you judge it at Accept` }));
|
|
692
804
|
}
|
|
693
805
|
|
|
694
806
|
// ── the sign-vs-run invariant: the checks the person is SHOWN are the checks the signed close RUNS ───────────────────
|
package/src/runlist.js
CHANGED
|
@@ -104,7 +104,8 @@ export function appendRun(row, opts = {}) {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
/**
|
|
107
|
-
* @typedef {{ runid: string, type: 'settled'|'released', by: string, at: string, reason?: string, spentUsd?: number, spendComplete?: boolean, leg?: number }} RunEvent
|
|
107
|
+
* @typedef {{ runid: string, type: 'settled'|'released', by: string, at: string, reason?: string, words?: string, spentUsd?: number, spendComplete?: boolean, leg?: number }} RunEvent
|
|
108
|
+
* `words` (2026-10-19): on a `settled` with `reason: 'refused before start'`, the plain refusal the panel shows for that run (it has no spine).
|
|
108
109
|
* `leg` (P5-R): which leg of the run the write-back is about; absent = leg 1 (every entry written before P5-R).
|
|
109
110
|
* A resumed run is ONE row, so `settled`/`released` are per leg: a later leg's `released` gives back THAT leg's
|
|
110
111
|
* claim and never removes the run.
|
|
@@ -128,6 +129,41 @@ export function appendRunEvent(event, opts = {}) {
|
|
|
128
129
|
try { chmodSync(path, 0o600); } catch { /* best-effort perms */ }
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
/**
|
|
133
|
+
* RULED 2026-10-15 ("Remove from Needs you"): the person took a stopped run's card out of the Needs-you list. ONE row
|
|
134
|
+
* `{runid, type:'needs-removed', at}` appended beside the run rows; it is not a run, a claim or an event ({@link readRunList}
|
|
135
|
+
* ignores it, so the monthly limit, the run counter and the scheduler never see it). Nothing is rewritten.
|
|
136
|
+
* @param {string} runid
|
|
137
|
+
* @param {{ home?: string, now?: () => number }} [opts]
|
|
138
|
+
* @returns {string} the `at` written
|
|
139
|
+
*/
|
|
140
|
+
export function appendNeedsRemoved(runid, opts = {}) {
|
|
141
|
+
const at = new Date((opts.now ?? Date.now)()).toISOString();
|
|
142
|
+
const path = runlistPath(opts.home);
|
|
143
|
+
mkdirSync(runlistHome(opts.home), { recursive: true, mode: 0o700 });
|
|
144
|
+
appendFileSync(path, `${JSON.stringify({ runid, type: 'needs-removed', at })}\n`);
|
|
145
|
+
try { chmodSync(path, 0o600); } catch { /* best-effort perms */ }
|
|
146
|
+
return at;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 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.
|
|
151
|
+
* @param {{ home?: string }} [opts]
|
|
152
|
+
* @returns {Map<string, number>}
|
|
153
|
+
*/
|
|
154
|
+
export function readNeedsRemovals(opts = {}) {
|
|
155
|
+
/** @type {Map<string, number>} */
|
|
156
|
+
const out = new Map();
|
|
157
|
+
const path = runlistPath(opts.home);
|
|
158
|
+
if (!existsSync(path)) return out;
|
|
159
|
+
for (const r of parseJsonl(path).records) {
|
|
160
|
+
if (!r || r.type !== 'needs-removed' || typeof r.runid !== 'string') continue;
|
|
161
|
+
const t = typeof r.at === 'string' ? Date.parse(r.at) : NaN;
|
|
162
|
+
if (Number.isFinite(t) && t >= (out.get(r.runid) ?? -Infinity)) out.set(r.runid, t);
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
131
167
|
/**
|
|
132
168
|
* @typedef {{ type: 'leg-start', runid: string, leg: number, pid?: number, capUsd?: number, at: string }} LegStart
|
|
133
169
|
* 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,299 @@
|
|
|
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
|
+
import { STATUS } from './panel/status.js';
|
|
10
|
+
|
|
11
|
+
/** @typedef {import('./trigger.js').Trigger} Trigger */
|
|
12
|
+
|
|
13
|
+
const DAY_PLURAL = { mon: 'Mondays', tue: 'Tuesdays', wed: 'Wednesdays', thu: 'Thursdays', fri: 'Fridays', sat: 'Saturdays', sun: 'Sundays' };
|
|
14
|
+
const DAY_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
15
|
+
const MONTH_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
16
|
+
|
|
17
|
+
/** @param {number} n */
|
|
18
|
+
const two = (n) => String(n).padStart(2, '0');
|
|
19
|
+
/** @param {{hour: number, minute: number}} s */
|
|
20
|
+
const hhmm = (s) => `${two(s.hour)}:${two(s.minute)}`;
|
|
21
|
+
|
|
22
|
+
/** A path with the home folder shown as `~`. @param {string} p @param {string|undefined} home */
|
|
23
|
+
function tilde(p, home) {
|
|
24
|
+
if (home && (p === home || p.startsWith(`${home}/`))) return `~${p.slice(home.length)}`;
|
|
25
|
+
return p;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Every schedule button/word the page shows lives here, so a reword is ONE edit (hamr will reword on his walk).
|
|
30
|
+
* The server sends this table with the run detail and the schedule card; the page reads every label from it.
|
|
31
|
+
*/
|
|
32
|
+
export const SCHEDULE_WORDS = Object.freeze({
|
|
33
|
+
byHand: 'By hand',
|
|
34
|
+
cancelSchedule: 'Cancel schedule',
|
|
35
|
+
cancelWords: 'Cancel schedule',
|
|
36
|
+
// the Run tab's buttons (never relabelled in place: a different state is a different button)
|
|
37
|
+
scheduleRun: 'Schedule run',
|
|
38
|
+
cancelModify: 'Cancel / modify schedule',
|
|
39
|
+
// the one main button of the schedule card
|
|
40
|
+
signSchedule: 'Sign schedule',
|
|
41
|
+
nothingChanged: 'Nothing changed yet. This card only sets or adjusts the schedule; it never runs the job.',
|
|
42
|
+
confirmPrefix: 'Confirm: ',
|
|
43
|
+
// the When field and the blocks
|
|
44
|
+
whenLabel: 'When',
|
|
45
|
+
nowPrefix: 'Now: ',
|
|
46
|
+
scheduledLabel: 'SCHEDULE',
|
|
47
|
+
nextLabel: 'NEXT',
|
|
48
|
+
options: Object.freeze({
|
|
49
|
+
hand: 'By hand',
|
|
50
|
+
daily: 'Every day at HH:MM',
|
|
51
|
+
weekdays: 'Weekdays at HH:MM',
|
|
52
|
+
weekly: 'Every <day> at HH:MM',
|
|
53
|
+
'file-updated': 'When <file> is updated',
|
|
54
|
+
'new-file': 'When a new file arrives in <folder>',
|
|
55
|
+
}),
|
|
56
|
+
// RULED 2026-10-15: the Needs-you pill of a schedule that gave up (hamr may reword)
|
|
57
|
+
gaveUpPill: 'Check',
|
|
58
|
+
// RULED 2026-10-15 (aligned with fwdloop M9 am7): the SCHEDULE row's second line when the last fire started nothing
|
|
59
|
+
problemNext: 'next: fix it in Cancel / modify schedule',
|
|
60
|
+
monthNext: 'next: raise the limit in Settings, or it starts on the 1st',
|
|
61
|
+
acceptWaiting: 'waiting for your Accept (the next run does not wait)',
|
|
62
|
+
scheduleGroup: 'schedule',
|
|
63
|
+
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.',
|
|
64
|
+
stale: 'What you were shown is not what is on the card now. Nothing was signed. Look again, then sign.',
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What starts the job, in words. `null` (by hand) has no sentence.
|
|
69
|
+
* @param {Trigger|null|undefined} t @param {string} [home]
|
|
70
|
+
* @returns {string|null}
|
|
71
|
+
*/
|
|
72
|
+
export function scheduledWords(t, home) {
|
|
73
|
+
if (!t) return null;
|
|
74
|
+
if (t.kind === 'daily') return `Every day at ${hhmm(t)}`;
|
|
75
|
+
if (t.kind === 'weekdays') return `Weekdays at ${hhmm(t)}`;
|
|
76
|
+
if (t.kind === 'weekly') return `${DAY_PLURAL[/** @type {keyof typeof DAY_PLURAL} */ (t.day)] ?? t.day} at ${hhmm(t)}`;
|
|
77
|
+
if (t.kind === 'file-updated') return `When ${basename(t.path)} is updated`;
|
|
78
|
+
const folder = tilde(/** @type {{folder: string}} */ (t).folder, home);
|
|
79
|
+
return `When a new file arrives in ${folder.endsWith('/') ? folder : `${folder}/`}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** "Mon 13 Oct, 09:00" — local time, fixed English names (never the machine's locale). @param {number} ms */
|
|
83
|
+
export function slotWords(ms) {
|
|
84
|
+
const d = new Date(ms);
|
|
85
|
+
return `${DAY_SHORT[d.getDay()]} ${d.getDate()} ${MONTH_SHORT[d.getMonth()]}, ${two(d.getHours())}:${two(d.getMinutes())}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The Scheduled card's first line: "today 17:00", "tomorrow 09:00", else the date the NEXT line already says
|
|
90
|
+
* ("Tue 20 Oct, 09:05"). Local time, by calendar day. @param {number} ms the next run @param {number} now
|
|
91
|
+
*/
|
|
92
|
+
export function nextTimeWords(ms, now) {
|
|
93
|
+
const d = new Date(ms);
|
|
94
|
+
const day = (/** @type {Date} */ x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
|
95
|
+
const apart = Math.round((day(d) - day(new Date(now))) / 86_400_000);
|
|
96
|
+
if (apart === 0) return `today ${two(d.getHours())}:${two(d.getMinutes())}`;
|
|
97
|
+
if (apart === 1) return `tomorrow ${two(d.getHours())}:${two(d.getMinutes())}`;
|
|
98
|
+
return slotWords(ms);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** "next in 20m" / "next in 3h" / "next in 3d" (whole units, rounded down, never "0m"). @param {number} ms @param {number} now */
|
|
102
|
+
export function nextInWords(ms, now) {
|
|
103
|
+
const mins = Math.max(1, Math.floor((ms - now) / 60_000));
|
|
104
|
+
if (mins < 60) return `next in ${mins}m`;
|
|
105
|
+
const hours = Math.floor(mins / 60);
|
|
106
|
+
return hours < 48 ? `next in ${hours}h` : `next in ${Math.floor(hours / 24)}d`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* NEXT of a scheduled job: one sentence, first match wins.
|
|
111
|
+
* 1. a run of this job is waiting for the person's Accept (the next scheduled run does NOT wait for it, T7);
|
|
112
|
+
* 2. the latest outcome this month is `monthly-exhausted` (amounts from the row), RED;
|
|
113
|
+
* 3. a clock kind with a next slot; 4. a file kind that is armed; 5. not armed yet.
|
|
114
|
+
* A last outcome of `refused` goes in front of 3-5, so a trigger that cannot start is never silent.
|
|
115
|
+
* `red` is the typed flag for "the person must act": the page paints it and never guesses from the text.
|
|
116
|
+
* @param {{trigger: Trigger, armed: boolean, nextSlotMs: number|null, lastOutcome: any, waitingRunNo: number|null, now: number, retryInMs?: number|null, gaveUp?: any}} f
|
|
117
|
+
* `lastOutcome` = the newest outcome row of the CURRENT signing (`kind`, `at` in ms, `why`, `leftUsd`, `needUsd`, `atLeast`)
|
|
118
|
+
* @returns {{text: string, red: boolean}}
|
|
119
|
+
*/
|
|
120
|
+
export function scheduledNextFor(f) {
|
|
121
|
+
const { trigger, armed, nextSlotMs, lastOutcome, waitingRunNo, now, retryInMs = null, gaveUp = null } = f;
|
|
122
|
+
/** @param {string} text @param {boolean} [red] */
|
|
123
|
+
const say = (text, red = false) => ({ text, red });
|
|
124
|
+
if (waitingRunNo !== null) return say(`Run ${waitingRunNo} is waiting for your Accept. The next scheduled run does not wait for it.`);
|
|
125
|
+
if (gaveUp && typeof gaveUp === 'object') return say(gaveUpNextWords(gaveUp.why), true);
|
|
126
|
+
const last = lastOutcome && typeof lastOutcome === 'object' ? lastOutcome : null;
|
|
127
|
+
const sameMonth = last !== null && sameMonthAs(last.at, now);
|
|
128
|
+
if (last !== null && last.kind === 'monthly-exhausted' && sameMonth && typeof last.leftUsd === 'number' && typeof last.needUsd === 'number') {
|
|
129
|
+
const left = last.atLeast === true ? `at most ${panelMoney2(last.leftUsd)} left` : `${panelMoney2(last.leftUsd)} left`;
|
|
130
|
+
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);
|
|
131
|
+
}
|
|
132
|
+
const lead = last !== null && last.kind === 'refused' && typeof last.why === 'string' && last.why !== '' ? `The last try did not start: ${last.why} ${retryWords(retryInMs)}` : '';
|
|
133
|
+
const red = lead !== '';
|
|
134
|
+
if (!armed) return say(`${lead}Not armed yet: the panel arms it on its next check.`, red);
|
|
135
|
+
if (trigger.kind === 'file-updated') return say(`${lead}Watching for it. A run starts when its contents change.`, red);
|
|
136
|
+
if (trigger.kind === 'new-file') return say(`${lead}Watching for it. A run starts for each new file.`, red);
|
|
137
|
+
if (nextSlotMs === null) return say(`${lead}Not armed yet: the panel arms it on its next check.`, red);
|
|
138
|
+
return say(`${lead}Next run ${slotWords(nextSlotMs)}.`, red);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Is `atMs` in the same local calendar month as `nowMs`? A non-number is never. @param {unknown} atMs @param {number} nowMs */
|
|
142
|
+
export function sameMonthAs(atMs, nowMs) {
|
|
143
|
+
if (typeof atMs !== 'number' || !Number.isFinite(atMs)) return false;
|
|
144
|
+
const a = new Date(atMs); const n = new Date(nowMs);
|
|
145
|
+
return a.getFullYear() === n.getFullYear() && a.getMonth() === n.getMonth();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** RULED 2026-10-15: the red NEXT of a schedule that gave up. @param {unknown} why the refusal's own words */
|
|
149
|
+
export function gaveUpNextWords(why) {
|
|
150
|
+
const w = typeof why === 'string' ? why.trim().replace(/\.$/, '') : '';
|
|
151
|
+
return `Stopped trying after 60 minutes: ${w}. Fix it, then Cancel / modify schedule to start again.`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The give-up date on the Needs-you card, local `MM/DD HH:MM`. @param {number} ms */
|
|
155
|
+
export function gaveUpDateWords(ms) {
|
|
156
|
+
const d = new Date(ms);
|
|
157
|
+
return `${two(d.getMonth() + 1)}/${two(d.getDate())} ${two(d.getHours())}:${two(d.getMinutes())}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 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 */
|
|
161
|
+
export function retryWords(ms) {
|
|
162
|
+
if (typeof ms !== 'number' || !(ms > 0)) return '';
|
|
163
|
+
const m = Math.ceil(ms / 60_000);
|
|
164
|
+
return `Trying again in ${m} minute${m === 1 ? '' : 's'}. `;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The fixed catch-up sentence from a run's `trigger.json`. @param {any} rec */
|
|
168
|
+
export function catchUpWords(rec) {
|
|
169
|
+
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;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A spawn error AFTER the consuming row: `tries` releases so far, `max` the bound. Under the bound it will be tried again;
|
|
174
|
+
* at the bound it stays consumed and says so. @param {string} why @param {number} tries @param {number} max
|
|
175
|
+
*/
|
|
176
|
+
export function lateRefusalWords(why, tries, max) {
|
|
177
|
+
return tries < max ? `${why} It will be tried again.` : `${why} It was tried ${max} times and is given up on.`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------------------------------------------------------
|
|
181
|
+
// RULED 2026-10-15 (aligned with fwdloop M9 am7): the schedule on the Run, Job and Audit tabs. ONE owner of these sentences.
|
|
182
|
+
|
|
183
|
+
/** "10/15 13:40" — local time. @param {number} ms */
|
|
184
|
+
export const dateTimeWords = (ms) => gaveUpDateWords(ms);
|
|
185
|
+
|
|
186
|
+
/** @param {string} s */
|
|
187
|
+
export const lowerFirst = (s) => (s === '' ? s : s[0].toLowerCase() + s.slice(1));
|
|
188
|
+
|
|
189
|
+
/** "Thu 10/16 08:15" — local time, fixed English day names. @param {number} ms */
|
|
190
|
+
export function nextSlotShort(ms) {
|
|
191
|
+
const d = new Date(ms);
|
|
192
|
+
return `${DAY_SHORT[d.getDay()]} ${two(d.getMonth() + 1)}/${two(d.getDate())} ${two(d.getHours())}:${two(d.getMinutes())}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** @param {any} rec a run's `trigger.json` @returns {boolean} */
|
|
196
|
+
function isNewFile(rec) {
|
|
197
|
+
if (rec.triggerKind) return rec.triggerKind === 'new-file';
|
|
198
|
+
return typeof rec.trigger === 'string' && rec.trigger.startsWith('When a new file arrives');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Job tab, "Started": how the run began, then its local start time. `rec` = the run's `trigger.json` (null on a hand run).
|
|
203
|
+
* @param {any} rec @param {number|null} atMs the run's start
|
|
204
|
+
*/
|
|
205
|
+
export function startedWords(rec, atMs) {
|
|
206
|
+
const when = atMs === null ? '' : ` · ${dateTimeWords(atMs)}`;
|
|
207
|
+
if (!rec || typeof rec !== 'object') return `${SCHEDULE_WORDS.byHand}${when}`;
|
|
208
|
+
const trig = typeof rec.trigger === 'string' ? rec.trigger : '';
|
|
209
|
+
let how;
|
|
210
|
+
if (rec.kind === 'file') {
|
|
211
|
+
const name = basename(String(rec.file ?? ''));
|
|
212
|
+
how = isNewFile(rec) ? `By a new file: ${name}` : `By a file update: ${name}`;
|
|
213
|
+
} else {
|
|
214
|
+
how = `By the schedule: ${lowerFirst(trig)}`;
|
|
215
|
+
if (rec.catchUp === true) {
|
|
216
|
+
const min = typeof rec.lateMs === 'number' && rec.lateMs > 0 ? Math.round(rec.lateMs / 60_000) : null;
|
|
217
|
+
how += min === null ? ' · catch-up' : ` · catch-up, ${min} min late`;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return `${how}${when}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Audit, first row of a run a schedule started. @param {any} rec `trigger.json` @param {number|null} originNo the origin run's number
|
|
225
|
+
*/
|
|
226
|
+
export function startedAuditWords(rec, originNo) {
|
|
227
|
+
const from = originNo === null ? '' : ` · from run-${originNo}`;
|
|
228
|
+
if (rec.kind === 'file') return `started by the schedule · ${isNewFile(rec) ? 'new file' : 'file update'} ${basename(String(rec.file ?? ''))}${from}`;
|
|
229
|
+
const slot = typeof rec.slot === 'number' ? ` · slot ${two(new Date(rec.slot).getHours())}:${two(new Date(rec.slot).getMinutes())}` : '';
|
|
230
|
+
return `started by the schedule · ${lowerFirst(String(rec.trigger ?? ''))}${slot}${from}${rec.catchUp === true ? ' · catch-up' : ''}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** a refusal's own words without its trailing "Nothing started." @param {unknown} why */
|
|
234
|
+
function reasonOf(why) {
|
|
235
|
+
return String(why ?? '').replace(/\s*Nothing (started|spent)\.\s*$/, '').trim().replace(/\.$/, '');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* A fire that started nothing, in plain words. `short` = the SCHEDULE row's `last` reason; else the Audit group's line.
|
|
240
|
+
* @param {any} row a ledger row (`busy` | `monthly-exhausted` | `refused` | `gave-up`) @param {boolean} [short]
|
|
241
|
+
*/
|
|
242
|
+
export function skippedWords(row, short = false) {
|
|
243
|
+
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.';
|
|
244
|
+
if (row.kind === 'monthly-exhausted') {
|
|
245
|
+
const left = typeof row.leftUsd === 'number' ? `${row.atLeast === true ? 'at most ' : ''}${panelMoney2(row.leftUsd)}` : 'little';
|
|
246
|
+
const need = typeof row.needUsd === 'number' ? panelMoney2(row.needUsd) : 'more';
|
|
247
|
+
return short ? `month used up: ${left} left, needs ${need}` : `month used up: ${left} left, needs ${need}. Nothing spent.`;
|
|
248
|
+
}
|
|
249
|
+
const r = lowerFirst(reasonOf(row.why));
|
|
250
|
+
return short ? `refused: ${r}` : `refused: ${r}. Nothing started.`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 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}),
|
|
255
|
+
* first letter lowered, so the two can never disagree.
|
|
256
|
+
* @param {{trigger: Trigger|null, originNo: number|null, model: string|null, destination: string|null, home?: string}} f
|
|
257
|
+
*/
|
|
258
|
+
export function jobScheduleWords(f) {
|
|
259
|
+
const w = f.trigger ? scheduledWords(f.trigger, f.home) : null;
|
|
260
|
+
if (w === null) return 'none';
|
|
261
|
+
const parts = [lowerFirst(w)];
|
|
262
|
+
if (f.originNo !== null) parts.push(`from run-${f.originNo}`);
|
|
263
|
+
if (f.model) parts.push(f.model);
|
|
264
|
+
if (f.destination) parts.push(tilde(f.destination, f.home));
|
|
265
|
+
return parts.join(' · ');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** RULED 2026-10-15: the SCHEDULE row's `next` while an install-needed refusal is waiting to be tried again. @param {number} ms */
|
|
269
|
+
export function retryNextWords(ms) {
|
|
270
|
+
const m = Math.max(1, Math.ceil(ms / 60_000));
|
|
271
|
+
return `next: tries again in ${m} min`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 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
|
|
276
|
+
* run of the job is running (as everywhere in the panel), [✗] when the SCHEDULE row is red, else [·] (on, waiting). RULED 2026-10-18 (v1.106
|
|
277
|
+
* Part 4): the header reads `[sign] word schedule · ...`, the sign AND the word both from the one status table (src/panel/status.js), never typed here.
|
|
278
|
+
* @param {{running: boolean, red: boolean}} f @returns {{sign: string, word: string}}
|
|
279
|
+
*/
|
|
280
|
+
export function scheduleSignFor(f) {
|
|
281
|
+
const { sign, word } = f.running ? STATUS.running : f.red ? STATUS.failed : STATUS.waiting;
|
|
282
|
+
return { sign, word };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** 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` */
|
|
286
|
+
export function sectionThisRunWords(rec) {
|
|
287
|
+
if (rec.kind === 'file') return `started by the schedule · ${isNewFile(rec) ? 'new file' : 'file update'} ${basename(String(rec.file ?? ''))}`;
|
|
288
|
+
let late = '';
|
|
289
|
+
if (rec.catchUp === true) {
|
|
290
|
+
const min = typeof rec.lateMs === 'number' && rec.lateMs > 0 ? Math.round(rec.lateMs / 60_000) : null;
|
|
291
|
+
late = min === null ? ' · catch-up' : ` · catch-up, ${min} min late`;
|
|
292
|
+
}
|
|
293
|
+
return `started by the schedule · ${lowerFirst(String(rec.trigger ?? ''))}${late}`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Audit schedule section, folded tail: `1 try started nothing` / `N tries started nothing`; '' for none. @param {number} n */
|
|
297
|
+
export function triesTailWords(n) {
|
|
298
|
+
return n > 0 ? `${n} ${n === 1 ? 'try' : 'tries'} started nothing` : '';
|
|
299
|
+
}
|
package/src/source.js
CHANGED
|
@@ -1388,3 +1388,21 @@ export async function refreezeUrlInputs({ into, fetchUrl = (u) => fetchPageText(
|
|
|
1388
1388
|
await writeFile(join(resolve(into), 'source.json'), `${JSON.stringify(next, null, 2)}\n`);
|
|
1389
1389
|
return { ok: true, refreshed: rows.map((/** @type {any} */ r) => r.label), seed };
|
|
1390
1390
|
}
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* PRD v1.106 Part 5 Q2: what `source.json` says about web inputs, read synchronously and never throwing: whether the copy has any web input
|
|
1394
|
+
* (`inputs[].url`) and when the run-start refetch last finished (`refrozenAt`, written last by {@link refreezeUrlInputs}). The panel decides
|
|
1395
|
+
* "fetching the page..." from this: it applies while a web input exists and no refetch has finished at or after `sinceIso`.
|
|
1396
|
+
* @param {string} sourceJsonPath @param {string|null|undefined} sinceIso
|
|
1397
|
+
* @returns {{ hasWeb: boolean, fetching: boolean }}
|
|
1398
|
+
*/
|
|
1399
|
+
export function webFetchState(sourceJsonPath, sinceIso) {
|
|
1400
|
+
try {
|
|
1401
|
+
const m = JSON.parse(readFileSync(sourceJsonPath, 'utf8'));
|
|
1402
|
+
const hasWeb = Array.isArray(m?.inputs) && m.inputs.some((/** @type {any} */ r) => r && typeof r.url === 'string' && r.url !== '');
|
|
1403
|
+
if (!hasWeb) return { hasWeb: false, fetching: false };
|
|
1404
|
+
const done = typeof m.refrozenAt === 'string' ? Date.parse(m.refrozenAt) : NaN;
|
|
1405
|
+
const since = typeof sinceIso === 'string' ? Date.parse(sinceIso) : NaN;
|
|
1406
|
+
return { hasWeb: true, fetching: !(Number.isFinite(done) && (!Number.isFinite(since) || done >= since)) };
|
|
1407
|
+
} catch { return { hasWeb: false, fetching: false }; }
|
|
1408
|
+
}
|