bareloop 0.34.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/bareloop.context.md +276 -20
  3. package/package.json +1 -1
  4. package/src/authorrun.js +3 -2
  5. package/src/bundlerun.js +17 -12
  6. package/src/draftspend.js +197 -0
  7. package/src/exits.js +10 -1
  8. package/src/index.js +3 -3
  9. package/src/interviewrun.js +7 -7
  10. package/src/job.js +37 -0
  11. package/src/judged.js +2 -2
  12. package/src/kinds.js +2 -1
  13. package/src/ledger.js +101 -5
  14. package/src/legs.js +171 -0
  15. package/src/monthly.js +111 -72
  16. package/src/movefile.js +22 -0
  17. package/src/npminstall.js +55 -0
  18. package/src/panel/authorroutes.js +84 -14
  19. package/src/panel/authorsession.js +490 -125
  20. package/src/panel/importroutes.js +359 -0
  21. package/src/panel/importrun.js +203 -0
  22. package/src/panel/index.html +1194 -213
  23. package/src/panel/runroutes.js +219 -0
  24. package/src/panel/server.js +964 -79
  25. package/src/panel/settingsroutes.js +52 -2
  26. package/src/panel/status.js +51 -0
  27. package/src/planrun.js +60 -26
  28. package/src/providerrows.js +17 -0
  29. package/src/ralph.js +4 -1
  30. package/src/replay.js +139 -19
  31. package/src/replayio.js +11 -4
  32. package/src/reuse.js +20 -3
  33. package/src/run.js +6 -3
  34. package/src/runlist.js +75 -7
  35. package/src/source.js +43 -4
  36. package/src/text.js +20 -0
  37. package/src/u-patient.js +3 -2
  38. package/src/u-readout.js +9 -1
  39. package/src/u-watchdog.mjs +4 -2
  40. package/src/userrun.js +192 -65
  41. package/src/worktree.js +120 -0
  42. package/types/draftspend.d.ts +141 -0
  43. package/types/job.d.ts +25 -0
  44. package/types/ledger.d.ts +44 -0
  45. package/types/legs.d.ts +103 -0
  46. package/types/monthly.d.ts +23 -18
  47. package/types/movefile.d.ts +9 -0
  48. package/types/npminstall.d.ts +30 -0
  49. package/types/panel/authorroutes.d.ts +23 -1
  50. package/types/panel/authorsession.d.ts +110 -2
  51. package/types/panel/importroutes.d.ts +129 -0
  52. package/types/panel/importrun.d.ts +77 -0
  53. package/types/panel/runroutes.d.ts +32 -0
  54. package/types/panel/server.d.ts +266 -2
  55. package/types/panel/settingsroutes.d.ts +6 -1
  56. package/types/panel/status.d.ts +51 -0
  57. package/types/planrun.d.ts +13 -21
  58. package/types/providerrows.d.ts +12 -0
  59. package/types/ralph.d.ts +19 -0
  60. package/types/replay.d.ts +25 -1
  61. package/types/replayio.d.ts +2 -2
  62. package/types/reuse.d.ts +5 -0
  63. package/types/run.d.ts +6 -2
  64. package/types/runlist.d.ts +52 -2
  65. package/types/source.d.ts +13 -5
  66. package/types/text.d.ts +19 -0
  67. package/types/userrun.d.ts +10 -0
  68. package/types/worktree.d.ts +48 -0
@@ -0,0 +1,219 @@
1
+ // PANEL-BUILD.md P5 — the run-level write routes. Item 2: `POST /api/runs/:runid/resume`.
2
+ //
3
+ // Layering: this is one more CALLER of the same engine the CLI drives (`bareloop run-u
4
+ // --resume`); the panel adds no logic of its own beyond reading two caps off a form and
5
+ // spawning. Every route here sits behind `checkHumanGuard` (token + own address), the
6
+ // same guard `/api/author/*` has: the click IS the signature, the chat can never reach it.
7
+ //
8
+ // A resume under RAISED caps is a re-signed spec (budgetUsd/maxWallMs are in the hash —
9
+ // that is how a top-up works, src/userrun.js "THE TOP-UP ITSELF IS NOT HERE"). The new spec
10
+ // is written beside the original as `resolved-spec-r<k>.json`; the signed spec the run
11
+ // started under is NEVER overwritten. Prior spend stays folded by the engine, so the
12
+ // ceiling cannot silently widen.
13
+
14
+ import { spawn as realSpawn } from 'node:child_process';
15
+ import {
16
+ openSync, closeSync, writeFileSync, readFileSync, readdirSync,
17
+ } from 'node:fs';
18
+ import { dirname, join } from 'node:path';
19
+ import { checkHumanGuard } from './authorroutes.js';
20
+ import { checkMonthlyRoom, monthlyRefusalText } from '../monthly.js';
21
+ import { ConfigError } from '../config.js';
22
+ import { keysForDoor } from '../keysfile.js';
23
+ import { jobSpecHash } from '../job.js';
24
+ import { stopFilePath } from '../legs.js';
25
+
26
+ /** the tail of an engine log shown to the person when the engine refuses at start (bytes) */
27
+ const LOG_TAIL_BYTES = 4000;
28
+
29
+ /** how long a resume waits for an early engine exit before reporting "started" (ms) */
30
+ export const DEFAULT_SETTLE_MS = 6000;
31
+
32
+ /**
33
+ * The next free `resolved-spec-r<k>.json` name in `dir` (k from 1) — never an existing one.
34
+ * @param {string} dir
35
+ * @returns {string}
36
+ */
37
+ function nextRevisionName(dir) {
38
+ let max = 0;
39
+ for (const n of readdirSync(dir)) {
40
+ const m = /^resolved-spec-r(\d+)\.json$/.exec(n);
41
+ if (m) max = Math.max(max, Number(m[1]));
42
+ }
43
+ return `resolved-spec-r${max + 1}.json`;
44
+ }
45
+
46
+ /**
47
+ * The tail of the engine's own log — its refusal text, verbatim.
48
+ * @param {string} file
49
+ * @returns {string}
50
+ */
51
+ function readLogTail(file) {
52
+ try {
53
+ const text = readFileSync(file, 'utf8');
54
+ return text.slice(Math.max(0, text.length - LOG_TAIL_BYTES)).trim();
55
+ } catch { return ''; }
56
+ }
57
+
58
+ /**
59
+ * The run routes' handler. `getResumeContext(runid)` is the server's own reader (the same
60
+ * `resumePlanFor` the Ended block uses, so the page never offers what this refuses):
61
+ * `null` = no such run, else `{row, plan}`.
62
+ * @param {{ port: number, token: string, env?: Record<string,string|undefined>, home?: string,
63
+ * spawnFn?: typeof realSpawn, bareloopBin?: string, settleMs?: number,
64
+ * getResumeContext: (runid: string) => ({row: any, plan: any}|null),
65
+ * getStopContext?: (runid: string) => ({row: any, live: boolean, spineExists: boolean}|null) }} opts
66
+ */
67
+ export function createRunRoutes(opts) {
68
+ const envNow = () => keysForDoor({ env: opts.env, keysHome: opts.home }).env;
69
+ const spawnFn = opts.spawnFn ?? realSpawn;
70
+ const bareloopBin = opts.bareloopBin ?? new URL('../../bin/bareloop.mjs', import.meta.url).pathname;
71
+ const settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS;
72
+
73
+ /**
74
+ * P5 item 5 — `POST /api/runs/:runid/stop`. The click writes the run's STOP REQUEST file (`<spine>.stop`,
75
+ * `stopFilePath`); the ENGINE reads it at its round boundary (the seam where the money cap binds) and ends the leg `stopped` (resumable). The
76
+ * route never signals or kills anything. Refused (409) unless the run's LATEST leg is live — a stop request for
77
+ * a run that is not running would sit on disk and could be misread by a later leg (the engine also clears a
78
+ * stale one at leg start, but a button that cannot do anything is not offered or accepted).
79
+ * @param {import('node:http').IncomingMessage} req
80
+ * @param {(code: number, obj: any) => void} send
81
+ * @param {string} runid
82
+ * @returns {boolean}
83
+ */
84
+ function handleStop(req, send, runid) {
85
+ const guard = checkHumanGuard(req, { token: opts.token, port: opts.port });
86
+ if (!guard.ok) { send(403, { ok: false, error: `refused — ${guard.reason}` }); return true; }
87
+ if (req.method !== 'POST') { send(405, { ok: false, error: 'POST only' }); return true; }
88
+ const ctx = opts.getStopContext ? opts.getStopContext(runid) : null;
89
+ if (!ctx) { send(404, { ok: false, error: 'no such run' }); return true; }
90
+ if (!ctx.live) { send(409, { ok: false, error: 'This run is not running, so there is nothing to stop.' }); return true; }
91
+ if (!ctx.spineExists) { send(409, { ok: false, error: 'The run is still starting — it has not written its log yet. Try again in a moment.' }); return true; }
92
+ const file = stopFilePath(ctx.row.spine);
93
+ try { writeFileSync(file, `${new Date().toISOString()}\n`); } catch (e) {
94
+ send(500, { ok: false, error: `could not write the stop request: ${/** @type {Error} */ (e).message}` });
95
+ return true;
96
+ }
97
+ send(200, { ok: true, runid, stopping: true });
98
+ return true;
99
+ }
100
+
101
+ /**
102
+ * @param {import('node:http').IncomingMessage} req
103
+ * @param {import('node:http').ServerResponse} res
104
+ * @param {string} pathname
105
+ * @param {any} body
106
+ * @returns {boolean}
107
+ */
108
+ function handle(req, res, pathname, body) {
109
+ const stopM = /^\/api\/runs\/([A-Za-z0-9._~-]+)\/stop$/.exec(pathname);
110
+ const m = /^\/api\/runs\/([A-Za-z0-9._~-]+)\/resume$/.exec(pathname);
111
+ if (!m && !stopM) return false;
112
+ const send = (/** @type {number} */ code, /** @type {any} */ obj) => {
113
+ const text = JSON.stringify(obj);
114
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(text) });
115
+ res.end(text);
116
+ };
117
+ if (stopM) return handleStop(req, send, stopM[1]);
118
+ if (!m) return false;
119
+ const guard = checkHumanGuard(req, { token: opts.token, port: opts.port });
120
+ if (!guard.ok) { send(403, { ok: false, error: `refused — ${guard.reason}` }); return true; }
121
+ if (req.method !== 'POST') { send(405, { ok: false, error: 'POST only' }); return true; }
122
+
123
+ const ctx = opts.getResumeContext(m[1]);
124
+ if (!ctx) { send(404, { ok: false, error: 'no such run' }); return true; }
125
+ const { plan } = ctx;
126
+ if (!plan.ok) { send(409, { ok: false, error: `Resume is not available for this run (${plan.why}).` }); return true; }
127
+
128
+ // the caps: blank/absent = the signed ones. A raised (or lowered) cap is a new spec.
129
+ /** @type {any} */
130
+ const spec = { ...plan.spec };
131
+ const wantBudget = body?.budgetUsd === undefined || body?.budgetUsd === null || body?.budgetUsd === '' ? spec.budgetUsd : Number(body.budgetUsd);
132
+ if (typeof wantBudget !== 'number' || !Number.isFinite(wantBudget) || wantBudget <= 0) {
133
+ send(400, { ok: false, error: 'the money cap must be a number above 0' });
134
+ return true;
135
+ }
136
+ spec.budgetUsd = wantBudget;
137
+ if (typeof plan.spec.maxWallMs === 'number') {
138
+ const wantMin = body?.maxWallMin === undefined || body?.maxWallMin === null || body?.maxWallMin === '' ? plan.spec.maxWallMs / 60000 : Number(body.maxWallMin);
139
+ if (!Number.isFinite(wantMin) || wantMin <= 0) { send(400, { ok: false, error: 'the time cap must be a number of minutes above 0' }); return true; }
140
+ spec.maxWallMs = Math.round(wantMin * 60000);
141
+ }
142
+ // tighten-only: a cap at or below what the run has ALREADY used buys an immediate re-halt
143
+ // (the engine only warns), so it is refused here at $0, before anything is written or spawned
144
+ if (typeof plan.spentUsd === 'number' && spec.budgetUsd <= plan.spentUsd) {
145
+ send(400, { ok: false, error: `The money cap must be above what is already spent (${plan.spendComplete ? '' : 'at least '}$${plan.spentUsd.toFixed(2)}) — raise it, then Resume.` });
146
+ return true;
147
+ }
148
+ if (typeof plan.wallUsedMs === 'number' && typeof spec.maxWallMs === 'number' && spec.maxWallMs <= plan.wallUsedMs) {
149
+ send(400, { ok: false, error: `The time cap must be above the time already used (${Math.ceil(plan.wallUsedMs / 60000)} min) — raise it, then Resume.` });
150
+ return true;
151
+ }
152
+ const changed = spec.budgetUsd !== plan.spec.budgetUsd || spec.maxWallMs !== plan.spec.maxWallMs;
153
+
154
+ // the monthly $ limit refuses here too, before anything is spawned — same library call
155
+ // and text as the run-start seam and Sign & run
156
+ try {
157
+ const refusal = monthlyRefusalText(checkMonthlyRoom({ capUsd: Number(spec.budgetUsd), home: opts.home }));
158
+ if (refusal !== null) { send(400, { ok: false, error: refusal }); return true; }
159
+ } catch (e) {
160
+ if (e instanceof ConfigError) { send(400, { ok: false, error: `${e.message} — refusing rather than guess the monthly limit` }); return true; }
161
+ throw e;
162
+ }
163
+
164
+ const dir = dirname(plan.specPath);
165
+ let specPath = plan.specPath;
166
+ let specHash = plan.specHash;
167
+ if (changed) {
168
+ specPath = join(dir, nextRevisionName(dir));
169
+ specHash = jobSpecHash(spec);
170
+ writeFileSync(specPath, `${JSON.stringify(spec, null, 2)}\n`, { flag: 'wx' });
171
+ }
172
+
173
+ const logFile = join(dir, `resume-${m[1]}-${Date.now()}.log`);
174
+ const args = [
175
+ '--wait', 'systemd-inhibit', '--why=bareloop panel resume',
176
+ process.execPath, bareloopBin,
177
+ 'run-u', '--spec', specPath, '--resume', m[1], '--approve', specHash,
178
+ // the drafting fold rides every leg of the chain (a leg that dropped it would widen the ceiling)
179
+ ...(plan.draftSpentUsd !== null ? ['--draft-spent-usd', String(plan.draftSpentUsd), ...(plan.draftSpendComplete === false ? ['--draft-spend-incomplete'] : [])] : []),
180
+ ];
181
+ /** @type {number|null} */
182
+ let logFd = null;
183
+ try { logFd = openSync(logFile, 'a'); } catch { logFd = null; }
184
+ const child = spawnFn('setsid', args, {
185
+ detached: true, stdio: ['ignore', logFd ?? 'ignore', logFd ?? 'ignore'], env: envNow(),
186
+ });
187
+ if (logFd !== null) { try { closeSync(logFd); } catch { /* the child holds its own copy */ } }
188
+ if (typeof child?.unref === 'function') child.unref();
189
+
190
+ // `setsid --wait` hands back the engine's own exit code, so an early non-zero exit IS
191
+ // the engine's refusal: its own text (the log) goes to the page, never a paraphrase.
192
+ // A run that is really going does not exit inside the window.
193
+ let done = false;
194
+ const finish = (/** @type {number} */ code, /** @type {any} */ obj) => { if (!done) { done = true; send(code, obj); } };
195
+ const timer = setTimeout(() => finish(200, {
196
+ ok: true, runid: m[1], specHash, capsChanged: changed, log: logFile,
197
+ }), settleMs);
198
+ if (typeof timer.unref === 'function') timer.unref();
199
+ if (typeof child?.once === 'function') {
200
+ child.once('exit', (/** @type {number|null} */ code) => {
201
+ clearTimeout(timer);
202
+ if (code === 0) {
203
+ finish(200, {
204
+ ok: true, runid: m[1], specHash, capsChanged: changed, log: logFile,
205
+ });
206
+ } else {
207
+ finish(409, { ok: false, error: readLogTail(logFile) || `the engine refused to resume (exit ${code}) and left no message`, log: logFile });
208
+ }
209
+ });
210
+ child.once('error', (/** @type {Error} */ e) => {
211
+ clearTimeout(timer);
212
+ finish(500, { ok: false, error: `could not start the engine: ${e.message}` });
213
+ });
214
+ }
215
+ return true;
216
+ }
217
+
218
+ return { handle };
219
+ }