bareloop 0.31.0 → 0.32.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 +72 -0
- package/bareloop.context.md +87 -13
- package/bin/bareloop.mjs +0 -1
- package/package.json +1 -1
- package/src/authorrun.js +14 -4
- package/src/cli.js +12 -6
- package/src/config.js +135 -0
- package/src/interviewrun.js +10 -2
- package/src/keysfile.js +156 -0
- package/src/ledger.js +26 -0
- package/src/monthly.js +352 -0
- package/src/panel/authorroutes.js +61 -16
- package/src/panel/authorsession.js +23 -25
- package/src/panel/index.html +287 -13
- package/src/panel/server.js +58 -51
- package/src/panel/settingsroutes.js +230 -0
- package/src/providerrows.js +217 -0
- package/src/providers.js +3 -1
- package/src/runlist.js +13 -0
- package/src/userrun.js +59 -5
- package/types/authorrun.d.ts +5 -0
- package/types/cli.d.ts +2 -1
- package/types/config.d.ts +48 -0
- package/types/interviewrun.d.ts +5 -0
- package/types/keysfile.d.ts +90 -0
- package/types/ledger.d.ts +14 -0
- package/types/monthly.d.ts +192 -0
- package/types/panel/authorroutes.d.ts +4 -2
- package/types/panel/authorsession.d.ts +7 -15
- package/types/panel/server.d.ts +10 -3
- package/types/panel/settingsroutes.d.ts +14 -0
- package/types/providerrows.d.ts +164 -0
- package/types/runlist.d.ts +1 -0
- package/types/userrun.d.ts +5 -0
|
@@ -21,8 +21,13 @@ import { randomBytes } from 'node:crypto';
|
|
|
21
21
|
import { spawn as realSpawn } from 'node:child_process';
|
|
22
22
|
import { openSync } from 'node:fs';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
24
|
+
import { readFileSync } from 'node:fs';
|
|
25
|
+
import { createSession, validateJobCard } from './authorsession.js';
|
|
26
|
+
import { checkMonthlyRoom, monthlyRefusalText } from '../monthly.js';
|
|
27
|
+
import { ConfigError } from '../config.js';
|
|
28
|
+
import { keysForDoor, keysHome } from '../keysfile.js';
|
|
29
|
+
import { keyNameFor, modelChoiceFor, rowsForHome, chatModels, usableKey } from '../providerrows.js';
|
|
30
|
+
import { apiKeyProblem, checkProviderReachable } from '../providers.js';
|
|
26
31
|
|
|
27
32
|
/** @returns {string} a fresh per-process token — never persisted, never logged */
|
|
28
33
|
export function mintToken() {
|
|
@@ -68,10 +73,12 @@ const TERMINAL_PHASES = new Set(['refused', 'abandoned', 'error', 'signed', 'sig
|
|
|
68
73
|
* deliberately has none of).
|
|
69
74
|
* @param {{ port: number, token: string, env?: Record<string,string|undefined>,
|
|
70
75
|
* sessionsRoot?: string, spawnFn?: typeof realSpawn, bareloopBin?: string,
|
|
71
|
-
* jobsDir?: string, fetchImpl?: typeof fetch }} opts
|
|
76
|
+
* jobsDir?: string, fetchImpl?: typeof fetch, home?: string }} opts
|
|
72
77
|
*/
|
|
73
78
|
export function createAuthorRoutes(opts) {
|
|
74
|
-
|
|
79
|
+
// the RAW env with the keys file re-merged on every use, so an edited file takes effect
|
|
80
|
+
// without a restart (never a start-time snapshot). An injected env with no `home` skips the file.
|
|
81
|
+
const envNow = () => keysForDoor({ env: opts.env, keysHome: opts.home }).env;
|
|
75
82
|
const spawnFn = opts.spawnFn ?? realSpawn;
|
|
76
83
|
const bareloopBin = opts.bareloopBin ?? new URL('../../bin/bareloop.mjs', import.meta.url).pathname;
|
|
77
84
|
// TEST SEAM ONLY: a test injects a fake `fetch`-shaped function so the
|
|
@@ -111,17 +118,17 @@ export function createAuthorRoutes(opts) {
|
|
|
111
118
|
if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
|
|
112
119
|
const q = new URL(/** @type {string} */ (req.url), 'http://127.0.0.1').searchParams;
|
|
113
120
|
const modelId = q.get('model') ?? '';
|
|
114
|
-
const
|
|
121
|
+
const rows = rowsForHome(keysHome(opts.home));
|
|
122
|
+
const choice = modelChoiceFor(rows, modelId);
|
|
115
123
|
if (!choice) { send(400, { ok: false, error: `unknown model "${modelId}"` }); return true; }
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const raw = env[providerEntry.envKey];
|
|
124
|
+
// the key of the Settings row this Name belongs to
|
|
125
|
+
const keyName = keyNameFor(choice.provider, choice.baseUrl, rows, choice.name).name;
|
|
126
|
+
const raw = envNow()[keyName];
|
|
120
127
|
const problem = raw ? apiKeyProblem(raw) : null;
|
|
121
128
|
const keyStatus = !raw ? 'missing' : (problem ? 'bad-shape' : 'found');
|
|
122
129
|
if (keyStatus !== 'found') {
|
|
123
130
|
send(200, {
|
|
124
|
-
ok: true, envKey:
|
|
131
|
+
ok: true, envKey: keyName, keyStatus, keyProblem: problem,
|
|
125
132
|
reachability: { checked: false, reachable: null, modelListed: null, status: null, note: 'no usable key to check reachability with' },
|
|
126
133
|
});
|
|
127
134
|
return true;
|
|
@@ -130,12 +137,12 @@ export function createAuthorRoutes(opts) {
|
|
|
130
137
|
// `checkProviderReachable`'s own doc) — a short timeout so a flaky
|
|
131
138
|
// endpoint never hangs the card.
|
|
132
139
|
checkProviderReachable({
|
|
133
|
-
providerName: choice.provider, apiKey: /** @type {string} */ (raw), model:
|
|
140
|
+
providerName: choice.provider, apiKey: /** @type {string} */ (usableKey(raw)), model: choice.name,
|
|
134
141
|
baseUrl: choice.baseUrl, fetchImpl, timeoutMs: 4000,
|
|
135
142
|
}).then((r) => {
|
|
136
143
|
send(200, {
|
|
137
144
|
ok: true,
|
|
138
|
-
envKey:
|
|
145
|
+
envKey: keyName,
|
|
139
146
|
keyStatus,
|
|
140
147
|
keyProblem: null,
|
|
141
148
|
reachability: {
|
|
@@ -146,13 +153,39 @@ export function createAuthorRoutes(opts) {
|
|
|
146
153
|
return true;
|
|
147
154
|
}
|
|
148
155
|
|
|
156
|
+
// ── P4a: the monthly-limit note under the cap field. READ-ONLY, $0 — the same
|
|
157
|
+
// library check (`checkMonthlyRoom`, src/monthly.js) the run-start seam and the
|
|
158
|
+
// Sign refusal use, so the note the person sees is never a second opinion. The
|
|
159
|
+
// page is never the arbiter: Sign & run re-checks server-side regardless.
|
|
160
|
+
// ── P4b: Chat's Model menu IS the Settings rows (a blank Name is not offered). ──
|
|
161
|
+
if (pathname === '/api/author/models') {
|
|
162
|
+
if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
|
|
163
|
+
send(200, { ok: true, models: chatModels(rowsForHome(keysHome(opts.home))) });
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (pathname === '/api/author/monthly-check') {
|
|
168
|
+
if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
|
|
169
|
+
const q = new URL(/** @type {string} */ (req.url), 'http://127.0.0.1').searchParams;
|
|
170
|
+
const cap = Number(q.get('cap'));
|
|
171
|
+
if (!Number.isFinite(cap) || cap <= 0) { send(200, { ok: true, refusal: null }); return true; }
|
|
172
|
+
try {
|
|
173
|
+
const room = checkMonthlyRoom({ capUsd: cap, home: opts.home });
|
|
174
|
+
send(200, { ok: true, refusal: monthlyRefusalText(room), leftUsd: room.leftUsd, atLeast: room.atLeast });
|
|
175
|
+
} catch (e) {
|
|
176
|
+
if (!(e instanceof ConfigError)) throw e;
|
|
177
|
+
send(200, { ok: true, refusal: null, configProblem: e.message });
|
|
178
|
+
}
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
|
|
149
182
|
if (pathname === '/api/author/start') {
|
|
150
183
|
if (req.method !== 'POST') { send(405, { ok: false, error: 'POST only' }); return true; }
|
|
151
184
|
if (hasLiveSession()) { send(409, { ok: false, error: 'an authoring session is already live — one at a time' }); return true; }
|
|
152
185
|
const card = body ?? {};
|
|
153
|
-
const v = validateJobCard(card);
|
|
186
|
+
const v = validateJobCard(card, { jobsDir: opts.jobsDir, rows: rowsForHome(keysHome(opts.home)) });
|
|
154
187
|
if (!v.ok) { send(400, { ok: false, error: v.error }); return true; }
|
|
155
|
-
const session = createSession(card, { env, sessionsRoot: opts.sessionsRoot });
|
|
188
|
+
const session = createSession(card, { env: envNow(), sessionsRoot: opts.sessionsRoot, ...(opts.home !== undefined ? { home: opts.home } : {}) });
|
|
156
189
|
sessions.set(session.id, session);
|
|
157
190
|
send(200, { ok: true, sessionId: session.id, state: session.state });
|
|
158
191
|
return true;
|
|
@@ -192,7 +225,7 @@ export function createAuthorRoutes(opts) {
|
|
|
192
225
|
}
|
|
193
226
|
if (sub === 'sign') {
|
|
194
227
|
const claimedHash = String(body?.specHash ?? '');
|
|
195
|
-
const r = signRun(session, claimedHash, { env, spawnFn, bareloopBin, sessionsRoot: opts.sessionsRoot });
|
|
228
|
+
const r = signRun(session, claimedHash, { env: envNow(), spawnFn, bareloopBin, sessionsRoot: opts.sessionsRoot, home: opts.home });
|
|
196
229
|
send(r.ok ? 200 : 400, r);
|
|
197
230
|
return true;
|
|
198
231
|
}
|
|
@@ -234,13 +267,25 @@ function panelMoney2(n) {
|
|
|
234
267
|
* it to sign"), and this is where it is checked, not trusted.
|
|
235
268
|
* @param {ReturnType<typeof createSession>} session
|
|
236
269
|
* @param {string} claimedHash
|
|
237
|
-
* @param {{env: any, spawnFn: typeof realSpawn, bareloopBin: string, sessionsRoot?: string}} o
|
|
270
|
+
* @param {{env: any, spawnFn: typeof realSpawn, bareloopBin: string, sessionsRoot?: string, home?: string}} o
|
|
238
271
|
* @returns {{ok: boolean, error?: string, job?: string}}
|
|
239
272
|
*/
|
|
240
273
|
export function signRun(session, claimedHash, o) {
|
|
241
274
|
if (session.state.phase !== 'prepared') return { ok: false, error: `session is not prepared (phase=${session.state.phase}) — nothing to sign` };
|
|
242
275
|
if (!session.state.specHash || claimedHash !== session.state.specHash) return { ok: false, error: 'spec hash mismatch — refusing to sign' };
|
|
243
276
|
if (!session.state.resolvedSpecPath) return { ok: false, error: 'no resolved spec on disk — refusing to sign' };
|
|
277
|
+
// P4a — the monthly $ limit REFUSES here too, server-side, before anything is spawned
|
|
278
|
+
// (the page's note is a courtesy; this is the check). Same library call, same text as
|
|
279
|
+
// the run-start seam in src/userrun.js. The session stays `prepared`, so the person can
|
|
280
|
+
// lower nothing (the cap is signed in) but may raise the limit in Settings and sign again.
|
|
281
|
+
try {
|
|
282
|
+
const spec = JSON.parse(readFileSync(session.state.resolvedSpecPath, 'utf8'));
|
|
283
|
+
const refusal = monthlyRefusalText(checkMonthlyRoom({ capUsd: Number(spec.budgetUsd), home: o.home }));
|
|
284
|
+
if (refusal !== null) return { ok: false, error: refusal };
|
|
285
|
+
} catch (e) {
|
|
286
|
+
if (e instanceof ConfigError) return { ok: false, error: `${e.message} — refusing rather than guess the monthly limit` };
|
|
287
|
+
return { ok: false, error: 'could not read the signed spec to check the monthly limit — refusing to sign' };
|
|
288
|
+
}
|
|
244
289
|
const logFile = join(session.state.outDir, 'run.log');
|
|
245
290
|
// array argv, never a shell string — `--approve <hash>` is a literal
|
|
246
291
|
// element, checkable byte-for-byte by a test stubbing `spawnFn`, and there
|
|
@@ -20,10 +20,11 @@
|
|
|
20
20
|
// `src/panel/server.js`'s POST routes). Nothing here changes what a step
|
|
21
21
|
// means or what an answer does — only where the answer comes from.
|
|
22
22
|
|
|
23
|
+
import { applyConfiguredKey, keyNameFor, modelChoiceFor, rowsForHome } from '../providerrows.js';
|
|
23
24
|
import {
|
|
24
25
|
mkdirSync, existsSync, writeFileSync, readdirSync,
|
|
25
26
|
} from 'node:fs';
|
|
26
|
-
import { join, resolve } from 'node:path';
|
|
27
|
+
import { join, resolve, dirname } from 'node:path';
|
|
27
28
|
import {
|
|
28
29
|
prepareSource, proveDestination, missingDependencies, looksLikeRepoSource,
|
|
29
30
|
} from '../source.js';
|
|
@@ -46,20 +47,6 @@ import { closeJudges } from '../kinds.js';
|
|
|
46
47
|
import { redactSecrets } from '../validate.js';
|
|
47
48
|
import { tallyCalls } from '../text.js';
|
|
48
49
|
|
|
49
|
-
/**
|
|
50
|
-
* The job card's "Model" select — a fixed, small, source-grounded mapping
|
|
51
|
-
* from a display id to the `{provider, baseUrl?}` pair `src/interviewrun.js`
|
|
52
|
-
* itself documents (that script's own `--provider openai-api --base-url
|
|
53
|
-
* https://api.deepseek.com/v1` usage line for DeepSeek): the panel builds no
|
|
54
|
-
* provider table of its own, this is a UI convenience over the SAME two
|
|
55
|
-
* provider identities the CLI already resolves through `resolveProvider`.
|
|
56
|
-
* @type {Readonly<Record<string, {provider: string, baseUrl?: string}>>}
|
|
57
|
-
*/
|
|
58
|
-
export const MODEL_OPTIONS = Object.freeze({
|
|
59
|
-
'claude-sonnet-5': Object.freeze({ provider: 'anthropic-api' }),
|
|
60
|
-
'deepseek-flash': Object.freeze({ provider: 'openai-api', baseUrl: 'https://api.deepseek.com/v1' }),
|
|
61
|
-
});
|
|
62
|
-
|
|
63
50
|
/**
|
|
64
51
|
* Plain-language labels for the library's own `onPhase(name, …)` calls
|
|
65
52
|
* (`src/authorjob.js`/`src/authorflow.js`) — build item 3's progress
|
|
@@ -126,17 +113,18 @@ export function jobNameTaken(name, opts = {}) {
|
|
|
126
113
|
* creates a session or spends anything — never inside the async pipeline,
|
|
127
114
|
* so a bad card refuses synchronously, in the same HTTP response.
|
|
128
115
|
* @param {any} card
|
|
116
|
+
* @param {{jobsDir?: string, rows?: readonly import('../providerrows.js').KeyRow[]}} [opts] `rows` = the Settings rows (the Model menu); jobsDir is a server-side test seam — NEVER read off the client card
|
|
129
117
|
* @returns {{ok: true}|{ok: false, error: string}}
|
|
130
118
|
*/
|
|
131
|
-
export function validateJobCard(card) {
|
|
119
|
+
export function validateJobCard(card, opts = {}) {
|
|
132
120
|
if (!card || typeof card !== 'object') return { ok: false, error: 'missing job card' };
|
|
133
121
|
const { jobName, checkType, model, goal, source, destination, success, guardrails, capUsd } = card;
|
|
134
122
|
if (!isSlug(jobName)) return { ok: false, error: 'Job name must be a kebab-case slug (letters, digits, dashes)' };
|
|
135
|
-
if (jobNameTaken(jobName,
|
|
123
|
+
if (jobNameTaken(jobName, { jobsDir: opts.jobsDir })) {
|
|
136
124
|
return { ok: false, error: `a job named "${jobName}" already exists — P3 is new jobs only (Q4=A)` };
|
|
137
125
|
}
|
|
138
126
|
if (checkType !== 'deterministic' && checkType !== 'rubric') return { ok: false, error: 'Check type must be deterministic or rubric' };
|
|
139
|
-
if (!
|
|
127
|
+
if (!modelChoiceFor(opts.rows ?? [], model)) return { ok: false, error: 'Model must be one of the Names in Settings > Providers' };
|
|
140
128
|
for (const [field, label] of [[goal, 'Goal'], [source, 'Source'], [destination, 'Destination'], [success, 'Success'], [guardrails, 'Guardrails']]) {
|
|
141
129
|
if (typeof field !== 'string' || field.trim() === '') return { ok: false, error: `${label} is required` };
|
|
142
130
|
}
|
|
@@ -155,7 +143,7 @@ export function validateJobCard(card) {
|
|
|
155
143
|
* is the ONE thing a person answers next; `answer()`/`send()`/`revise()`/
|
|
156
144
|
* `signPrepare()` are the only ways in.
|
|
157
145
|
* @param {any} card the validated job card (see {@link validateJobCard})
|
|
158
|
-
* @param {{env?: Record<string,string|undefined>, sessionsRoot?: string, timeoutMs?: number,
|
|
146
|
+
* @param {{env?: Record<string,string|undefined>, sessionsRoot?: string, home?: string, timeoutMs?: number,
|
|
159
147
|
* scout?: any, generate?: Function, confirmGenerate?: Function, authorFn?: Function,
|
|
160
148
|
* prepareSigningFn?: Function}} [deps] the last five are TEST SEAMS ONLY — see the note
|
|
161
149
|
* just above where each is read, below.
|
|
@@ -164,6 +152,8 @@ export function validateJobCard(card) {
|
|
|
164
152
|
export function createSession(card, deps = {}) {
|
|
165
153
|
const env = deps.env ?? process.env;
|
|
166
154
|
const sessionsRoot = deps.sessionsRoot ?? join(process.env.HOME ?? '/tmp', '.config', 'bareloop', 'panel-sessions');
|
|
155
|
+
// config.json lives beside the sessions dir (~/.config/bareloop) unless a home is injected
|
|
156
|
+
const configHome = deps.home ?? dirname(sessionsRoot);
|
|
167
157
|
const timeoutMs = deps.timeoutMs ?? 300_000;
|
|
168
158
|
// TEST SEAMS ONLY (never set by `src/panel/authorroutes.js`'s real caller):
|
|
169
159
|
// override the declaration composer and/or `prepareSigning` itself so a
|
|
@@ -309,13 +299,19 @@ export function createSession(card, deps = {}) {
|
|
|
309
299
|
|
|
310
300
|
async function run() {
|
|
311
301
|
// ── keys, at $0, before anything is prepared ────────────────────────
|
|
312
|
-
const
|
|
302
|
+
const keyRows = rowsForHome(configHome);
|
|
303
|
+
const modelChoice = modelChoiceFor(keyRows, card.model);
|
|
304
|
+
if (!modelChoice) { refuse(`Model "${card.model}" is not a Name in Settings > Providers. Stopped — nothing spent.`); return; }
|
|
313
305
|
let providerEntry;
|
|
314
306
|
try { providerEntry = resolveProvider(modelChoice.provider); } catch (e) { refuse(/** @type {Error} */ (e).message); return; }
|
|
315
|
-
|
|
316
|
-
|
|
307
|
+
// P4a item 4 — the key variable the person picked in Settings stands in for the built-in one
|
|
308
|
+
const keyCfg = keyRows;
|
|
309
|
+
const draftEnv = applyConfiguredKey(env, modelChoice.provider, modelChoice.baseUrl, keyCfg, modelChoice.name);
|
|
310
|
+
const draftKeyName = keyNameFor(modelChoice.provider, modelChoice.baseUrl, keyCfg, modelChoice.name).name;
|
|
311
|
+
const apiKey = draftEnv[providerEntry.envKey];
|
|
312
|
+
if (!apiKey) { refuse(`${draftKeyName} not set. Stopped — nothing spent.`); return; }
|
|
317
313
|
const keyProblem = apiKeyProblem(apiKey);
|
|
318
|
-
if (keyProblem) { refuse(`${
|
|
314
|
+
if (keyProblem) { refuse(`${draftKeyName} ${keyProblem}. Stopped — nothing spent.`); return; }
|
|
319
315
|
|
|
320
316
|
const verdictType = card.checkType === 'rubric' ? 'soft-green' : 'green';
|
|
321
317
|
const into = join(outDir, 'source-seed');
|
|
@@ -389,6 +385,8 @@ export function createSession(card, deps = {}) {
|
|
|
389
385
|
description: `${card.jobName} — authored through the bareloop panel (${verdictType}, ${LANG})`,
|
|
390
386
|
provider: modelChoice.provider,
|
|
391
387
|
...(modelChoice.baseUrl ? { baseUrl: modelChoice.baseUrl } : {}),
|
|
388
|
+
// the Name is the model id: signed into the spec only when it is not the provider's own default
|
|
389
|
+
...(modelChoice.name !== providerEntry.tiers.sonnet ? { model: modelChoice.name } : {}),
|
|
392
390
|
cadence: { unit: 'day', every: 1 },
|
|
393
391
|
budgetUsd: card.capUsd,
|
|
394
392
|
...(typeof card.maxWallMs === 'number' ? { maxWallMs: card.maxWallMs } : {}),
|
|
@@ -414,7 +412,7 @@ export function createSession(card, deps = {}) {
|
|
|
414
412
|
const draftJudge = (() => {
|
|
415
413
|
try { return resolveJobJudge(draft, modelChoice.provider, resolveWorkerModel); } catch { return { provider: modelChoice.provider, model: providerEntry.tiers.sonnet }; }
|
|
416
414
|
})();
|
|
417
|
-
const MODEL =
|
|
415
|
+
const MODEL = modelChoice.name;
|
|
418
416
|
const { provider } = buildRunnerProviders({
|
|
419
417
|
providerName: modelChoice.provider, apiKey, model: MODEL, tierModels: providerEntry.tiers, baseUrl: modelChoice.baseUrl,
|
|
420
418
|
judgeApiKey: apiKey, judgeModel: MODEL, judgeProviderName: modelChoice.provider, judgeBaseUrl: modelChoice.baseUrl,
|
|
@@ -473,7 +471,7 @@ export function createSession(card, deps = {}) {
|
|
|
473
471
|
const judge = judges ? resolveJobJudge(spec, modelChoice.provider, resolveWorkerModel) : null;
|
|
474
472
|
let judgeProvider = null;
|
|
475
473
|
if (judge) {
|
|
476
|
-
const judgeKeyValue = env.JUDGE_API_KEY ?? env[resolveProvider(judge.provider).envKey];
|
|
474
|
+
const judgeKeyValue = env.JUDGE_API_KEY ?? (judge.provider === modelChoice.provider ? draftEnv : applyConfiguredKey(env, judge.provider, undefined, keyCfg, judge.model))[resolveProvider(judge.provider).envKey];
|
|
477
475
|
const judgeKeyProblem = judgeKeyValue ? apiKeyProblem(judgeKeyValue) : null;
|
|
478
476
|
if (!judgeKeyValue || judgeKeyProblem) {
|
|
479
477
|
refuse(`the judge key is not usable (${judgeKeyProblem ?? 'not set'}) — refusing before gate 4 spends anything`);
|