bareloop 0.46.1 → 0.47.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/src/learn.js CHANGED
@@ -567,7 +567,7 @@ export function foldCards(cards) {
567
567
  * @typedef {object} Ledger
568
568
  * @property {1} version
569
569
  * @property {Row[]} rows
570
- * @property {Pending[]} pending rubric cards held until a human Accept (NOT counted, NOT readable)
570
+ * @property {Pending[]} pending holds written by v0.46.x for rubric runs, released by the human Accept (NOT counted, NOT readable); new runs never write one
571
571
  * @property {Card[]} suspect CHECK-SUSPECT cards, listed, newest last
572
572
  * @property {Record<string, {at: string, noise: string|null, cards: number, suspect: number, thin: number}>} health per run (idempotent by run id)
573
573
  */
@@ -622,8 +622,9 @@ const byRank = (/** @type {Row} */ a, /** @type {Row} */ b) => b.count - a.count
622
622
  * Fold ONE run's cards into the ledger. Pure: returns a new ledger.
623
623
  *
624
624
  * +1 per RUN per row: the run's own cards are folded into lessons first, and a row whose `runIds` already holds the
625
- * run id is never counted twice (a resumed run, one id, never double counts). A matching lesson replaces the row's
626
- * sample, bumps the model count and appends the run id (a row last seen 56+ days ago counts +1 and is read again);
625
+ * run id is never counted twice (a resumed run, one id, never double counts) but IS refreshed: the resumed leg's card
626
+ * replaces the sample, lesson, key, lastSeen, hash and place (a leg-1 NEVER FIXED row becomes the leg-2 FIXED one). A
627
+ * matching lesson replaces the row's sample, bumps the model count and appends the run id (a row last seen 56+ days ago counts +1 and is read again);
627
628
  * otherwise it opens a new row at count 1. NEVER FIXED cards count like FIXED ones (RULING 3). A workflow row of
628
629
  * another signed hash is never matched here: the new version opens its own rows. Nothing is ever deleted or stamped.
629
630
  * @param {Ledger} ledger
@@ -636,16 +637,23 @@ export function countLedger(ledger, { runid, cards, workflow, place, specHash, t
636
637
  const rep = group.reduce((a, c) => (c.at >= a.at ? c : a));
637
638
  const hit = [...rows].sort(byRank).find((r) => r.workflow === workflow && !otherJobVersion(r, workflow, specHash) && cardsMatch(rep, r.sample).match);
638
639
  if (hit) {
639
- if (hit.runIds.includes(runid)) continue;
640
- hit.count += 1;
641
- hit.models[rep.model ?? 'unknown'] = (hit.models[rep.model ?? 'unknown'] ?? 0) + 1;
642
- hit.runIds.push(runid);
643
- hit.sample = rep;
644
- hit.lesson = lessonOf(rep);
645
- hit.key = keyOfCard(rep);
646
- hit.lastSeen = rep.at;
647
- hit.specHash = specHash;
648
- hit.place = place ?? hit.place;
640
+ // A row already holding this run id is the run's OWN row (a resumed leg re-reads the whole spine): refresh it from
641
+ // the new card, but the count, the model tally and the run id stay one per run.
642
+ const own = hit.runIds.includes(runid);
643
+ if (!own) {
644
+ hit.count += 1;
645
+ hit.models[rep.model ?? 'unknown'] = (hit.models[rep.model ?? 'unknown'] ?? 0) + 1;
646
+ hit.runIds.push(runid);
647
+ }
648
+ // a refresh never steps the row back: re-folding an older run's cards must not replace a newer run's sample
649
+ if (!own || rep.at >= hit.lastSeen) {
650
+ hit.sample = rep;
651
+ hit.lesson = lessonOf(rep);
652
+ hit.key = keyOfCard(rep);
653
+ hit.lastSeen = rep.at;
654
+ hit.specHash = specHash;
655
+ hit.place = place ?? hit.place;
656
+ }
649
657
  } else {
650
658
  let id = rowId(rep);
651
659
  for (let n = 2; rows.some((r) => r.id === id); n += 1) id = `${rowId(rep)}-${n}`;
@@ -712,8 +720,8 @@ export function climb(rows) {
712
720
  }
713
721
 
714
722
  /**
715
- * A rubric run's cards are HELD, not counted, until a human Accept (the existing quarantine is the rule). Replaces
716
- * an earlier hold for the same run id (a resumed run re-derives its cards). Pure.
723
+ * Hold a run's cards until a human Accept. NO LONGER CALLED for new runs (v1.112: a rubric run's lessons count at run
724
+ * end); kept so a hold already in a ledger file still has a writer for tests and a reader in {@link releasePending}. Pure.
717
725
  * @param {Ledger} ledger
718
726
  * @param {{runid: string, cards: Card[], workflow: string, place: string|null, specHash: string|null, at: string}} o
719
727
  * @param {Date} now @returns {Ledger}
@@ -725,7 +733,7 @@ export function holdPending(ledger, { runid, cards, workflow, place, specHash, a
725
733
  }
726
734
 
727
735
  /**
728
- * The human Accept releases a held rubric run: its cards count now (trust `rubric`). Only forward, only here. A hold
736
+ * The human Accept releases an OLD hold (written by v0.46.x; new runs write none): its cards count now (trust `rubric`). Only forward, only here. A hold
729
737
  * older than 60 days is dropped uncounted. Idempotent (no hold = nothing to do; run ids stop a double count).
730
738
  * @param {Ledger} ledger @param {string} runid @param {Date} now
731
739
  * @returns {{ledger: Ledger, released: number}}
package/src/learnstore.js CHANGED
@@ -9,7 +9,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFil
9
9
  import { join, dirname } from 'node:path';
10
10
  import { keysHome } from './keysfile.js';
11
11
  import { readConfig } from './config.js';
12
- import { emptyLedger, redactDeep, countLedger, holdPending, releasePending, recordHealth, buildCards, placeOf, renderHints } from './learn.js';
12
+ import { emptyLedger, redactDeep, countLedger, releasePending, recordHealth, buildCards, placeOf, renderHints } from './learn.js';
13
13
  import { stageQuestionsByName } from './stagewords.js';
14
14
 
15
15
  /**
@@ -57,9 +57,8 @@ export function writeLedger(ledger, home) {
57
57
  }
58
58
 
59
59
  /**
60
- * Fold ONE finished run into the ledger on disk: a deterministic job's cards COUNT now; a rubric job's cards are HELD
61
- * until a human Accept (they are written to `pending`, never counted, never readable). The run's health line is recorded
62
- * either way. Never throws on a bad ledger file: it reports `problem` and writes nothing.
60
+ * Fold ONE finished run into the ledger on disk: every run's cards COUNT now, rubric jobs too (trust kept on new rows;
61
+ * v1.112). `held` is always 0 (kept in the shape for callers). The run's health line is recorded. Never throws on a bad ledger file: it reports `problem` and writes nothing.
63
62
  * @param {{home?: string, runid: string, build: import('./learn.js').CardBuild, workflow: string, place: string|null, specHash: string|null, trust: 'deterministic'|'rubric', at: string, now?: Date}} o
64
63
  * @returns {{wrote: boolean, counted: number, held: number, problem: string|null}}
65
64
  */
@@ -67,23 +66,16 @@ export function foldRunIntoLedger({ home, runid, build, workflow, place, specHas
67
66
  const cur = readLedger(home);
68
67
  if (cur.problem) return { wrote: false, counted: 0, held: 0, problem: cur.problem };
69
68
  let L = cur.ledger;
70
- let counted = 0;
71
- let held = 0;
72
- if (trust === 'rubric') {
73
- L = holdPending(L, { runid, cards: build.cards, workflow, place, specHash, at }, now);
74
- held = build.cards.length;
75
- } else {
76
- const before = L.rows.reduce((n, r) => n + r.runIds.filter((x) => x === runid).length, 0);
77
- L = countLedger(L, { runid, cards: build.cards, workflow, place, specHash, trust, now });
78
- counted = L.rows.reduce((n, r) => n + r.runIds.filter((x) => x === runid).length, 0) - before;
79
- }
69
+ const before = L.rows.reduce((n, r) => n + r.runIds.filter((x) => x === runid).length, 0);
70
+ L = countLedger(L, { runid, cards: build.cards, workflow, place, specHash, trust, now });
71
+ const counted = L.rows.reduce((n, r) => n + r.runIds.filter((x) => x === runid).length, 0) - before;
80
72
  L = recordHealth(L, runid, build, at, now);
81
73
  writeLedger(L, home);
82
- return { wrote: true, counted, held, problem: null };
74
+ return { wrote: true, counted, held: 0, problem: null };
83
75
  }
84
76
 
85
77
  /**
86
- * The human Accept at the review door: release the run's held rubric cards so they count (only forward, only here).
78
+ * The human Accept at the review door: release a hold an older version wrote for the run so it counts (new runs write none; only forward, only here).
87
79
  * @param {{home?: string, runid: string, now?: Date}} o
88
80
  * @returns {{released: number, problem: string|null}}
89
81
  */
@@ -123,7 +115,7 @@ const realOr = (p) => { try { return realpathSync(p); } catch { return p; } };
123
115
 
124
116
  /**
125
117
  * THE RUN-END TALLY ($0, code only, no model): turn the finished run's whole record into cards and fold them into the
126
- * ledger (a rubric job's are held until Accept). Called once from the one engine's run-end block (src/userrun.js
118
+ * ledger (a rubric job's count too, trust kept). Called once from the one engine's run-end block (src/userrun.js
127
119
  * `execute`, shared by the panel, the CLI and the bundle runner), inside its own try/catch: it is a record, never a verdict,
128
120
  * and can never change a run's outcome, exit code or spend. It runs whether the hints setting is on or off.
129
121
  * @param {{home?: string, runid: string, events: any[], auditFiles?: string[], spec: any, specHash: string|null, workdir: string|null, trust: 'deterministic'|'rubric', now?: Date}} o
package/src/opener.js ADDED
@@ -0,0 +1,30 @@
1
+ // The ONE "open this in the OS" helper: `xdg-open` (linux) / `open` (macOS), argv array, no shell, detached.
2
+ // Settings' "Open keys folder" and the `bareloop start` launcher both use it. Another OS or a failed spawn
3
+ // resolves `false` (the caller then shows the path/URL as text); it never throws.
4
+ import { spawn as realSpawn } from 'node:child_process';
5
+
6
+ /**
7
+ * @param {string} target a folder path or a URL
8
+ * @param {{ platform?: string, spawnFn?: ((cmd: string, args: string[], o: object) => any)|null, underTest?: boolean }} [opts]
9
+ * `spawnFn: null` = no opener at all (resolves false); undefined = the real spawn, unless `underTest`, which spawns nothing
10
+ * unless a test injected one
11
+ * @returns {Promise<boolean>} true once the opener process has started
12
+ */
13
+ export function openInOs(target, opts = {}) {
14
+ const platform = opts.platform ?? process.platform;
15
+ const cmd = platform === 'linux' ? 'xdg-open' : (platform === 'darwin' ? 'open' : null);
16
+ const opener = opts.spawnFn === undefined ? (opts.underTest ? null : realSpawn) : opts.spawnFn;
17
+ if (!cmd || !opener) return Promise.resolve(false);
18
+ return new Promise((resolve) => {
19
+ let child;
20
+ try { child = opener(cmd, [target], { detached: true, stdio: 'ignore' }); } catch { resolve(false); return; }
21
+ let settled = false;
22
+ child.once('error', () => { if (!settled) { settled = true; resolve(false); } });
23
+ child.once('spawn', () => {
24
+ if (settled) return;
25
+ settled = true;
26
+ if (typeof child.unref === 'function') child.unref();
27
+ resolve(true);
28
+ });
29
+ });
30
+ }
@@ -432,7 +432,7 @@ export function createAuthorRoutes(opts) {
432
432
  }
433
433
 
434
434
  return {
435
- handle, sessions, headless, startSession, envNow, spawnFn, bareloopBin,
435
+ handle, sessions, headless, openDrafts: () => [...sessions.values()].filter((s) => !TERMINAL_PHASES.has(s.state.phase)).length, startSession, envNow, spawnFn, bareloopBin,
436
436
  /** a headless session the starter has removed from disk leaves its map too @param {string} id */
437
437
  forgetHeadless: (id) => { headless.delete(id); },
438
438
  };
@@ -82,13 +82,24 @@ const HERE = dirname(fileURLToPath(import.meta.url));
82
82
  /** bareloop's own version, from its package.json (read each time the page is served); 'unknown' if it cannot be read, never a guess
83
83
  * @param {string} [file] test seam: another package.json
84
84
  * @returns {string} */
85
- function packageVersion(file = join(HERE, '..', '..', 'package.json')) {
85
+ export function packageVersion(file = join(HERE, '..', '..', 'package.json')) {
86
86
  try {
87
87
  const v = JSON.parse(readFileSync(file, 'utf8')).version;
88
88
  return typeof v === 'string' && v.length > 0 ? v : 'unknown';
89
89
  } catch { return 'unknown'; }
90
90
  }
91
91
 
92
+ /**
93
+ * `GET /api/whoami` body. `drafts` counts the person's drafting sessions that are not finished (the headless scheduled ones are not drafts).
94
+ * @param {{ port: number, packageJsonPath?: string, authorRoutes?: ReturnType<typeof createAuthorRoutes>, schedules?: ReturnType<typeof createScheduleDoor> }} opts
95
+ */
96
+ function whoamiPayload(opts) {
97
+ const drafts = opts.authorRoutes ? opts.authorRoutes.openDrafts() : 0;
98
+ let schedules = 0;
99
+ try { schedules = opts.schedules ? opts.schedules.scan().length : 0; } catch { schedules = 0; }
100
+ return { app: 'bareloop', version: packageVersion(opts.packageJsonPath), pid: process.pid, port: opts.port, drafts, schedules };
101
+ }
102
+
92
103
  /** Largest POST body the panel buffers (job cards are short prose); a bigger one is refused with 413. */
93
104
  const MAX_BODY_BYTES = 1024 * 1024;
94
105
 
@@ -1056,6 +1067,25 @@ export function runsListPayload(opts = {}) {
1056
1067
  return { runs, jobs, scheduled, gaveUp, limits: RUNS_LIST_LIMITS };
1057
1068
  }
1058
1069
 
1070
+ /**
1071
+ * The runs list as the panel builds it, for a caller with no panel running (the launcher header): the same `runsListPayload` over a
1072
+ * schedule door built read-only (no scheduler, no timer), so Needs you = the rows tagged `needs` + the schedules that gave up, ONE owner.
1073
+ * @param {{ home?: string, now?: number, env?: Record<string, string|undefined> }} [opts]
1074
+ * @returns {{ runs: any[], gaveUp: any[] }}
1075
+ */
1076
+ export function runsListForCount(opts = {}) {
1077
+ const home = opts.home;
1078
+ const schedules = createScheduleDoor({
1079
+ ...(home ? { home } : {}),
1080
+ jobRuns: (job) => jobRunsOf(job, { home }),
1081
+ startFrom: (runid) => getStartFrom(runid, { home }),
1082
+ jobOfRun: (runid) => readRunList({ home }).rows.find((r) => r && r.runid === runid)?.job ?? null,
1083
+ rowsFor: () => rowsForHome(keysHome(home), keysForDoor({ env: opts.env ?? {}, keysHome: home }).env),
1084
+ });
1085
+ const { runs, gaveUp } = runsListPayload({ home, ...(opts.now !== undefined ? { now: opts.now } : {}), schedules });
1086
+ return { runs, gaveUp };
1087
+ }
1088
+
1059
1089
  /**
1060
1090
  * The run's DRAFTING PART (hamr 2026-10-06, option A), or null: it exists only when the run came out of a panel
1061
1091
  * session whose drafting log carries a metered call (src/draftspend.js, `draftingPartFor`) and the spine carries the
@@ -3827,6 +3857,13 @@ export function handleRequest(req, res, opts) {
3827
3857
  return;
3828
3858
  }
3829
3859
 
3860
+ // L (PANEL-BUILD.md addendum 2026-10-24): who is answering here — the launcher's probe (`bareloop start|stop|status`). Read-only, no token;
3861
+ // the Host guard above already holds. `drafts` = open drafting sessions (in memory, lost on stop); `schedules` = jobs with a schedule on.
3862
+ if (pathname === '/api/whoami') {
3863
+ send(200, whoamiPayload(opts));
3864
+ return;
3865
+ }
3866
+
3830
3867
  if (pathname === '/api/runs') { send(200, runsListPayload({ home: opts.home, schedules: opts.schedules })); return; }
3831
3868
 
3832
3869
  const runMatch = /^\/api\/runs\/([^/]+)(\/(audit|job|rounds|result|download))?$/.exec(pathname);
@@ -3895,6 +3932,15 @@ export function handleRequest(req, res, opts) {
3895
3932
  sendText(res, 404, 'not found');
3896
3933
  }
3897
3934
 
3935
+ /**
3936
+ * The ONE spelling of the panel's own address (the launcher prints and opens it too).
3937
+ * @param {number} boundPort
3938
+ * @returns {string}
3939
+ */
3940
+ export function panelUrl(boundPort) {
3941
+ return `http://127.0.0.1:${boundPort}`;
3942
+ }
3943
+
3898
3944
  /**
3899
3945
  * Start the panel server. Binds `127.0.0.1` ONLY. A taken port is a LOUD,
3900
3946
  * non-zero-exit failure — this never falls back to another port (see file
@@ -4057,7 +4103,7 @@ export async function panelMain(argv, ctx) {
4057
4103
  }
4058
4104
  try {
4059
4105
  const { port: boundPort } = await createPanelServer({ port, home: ctx.runlistHome, env: ctx.env });
4060
- ctx.out(`bareloop panel — read-only, http://127.0.0.1:${boundPort} (Ctrl-C to stop)`);
4106
+ ctx.out(`bareloop panel — read-only, ${panelUrl(boundPort)} (Ctrl-C to stop)`);
4061
4107
  // never resolves on its own — the process stays up until killed, same
4062
4108
  // shape any other long-running dev server takes.
4063
4109
  await new Promise(() => {});
@@ -21,7 +21,7 @@ import {
21
21
  readConfig, updateConfig, ConfigError, providerFieldProblem, cleanBaseUrl, modelsProblem,
22
22
  } from '../config.js';
23
23
  import { spendSummary, monthlyLimitOf } from '../monthly.js';
24
- import { spawn as realSpawn } from 'node:child_process';
24
+ import { openInOs } from '../opener.js';
25
25
  import { loadKeysEnv, keysFilePath, keysHome, filledKeyNames, ensureKeysFile } from '../keysfile.js';
26
26
  import { SHAPES } from '../providershapes.js';
27
27
  import { keyRows, effectiveModels, usableKey, rowNamesOf } from '../providerrows.js';
@@ -208,24 +208,10 @@ export function createSettingsRoutes(opts) {
208
208
  }
209
209
  const dir = keysHome(opts.home);
210
210
  const cannot = () => reply(200, { ok: true, opened: false, path: dir });
211
- const platform = opts.platform ?? process.platform;
212
- const cmd = platform === 'linux' ? 'xdg-open' : (platform === 'darwin' ? 'open' : null);
213
211
  // under a test process (NODE_ENV=test or `node --test`) nothing is spawned unless a test injected an opener
214
212
  const underTest = process.env.NODE_ENV === 'test' || process.env.NODE_TEST_CONTEXT !== undefined;
215
- const opener = opts.openFolderSpawn ?? (underTest ? null : realSpawn);
216
- if (!cmd || !opener) return Promise.resolve(cannot());
217
- return new Promise((resolve) => {
218
- let child;
219
- try { child = opener(cmd, [dir], { detached: true, stdio: 'ignore' }); } catch { resolve(cannot()); return; }
220
- let settled = false;
221
- child.once('error', () => { if (!settled) { settled = true; resolve(cannot()); } });
222
- child.once('spawn', () => {
223
- if (settled) return;
224
- settled = true;
225
- if (typeof child.unref === 'function') child.unref();
226
- resolve(reply(200, { ok: true, opened: true, path: dir }));
227
- });
228
- });
213
+ return openInOs(dir, { platform: opts.platform, spawnFn: opts.openFolderSpawn, underTest })
214
+ .then((opened) => (opened ? reply(200, { ok: true, opened: true, path: dir }) : cannot()));
229
215
  }
230
216
 
231
217
  /**
@@ -0,0 +1,27 @@
1
+ // ONE place for terminal colour (the launcher's header, menu and messages). Colours wrap words and never change them: with colour off
2
+ // every helper returns its text untouched, so plain-text readers (tests, pipes, logs) see the same words.
3
+
4
+ const wrap = (/** @type {string} */ code) => (/** @type {string} */ s) => `\x1b[${code}m${s}\x1b[0m`;
5
+ const id = (/** @type {string} */ s) => s;
6
+
7
+ /**
8
+ * @typedef {object} Paint
9
+ * @property {boolean} on
10
+ * @property {(s: string) => string} grey @property {(s: string) => string} green @property {(s: string) => string} yellow
11
+ * @property {(s: string) => string} red @property {(s: string) => string} cyan @property {(s: string) => string} boldCyan
12
+ */
13
+
14
+ /**
15
+ * Colour is on only for a terminal: the stream says it is a TTY, `NO_COLOR` is empty/unset, and `TERM` is not `dumb`.
16
+ * @param {{isTTY?: boolean}|undefined} stream the output stream (process.stdout)
17
+ * @param {Record<string, string|undefined>} [env]
18
+ * @returns {Paint}
19
+ */
20
+ export function makePaint(stream, env = process.env) {
21
+ const on = Boolean(stream && stream.isTTY) && !(env.NO_COLOR ?? '') && env.TERM !== 'dumb';
22
+ if (!on) return { on, grey: id, green: id, yellow: id, red: id, cyan: id, boldCyan: id };
23
+ return { on, grey: wrap('90'), green: wrap('32'), yellow: wrap('33'), red: wrap('31'), cyan: wrap('36'), boldCyan: wrap('1;36') };
24
+ }
25
+
26
+ /** the no-colour paint, the default when a caller passes none */
27
+ export const PLAIN = makePaint(undefined, {});
package/src/userrun.js CHANGED
@@ -1307,7 +1307,7 @@ async function execute(ctx) {
1307
1307
  if (RULING.decision !== 'rerun') {
1308
1308
  out(`\n${RULING.decision === 'accept' ? 'ACCEPTED' : 'PAUSED'} — the verdict this run minted (${doorRecord.outcome}) is UNTOUCHED; what you answered is what happens to the work.`);
1309
1309
  if (RULING.decision === 'accept') {
1310
- // Cross-run learnings: the human Accept is what releases a held rubric run's lessons so they count (forward only; a rerun or a
1310
+ // Cross-run learnings: the human Accept releases a hold an older version wrote for a rubric run (new runs count at run end) (forward only; a rerun or a
1311
1311
  // pause never does). Its own try/catch: a record, never a verdict.
1312
1312
  if (cfgOn && DOOR !== null) {
1313
1313
  try { releaseRun({ home: cfgHome, runid: DOOR.includes('/') || DOOR.endsWith('.jsonl') ? basename(DOOR).replace(/^u-/, '').replace(/\.jsonl$/, '') : DOOR }); } catch (/** @type {any} */ e) { err(`WARNING: could not release this run's lessons in the learnings ledger (${redactSecrets(String(e?.message ?? e)).split('\n')[0]}).`); }
@@ -2025,7 +2025,7 @@ async function execute(ctx) {
2025
2025
  } catch (/** @type {any} */ e) { err(`WARNING: could not settle this run's claim in ~/.config/bareloop/runs.jsonl (${e.message}) — the next run settles it once this process is gone.`); }
2026
2026
  }
2027
2027
  // The run-end tally of cross-run learnings ($0, code only, no model): the whole spine (all legs) and the gate audit become cards
2028
- // folded into the ONE ledger (a rubric job's are held until the human Accept). Its own try/catch, like the settle above: a
2028
+ // folded into the ONE ledger (a rubric job's count at run end too). Its own try/catch, like the settle above: a
2029
2029
  // record, never a verdict — a failure prints one WARNING line and can never change the outcome, the exit code or the spend.
2030
2030
  // `execute` is shared by the panel, the CLI and the bundle runner, so this is the ONE call site. It runs with the hints setting
2031
2031
  // on or off (OFF only means the planner is handed nothing). With no config home (a test with an injected env) it writes nothing.
package/types/cli.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * code, so `bin/bareloop.mjs` can set `process.exitCode` and let node flush
4
4
  * queued stdout on its own (F-something: `process.exit()` can discard it).
5
5
  * @param {string[]} argv
6
- * @param {{ env?: Record<string,string|undefined>, stdout?: any, stderr?: any, cwd?: string, provider?: any, providerFor?: any, judgeProvider?: any, judgeModel?: string|null, now?: () => number, stdin?: any, runlistHome?: string, keysHome?: string }} deps
6
+ * @param {{ env?: Record<string,string|undefined>, stdout?: any, stderr?: any, cwd?: string, provider?: any, providerFor?: any, judgeProvider?: any, judgeModel?: string|null, now?: () => number, stdin?: any, runlistHome?: string, keysHome?: string, processEnv?: Record<string,string|undefined> }} deps
7
7
  * @returns {Promise<number>}
8
8
  */
9
9
  export function main(argv: string[], deps?: {
@@ -19,4 +19,5 @@ export function main(argv: string[], deps?: {
19
19
  stdin?: any;
20
20
  runlistHome?: string;
21
21
  keysHome?: string;
22
+ processEnv?: Record<string, string | undefined>;
22
23
  }): Promise<number>;
@@ -0,0 +1,234 @@
1
+ /**
2
+ * @typedef {object} Panel one answered probe
3
+ * @property {number} port @property {number} pid @property {string} version @property {number} drafts @property {number} schedules
4
+ * @property {string} url
5
+ */
6
+ /**
7
+ * @typedef {object} LauncherDeps every OS touch, injectable (a test passes all of them)
8
+ * @property {string} [home] the bareloop home (`~/.config/bareloop`); undefined = the real one
9
+ * @property {number} [portStart] @property {number} [portCount]
10
+ * @property {number[]} [ports] an explicit port list (tests); wins over portStart/portCount
11
+ * @property {number} [timeoutMs] probe timeout per port
12
+ * @property {number} [deadlineMs] start/stop wait deadline
13
+ * @property {string} [nodeVersion] default process.versions.node
14
+ * @property {typeof fetch} [fetchImpl]
15
+ * @property {(cmd: string, args: string[], o: object) => any} [spawnFn] default node:child_process spawn
16
+ * @property {(pid: number, sig: string) => void} [kill] default process.kill
17
+ * @property {((target: string) => Promise<boolean>)} [open] default the shared OS opener
18
+ * @property {(port: number) => Promise<boolean>} [portFree] default: try to bind 127.0.0.1:port
19
+ * @property {(ms: number) => Promise<void>} [sleep]
20
+ * @property {(question: string) => Promise<string>} [ask] the y/N and menu reader
21
+ * @property {string} [bareloopBin] default bin/bareloop.mjs of this package
22
+ * @property {import('./tty-colour.js').Paint} [paint] colour for stdout lines (default none); {@link makePaint} decides TTY / NO_COLOR / TERM
23
+ * @property {import('./tty-colour.js').Paint} [paintErr] colour for stderr lines (default: `paint`)
24
+ * @property {number} [now] ms clock for the run list's age rules (default: real time); tests pass their own
25
+ * @property {string} [platform]
26
+ * @property {Record<string, string|undefined>} [env] raw env handed to the panel child (default process.env)
27
+ */
28
+ /**
29
+ * The run counts, from the panel's own `listRuns` rows. `status.key === 'running'` is the panel's word; `needs` is the one tag
30
+ * {@link import('./panel/server.js').needsStateFor} decides. A part that is 0 still shows.
31
+ * @param {any[]} rows
32
+ * @param {number} [gaveUp] schedules that gave up: the panel's Needs-you holds them too
33
+ * @returns {{running: number, needYou: number, total: number}}
34
+ */
35
+ export function countRuns(rows: any[], gaveUp?: number): {
36
+ running: number;
37
+ needYou: number;
38
+ total: number;
39
+ };
40
+ /**
41
+ * "installed ✓" = the folder exists, config.json is there and reads with no problem, `.env` exists with mode 600, node >= 22.
42
+ * Otherwise the FIRST missing thing in plain words.
43
+ * @param {{home?: string, nodeVersion?: string}} o
44
+ * @returns {{ok: boolean, problem: string|null}}
45
+ */
46
+ export function installState(o: {
47
+ home?: string;
48
+ nodeVersion?: string;
49
+ }): {
50
+ ok: boolean;
51
+ problem: string | null;
52
+ };
53
+ /**
54
+ * Ask 127.0.0.1 across the ports in parallel; a reply with `app === "bareloop"` is a panel. Never throws.
55
+ * @param {{ports: number[], timeoutMs?: number, fetchImpl?: typeof fetch}} o
56
+ * @returns {Promise<Panel[]>} in port order
57
+ */
58
+ export function probePanels(o: {
59
+ ports: number[];
60
+ timeoutMs?: number;
61
+ fetchImpl?: typeof fetch;
62
+ }): Promise<Panel[]>;
63
+ /**
64
+ * The header, ONCE per call: the two lines every `bareloop` command prints first, plus the panels the probe found (the commands
65
+ * reuse them, so nothing is probed twice).
66
+ * @param {LauncherDeps & {version?: string}} d
67
+ * @returns {Promise<{lines: string[], panels: Panel[]}>}
68
+ */
69
+ export function buildHeader(d: LauncherDeps & {
70
+ version?: string;
71
+ }): Promise<{
72
+ lines: string[];
73
+ panels: Panel[];
74
+ }>;
75
+ /**
76
+ * @param {LauncherDeps & {version?: string}} d
77
+ * @returns {Promise<string[]>} the two header lines
78
+ */
79
+ export function headerLines(d: LauncherDeps & {
80
+ version?: string;
81
+ }): Promise<string[]>;
82
+ /**
83
+ * `bareloop install` — creates only what is missing, never reads a key value.
84
+ * @param {{out: (s: string) => void, err: (s: string) => void}} io @param {LauncherDeps & {version?: string}} d @param {{lines: string[], panels: Panel[]}} [shown]
85
+ * @returns {Promise<number>}
86
+ */
87
+ export function installCmd(io: {
88
+ out: (s: string) => void;
89
+ err: (s: string) => void;
90
+ }, d: LauncherDeps & {
91
+ version?: string;
92
+ }, shown?: {
93
+ lines: string[];
94
+ panels: Panel[];
95
+ }): Promise<number>;
96
+ /**
97
+ * `bareloop status` — the header, then one line per panel (or `Panel not running.`). Exit 0 running, 3 not running; 0 when the menu ran it.
98
+ * @param {{out: (s: string) => void}} io @param {LauncherDeps & {version?: string}} d @param {{lines: string[], panels: Panel[]}} [shown] header the menu already printed
99
+ * @returns {Promise<number>}
100
+ */
101
+ export function statusCmd(io: {
102
+ out: (s: string) => void;
103
+ }, d: LauncherDeps & {
104
+ version?: string;
105
+ }, shown?: {
106
+ lines: string[];
107
+ panels: Panel[];
108
+ }): Promise<number>;
109
+ /**
110
+ * `bareloop start` — find a running panel first; else start one detached on the first free port 4700..4709, wait until it answers,
111
+ * open the page.
112
+ * @param {{out: (s: string) => void, err: (s: string) => void}} io @param {LauncherDeps & {version?: string}} d @param {{lines: string[], panels: Panel[]}} [shown]
113
+ * @returns {Promise<number>}
114
+ */
115
+ export function startCmd(io: {
116
+ out: (s: string) => void;
117
+ err: (s: string) => void;
118
+ }, d: LauncherDeps & {
119
+ version?: string;
120
+ }, shown?: {
121
+ lines: string[];
122
+ panels: Panel[];
123
+ }): Promise<number>;
124
+ /**
125
+ * `bareloop stop [--yes]` — SIGTERM the pid of EVERY panel the probe finds (one panel at a time is the rule; no "which one"), wait until each
126
+ * port stops answering. Drafts are in memory: warn (summed over all found panels) and ask y/N unless `--yes`.
127
+ * @param {string[]} args
128
+ * @param {{out: (s: string) => void, err: (s: string) => void}} io @param {LauncherDeps & {version?: string}} d @param {{lines: string[], panels: Panel[]}} [shown]
129
+ * @returns {Promise<number>}
130
+ */
131
+ export function stopCmd(args: string[], io: {
132
+ out: (s: string) => void;
133
+ err: (s: string) => void;
134
+ }, d: LauncherDeps & {
135
+ version?: string;
136
+ }, shown?: {
137
+ lines: string[];
138
+ panels: Panel[];
139
+ }): Promise<number>;
140
+ /**
141
+ * The bare `bareloop` menu: header, the four choices, one question. Runs the chosen command and returns its exit code.
142
+ * @param {{out: (s: string) => void, err: (s: string) => void}} io @param {LauncherDeps & {version?: string}} d
143
+ * @returns {Promise<number>}
144
+ */
145
+ export function menuCmd(io: {
146
+ out: (s: string) => void;
147
+ err: (s: string) => void;
148
+ }, d: LauncherDeps & {
149
+ version?: string;
150
+ }): Promise<number>;
151
+ /** the first port tried, and how many in a row (4700..4709) */
152
+ export const PORT_START: 4700;
153
+ export const PORT_COUNT: 10;
154
+ /**
155
+ * one answered probe
156
+ */
157
+ export type Panel = {
158
+ port: number;
159
+ pid: number;
160
+ version: string;
161
+ drafts: number;
162
+ schedules: number;
163
+ url: string;
164
+ };
165
+ /**
166
+ * every OS touch, injectable (a test passes all of them)
167
+ */
168
+ export type LauncherDeps = {
169
+ /**
170
+ * the bareloop home (`~/.config/bareloop`); undefined = the real one
171
+ */
172
+ home?: string | undefined;
173
+ portStart?: number | undefined;
174
+ portCount?: number | undefined;
175
+ /**
176
+ * an explicit port list (tests); wins over portStart/portCount
177
+ */
178
+ ports?: number[] | undefined;
179
+ /**
180
+ * probe timeout per port
181
+ */
182
+ timeoutMs?: number | undefined;
183
+ /**
184
+ * start/stop wait deadline
185
+ */
186
+ deadlineMs?: number | undefined;
187
+ /**
188
+ * default process.versions.node
189
+ */
190
+ nodeVersion?: string | undefined;
191
+ fetchImpl?: typeof fetch | undefined;
192
+ /**
193
+ * default node:child_process spawn
194
+ */
195
+ spawnFn?: ((cmd: string, args: string[], o: object) => any) | undefined;
196
+ /**
197
+ * default process.kill
198
+ */
199
+ kill?: ((pid: number, sig: string) => void) | undefined;
200
+ /**
201
+ * default the shared OS opener
202
+ */
203
+ open?: ((target: string) => Promise<boolean>) | undefined;
204
+ /**
205
+ * default: try to bind 127.0.0.1:port
206
+ */
207
+ portFree?: ((port: number) => Promise<boolean>) | undefined;
208
+ sleep?: ((ms: number) => Promise<void>) | undefined;
209
+ /**
210
+ * the y/N and menu reader
211
+ */
212
+ ask?: ((question: string) => Promise<string>) | undefined;
213
+ /**
214
+ * default bin/bareloop.mjs of this package
215
+ */
216
+ bareloopBin?: string | undefined;
217
+ /**
218
+ * colour for stdout lines (default none); {@link makePaint} decides TTY / NO_COLOR / TERM
219
+ */
220
+ paint?: import("./tty-colour.js").Paint | undefined;
221
+ /**
222
+ * colour for stderr lines (default: `paint`)
223
+ */
224
+ paintErr?: import("./tty-colour.js").Paint | undefined;
225
+ /**
226
+ * ms clock for the run list's age rules (default: real time); tests pass their own
227
+ */
228
+ now?: number | undefined;
229
+ platform?: string | undefined;
230
+ /**
231
+ * raw env handed to the panel child (default process.env)
232
+ */
233
+ env?: Record<string, string | undefined> | undefined;
234
+ };