bareloop 0.31.0 → 0.33.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 +151 -0
- package/README.md +4 -1
- package/bareloop.context.md +232 -85
- package/bin/bareloop.mjs +0 -1
- package/package.json +1 -1
- package/src/authorflow.js +5 -3
- package/src/authorjob.js +3 -1
- package/src/authorrun.js +49 -11
- package/src/authorscout.js +10 -6
- package/src/bundlerun.js +266 -0
- package/src/cli.js +35 -357
- package/src/config.js +135 -0
- package/src/interviewrun.js +13 -4
- package/src/invoke.js +22 -0
- package/src/judged.js +5 -3
- package/src/keysfile.js +156 -0
- package/src/ledger.js +26 -0
- package/src/monthly.js +422 -0
- package/src/panel/authorroutes.js +61 -16
- package/src/panel/authorsession.js +46 -28
- package/src/panel/index.html +287 -13
- package/src/panel/server.js +58 -51
- package/src/panel/settingsroutes.js +230 -0
- package/src/planrun.js +7 -3
- package/src/providerrows.js +301 -0
- package/src/providers.js +3 -1
- package/src/run.js +7 -2
- package/src/runlist.js +92 -7
- package/src/source.js +8 -3
- package/src/u-watchdog.mjs +296 -0
- package/src/userrun.js +230 -54
- package/types/authorflow.d.ts +8 -2
- package/types/authorjob.d.ts +6 -1
- package/types/authorrun.d.ts +5 -0
- package/types/authorscout.d.ts +11 -2
- package/types/bundlerun.d.ts +26 -0
- package/types/cli.d.ts +2 -1
- package/types/config.d.ts +48 -0
- package/types/interviewrun.d.ts +5 -0
- package/types/invoke.d.ts +8 -0
- package/types/judged.d.ts +5 -1
- package/types/keysfile.d.ts +90 -0
- package/types/ledger.d.ts +14 -0
- package/types/monthly.d.ts +227 -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/planrun.d.ts +13 -1
- package/types/providerrows.d.ts +229 -0
- package/types/run.d.ts +14 -1
- package/types/runlist.d.ts +66 -3
- package/types/source.d.ts +8 -3
- package/types/userrun.d.ts +54 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// PANEL-BUILD.md P4b — the Providers rows, driven by the keys file. NOTHING here is a
|
|
2
|
+
// hardcoded provider list: one row exists per key in `~/.config/bareloop/.env` that has a
|
|
3
|
+
// value; each row's Name / API shape / Base URL come from config.json `keys.<ENV NAME>`
|
|
4
|
+
// (src/config.js), else the per-name defaults below. This is NOT a new provider table:
|
|
5
|
+
// a row's API shape names an entry that already exists in `src/providers.js`
|
|
6
|
+
// (`PROVIDER_TABLE`), and a row's Base URL is that provider's `baseUrl` option.
|
|
7
|
+
//
|
|
8
|
+
// This module is also THE one owner of "model -> key + shape + URL": Chat's Model menu,
|
|
9
|
+
// the author doors and the run doors all resolve through `findRow` / `keyNameFor` /
|
|
10
|
+
// `applyConfiguredKey` / `modelChoiceFor` below — no second lookup exists. Key VALUES
|
|
11
|
+
// never pass through here — only names.
|
|
12
|
+
import { resolveProvider } from './providers.js';
|
|
13
|
+
import { readConfig, ConfigError } from './config.js';
|
|
14
|
+
import { filledKeyNames } from './keysfile.js';
|
|
15
|
+
|
|
16
|
+
/** DeepSeek's OpenAI-shaped endpoint (the same string the panel's Model menu used). */
|
|
17
|
+
export const DEEPSEEK_BASE_URL = 'https://api.deepseek.com/v1';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The API shapes the dropdown offers. `id` is the `src/providers.js` table name;
|
|
21
|
+
* `defaultUrl` is the host a blank Base URL means ('' = the vendor's own, unshown).
|
|
22
|
+
* @type {readonly {id: string, label: string, defaultUrl: string}[]}
|
|
23
|
+
*/
|
|
24
|
+
export const SHAPES = Object.freeze([
|
|
25
|
+
Object.freeze({ id: 'anthropic-api', label: 'Anthropic', defaultUrl: 'https://api.anthropic.com/v1' }),
|
|
26
|
+
Object.freeze({ id: 'openai-api', label: 'OpenAI-compatible', defaultUrl: 'https://api.openai.com/v1' }),
|
|
27
|
+
Object.freeze({ id: 'gemini-api', label: 'Gemini', defaultUrl: '' }),
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/** The five empty lines a fresh keys file is created with, in this order. */
|
|
31
|
+
export const PRESET_KEY_NAMES = Object.freeze(['ANTHROPIC_API_KEY', 'DEEPSEEK_API_KEY', 'OPENAI_API_KEY', 'GEMINI_API_KEY', 'LOCAL_API_KEY']);
|
|
32
|
+
|
|
33
|
+
/** A local server's default endpoint: Ollama's OpenAI-compatible host (LM Studio / llama.cpp: change the URL). */
|
|
34
|
+
export const LOCAL_BASE_URL = 'http://127.0.0.1:11434/v1';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A key line whose value is the word `null` is SET and means "no real key": the row exists and
|
|
38
|
+
* the provider is handed this harmless stand-in (local servers ignore auth). It is not a
|
|
39
|
+
* secret and is never shown or logged as a key.
|
|
40
|
+
*/
|
|
41
|
+
export const NO_KEY_PLACEHOLDER = 'no-key-needed';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The key value to hand a provider: the `null` marker becomes the placeholder, anything else
|
|
45
|
+
* passes through unchanged.
|
|
46
|
+
* @template {string|undefined} T
|
|
47
|
+
* @param {T} raw
|
|
48
|
+
* @returns {T|string}
|
|
49
|
+
*/
|
|
50
|
+
export function usableKey(raw) {
|
|
51
|
+
return raw === 'null' ? NO_KEY_PLACEHOLDER : raw;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* What a key with no saved entry shows. Any name not listed is OpenAI-compatible with a
|
|
56
|
+
* blank URL and a blank Name.
|
|
57
|
+
* @param {string} envName
|
|
58
|
+
* @returns {{ name: string, shape: string, baseUrl: string }}
|
|
59
|
+
*/
|
|
60
|
+
export function defaultsFor(envName) {
|
|
61
|
+
switch (envName) {
|
|
62
|
+
case 'ANTHROPIC_API_KEY': return { name: 'claude-sonnet-5', shape: 'anthropic-api', baseUrl: '' };
|
|
63
|
+
case 'DEEPSEEK_API_KEY': return { name: 'deepseek-flash', shape: 'openai-api', baseUrl: DEEPSEEK_BASE_URL };
|
|
64
|
+
case 'GEMINI_API_KEY': return { name: '', shape: 'gemini-api', baseUrl: '' };
|
|
65
|
+
case 'LOCAL_API_KEY': return { name: '', shape: 'openai-api', baseUrl: LOCAL_BASE_URL };
|
|
66
|
+
default: return { name: '', shape: 'openai-api', baseUrl: '' };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @typedef {object} KeyRow
|
|
72
|
+
* @property {string} envName the key variable NAME (read-only label; never a value)
|
|
73
|
+
* @property {string} name the model id Chat uses ('' = not offered in Chat)
|
|
74
|
+
* @property {string} provider the `src/providers.js` table entry (the API shape's id)
|
|
75
|
+
* @property {string} baseUrl the saved Base URL ('' = the shape's default host)
|
|
76
|
+
* @property {unknown} [priceInPerM] the customer's own price, USD per 1M input tokens — carried RAW
|
|
77
|
+
* (present only when the config row names it); {@link ratesFor} is the one place that validates it
|
|
78
|
+
* @property {unknown} [priceOutPerM] the same, for output tokens
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The rows: one per name in `filled`, saved entry over defaults.
|
|
83
|
+
* @param {{ filled: readonly string[], config: Record<string, any> }} src
|
|
84
|
+
* @returns {KeyRow[]}
|
|
85
|
+
*/
|
|
86
|
+
export function keyRows({ filled, config }) {
|
|
87
|
+
const saved = config?.keys && typeof config.keys === 'object' && !Array.isArray(config.keys) ? config.keys : {};
|
|
88
|
+
return filled.map((envName) => {
|
|
89
|
+
const d = defaultsFor(envName);
|
|
90
|
+
const s = saved[envName] && typeof saved[envName] === 'object' ? saved[envName] : {};
|
|
91
|
+
return {
|
|
92
|
+
envName,
|
|
93
|
+
name: typeof s.name === 'string' ? s.name : d.name,
|
|
94
|
+
provider: SHAPES.some((x) => x.id === s.shape) ? s.shape : d.shape,
|
|
95
|
+
baseUrl: typeof s.baseUrl === 'string' ? s.baseUrl : d.baseUrl,
|
|
96
|
+
...(Object.hasOwn(s, 'priceInPerM') ? { priceInPerM: s.priceInPerM } : {}),
|
|
97
|
+
...(Object.hasOwn(s, 'priceOutPerM') ? { priceOutPerM: s.priceOutPerM } : {}),
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The rows for a keys home. `undefined` home (a door that skipped the keys file) = no
|
|
104
|
+
* rows, so nothing resolves through the file and the provider's built-in variable stands.
|
|
105
|
+
* @param {string|undefined} home
|
|
106
|
+
* @returns {KeyRow[]}
|
|
107
|
+
*/
|
|
108
|
+
export function rowsForHome(home) {
|
|
109
|
+
if (home === undefined) return [];
|
|
110
|
+
return keyRows({ filled: filledKeyNames(home), config: readConfig({ home }).config });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @param {string} provider
|
|
115
|
+
* @returns {string} the shape's default host ('' = the vendor's own)
|
|
116
|
+
*/
|
|
117
|
+
export function defaultUrlOf(provider) {
|
|
118
|
+
return SHAPES.find((s) => s.id === provider)?.defaultUrl ?? '';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* An endpoint in comparable form: blank = the shape's default host; scheme + host lower-cased,
|
|
123
|
+
* trailing slashes dropped.
|
|
124
|
+
* @param {string} provider
|
|
125
|
+
* @param {string|null|undefined} baseUrl
|
|
126
|
+
* @returns {string}
|
|
127
|
+
*/
|
|
128
|
+
export function endpointOf(provider, baseUrl) {
|
|
129
|
+
const raw = (typeof baseUrl === 'string' && baseUrl.trim() !== '' ? baseUrl : defaultUrlOf(provider)).trim();
|
|
130
|
+
if (raw === '') return '';
|
|
131
|
+
try {
|
|
132
|
+
const u = new URL(raw);
|
|
133
|
+
return `${u.protocol}//${u.host}${u.pathname.replace(/\/+$/, '')}`;
|
|
134
|
+
} catch { return raw.replace(/\/+$/, ''); }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The URL a row talks to, for display ('' = the vendor's own host, which has no URL to show).
|
|
139
|
+
* @param {KeyRow} row
|
|
140
|
+
* @returns {string}
|
|
141
|
+
*/
|
|
142
|
+
export function shownUrl(row) {
|
|
143
|
+
return row.baseUrl.trim() !== '' ? row.baseUrl : defaultUrlOf(row.provider);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Which row a `(provider, baseUrl[, model])` identity belongs to: the rows on that shape and
|
|
148
|
+
* endpoint; when several share it, the one whose Name is the model, else the first.
|
|
149
|
+
* @param {readonly KeyRow[]} rows
|
|
150
|
+
* @param {{ provider?: string|null, baseUrl?: string|null, model?: string|null }} id
|
|
151
|
+
* @returns {KeyRow|null}
|
|
152
|
+
*/
|
|
153
|
+
export function findRow(rows, { provider, baseUrl, model }) {
|
|
154
|
+
if (typeof provider !== 'string') return null;
|
|
155
|
+
const want = endpointOf(provider, baseUrl);
|
|
156
|
+
const same = rows.filter((r) => r.provider === provider && endpointOf(r.provider, r.baseUrl) === want);
|
|
157
|
+
if (same.length === 0) return null;
|
|
158
|
+
return (model ? same.find((r) => r.name === model) : undefined) ?? same[0];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* What Chat's Model menu offers and how a pick resolves: the row whose Name is the model
|
|
163
|
+
* (a blank Name is never offered). `baseUrl` is absent when blank, exactly as a spec omits it.
|
|
164
|
+
* @param {readonly KeyRow[]} rows
|
|
165
|
+
* @param {string} model
|
|
166
|
+
* @returns {{ provider: string, baseUrl?: string, envName: string, name: string }|null}
|
|
167
|
+
*/
|
|
168
|
+
export function modelChoiceFor(rows, model) {
|
|
169
|
+
const r = model === '' ? undefined : rows.find((x) => x.name === model);
|
|
170
|
+
if (!r) return null;
|
|
171
|
+
return { provider: r.provider, ...(r.baseUrl.trim() !== '' ? { baseUrl: r.baseUrl.trim() } : {}), envName: r.envName, name: r.name };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The models Chat offers, in keys-file order.
|
|
176
|
+
* @param {readonly KeyRow[]} rows
|
|
177
|
+
* @returns {{ id: string, envName: string, shape: string }[]}
|
|
178
|
+
*/
|
|
179
|
+
export function chatModels(rows) {
|
|
180
|
+
return rows.filter((r) => r.name !== '').map((r) => ({
|
|
181
|
+
id: r.name, envName: r.envName, shape: SHAPES.find((s) => s.id === r.provider)?.label ?? r.provider,
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The key variable NAME a job on `(provider, baseUrl[, model])` reads: the matching row's
|
|
187
|
+
* variable, else the provider's built-in one (byte-identical to before for anything no row
|
|
188
|
+
* claims).
|
|
189
|
+
* @param {string|null|undefined} provider
|
|
190
|
+
* @param {string|null|undefined} baseUrl
|
|
191
|
+
* @param {readonly KeyRow[]} rows
|
|
192
|
+
* @param {string|null|undefined} [model]
|
|
193
|
+
* @returns {{ name: string, builtIn: string, chosen: boolean }}
|
|
194
|
+
*/
|
|
195
|
+
export function keyNameFor(provider, baseUrl, rows, model) {
|
|
196
|
+
const builtIn = resolveProvider(/** @type {string} */ (provider)).envKey;
|
|
197
|
+
const row = findRow(rows, { provider, baseUrl, model });
|
|
198
|
+
return row && row.envName !== builtIn
|
|
199
|
+
? { name: row.envName, builtIn, chosen: true }
|
|
200
|
+
: { name: builtIn, builtIn, chosen: false };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The env a door hands to provider construction, with the matching row's key variable
|
|
205
|
+
* standing in for the provider's built-in one: `env[builtIn] = env[row.envName]` (the `null`
|
|
206
|
+
* no-key marker becomes {@link NO_KEY_PLACEHOLDER}). A copy — never mutates the input.
|
|
207
|
+
* No matching row and no marker = the env unchanged.
|
|
208
|
+
* @param {Record<string,string|undefined>} env
|
|
209
|
+
* @param {string|null|undefined} provider
|
|
210
|
+
* @param {string|null|undefined} baseUrl
|
|
211
|
+
* @param {readonly KeyRow[]} rows
|
|
212
|
+
* @param {string|null|undefined} [model]
|
|
213
|
+
* @returns {Record<string,string|undefined>}
|
|
214
|
+
*/
|
|
215
|
+
export function applyConfiguredKey(env, provider, baseUrl, rows, model) {
|
|
216
|
+
if (typeof provider !== 'string') return env;
|
|
217
|
+
let k;
|
|
218
|
+
try { k = keyNameFor(provider, baseUrl, rows, model); } catch { return env; } // unknown provider: resolveProvider's own named throw fires at its own door
|
|
219
|
+
const value = env[k.name];
|
|
220
|
+
if (!k.chosen && value !== 'null') return env;
|
|
221
|
+
return { ...env, [k.builtIn]: usableKey(value) };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A per-1M price for a readout: two decimals at least ($1.20), more when the number needs them so a
|
|
226
|
+
* tiny price never rounds to $0.00 or $0.01 ($0.006, $0.0004).
|
|
227
|
+
* @param {number} n USD per 1M tokens
|
|
228
|
+
* @returns {string}
|
|
229
|
+
*/
|
|
230
|
+
function perMoney(n) {
|
|
231
|
+
const shown = String(n);
|
|
232
|
+
if (!shown.includes('e') && (shown.split('.')[1] ?? '').length >= 2) return `$${shown}`;
|
|
233
|
+
if (shown.includes('e')) return `$${n.toFixed(12).replace(/0+$/, '').replace(/\.$/, '')}`;
|
|
234
|
+
return `$${n.toFixed(2)}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The one readout line for a resolved customer price, preview and run tail alike:
|
|
239
|
+
* `yours: in $0.006 / out $1.20 per 1M tokens (DEEPSEEK_API_KEY row)`.
|
|
240
|
+
* @param {{ inPerM: number, outPerM: number, envName: string }} price a {@link ratesFor} result
|
|
241
|
+
* @returns {string}
|
|
242
|
+
*/
|
|
243
|
+
export function priceReadout(price) {
|
|
244
|
+
return `yours: in ${perMoney(price.inPerM)} / out ${perMoney(price.outPerM)} per 1M tokens (${price.envName} row)`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The customer's own price for the row a `(provider, baseUrl[, model])` identity belongs to —
|
|
249
|
+
* the SAME row match {@link keyNameFor} uses (`findRow`), so the price and the key always come
|
|
250
|
+
* from one row. `null` = no price set (the run keeps bare-agent's built-in guess, byte-identical
|
|
251
|
+
* to before). Both fields or neither, each a finite number >= 0, USD per 1M tokens; anything
|
|
252
|
+
* else throws `ConfigError` naming the row and field — a bad price never silently falls back to
|
|
253
|
+
* the guess. `rates` is bare-agent's `Loop({ rates })` shape (USD per 1K); `inPerM`/`outPerM`
|
|
254
|
+
* are what a readout prints.
|
|
255
|
+
* @param {string|null|undefined} provider
|
|
256
|
+
* @param {string|null|undefined} baseUrl
|
|
257
|
+
* @param {readonly KeyRow[]} rows
|
|
258
|
+
* @param {string|null|undefined} [model]
|
|
259
|
+
* @returns {{ rates: { in: number, out: number }, inPerM: number, outPerM: number, envName: string }|null}
|
|
260
|
+
*/
|
|
261
|
+
export function ratesFor(provider, baseUrl, rows, model) {
|
|
262
|
+
const row = findRow(rows, { provider, baseUrl, model });
|
|
263
|
+
if (!row) return null;
|
|
264
|
+
const hasIn = row.priceInPerM !== undefined;
|
|
265
|
+
const hasOut = row.priceOutPerM !== undefined;
|
|
266
|
+
if (!hasIn && !hasOut) return null;
|
|
267
|
+
if (hasIn !== hasOut) {
|
|
268
|
+
throw new ConfigError(`keys.${row.envName} sets ${hasIn ? 'priceInPerM' : 'priceOutPerM'} but not ${hasIn ? 'priceOutPerM' : 'priceInPerM'} — set both prices (USD per 1M tokens) or neither`);
|
|
269
|
+
}
|
|
270
|
+
for (const field of /** @type {const} */ (['priceInPerM', 'priceOutPerM'])) {
|
|
271
|
+
const v = row[field];
|
|
272
|
+
if (!(typeof v === 'number' && Number.isFinite(v) && v >= 0)) {
|
|
273
|
+
throw new ConfigError(`keys.${row.envName}.${field} must be a number, 0 or more (USD per 1M tokens) — fix it in config.json or remove both price lines`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const inPerM = /** @type {number} */ (row.priceInPerM);
|
|
277
|
+
const outPerM = /** @type {number} */ (row.priceOutPerM);
|
|
278
|
+
return { rates: { in: inPerM / 1000, out: outPerM / 1000 }, inPerM, outPerM, envName: row.envName };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The price a judge's rounds are booked at. A price belongs to the MODEL's row: when the judge is
|
|
283
|
+
* the worker's own provider it calls the worker's endpoint, so a row on that endpoint NAMED for the
|
|
284
|
+
* judge's model prices it ({@link ratesFor}); a judge model that is absent or names no row there
|
|
285
|
+
* keeps the worker's price (the worker's row, or none = the guess). A different provider resolves
|
|
286
|
+
* on its own default endpoint, as before. A bad price on the resolved row throws like `ratesFor`.
|
|
287
|
+
* The judge's KEY is chosen elsewhere (`applyConfiguredKey`, by the worker's model), so the two
|
|
288
|
+
* can come from different rows — same provider + endpoint, judge model naming another row.
|
|
289
|
+
* @param {{ provider: string, model?: string|null }} judge
|
|
290
|
+
* @param {string|null|undefined} workerProvider
|
|
291
|
+
* @param {string|null|undefined} workerBaseUrl
|
|
292
|
+
* @param {ReturnType<typeof ratesFor>} workerPrice what the worker's own rounds are booked at
|
|
293
|
+
* @param {readonly KeyRow[]} rows
|
|
294
|
+
* @returns {ReturnType<typeof ratesFor>}
|
|
295
|
+
*/
|
|
296
|
+
export function judgeRatesFor(judge, workerProvider, workerBaseUrl, workerPrice, rows) {
|
|
297
|
+
if (judge.provider !== workerProvider) return ratesFor(judge.provider, undefined, rows, judge.model);
|
|
298
|
+
const want = endpointOf(judge.provider, workerBaseUrl);
|
|
299
|
+
const named = judge.model ? rows.some((r) => r.provider === judge.provider && endpointOf(r.provider, r.baseUrl) === want && r.name === judge.model) : false;
|
|
300
|
+
return named ? ratesFor(judge.provider, workerBaseUrl, rows, judge.model) : workerPrice;
|
|
301
|
+
}
|
package/src/providers.js
CHANGED
|
@@ -396,7 +396,9 @@ export async function checkProviderReachable({
|
|
|
396
396
|
return { reachable: true, modelListed, status: 'ok', note: null };
|
|
397
397
|
} catch (/** @type {any} */ e) {
|
|
398
398
|
const cls = e?.name === 'AbortError' ? 'timeout' : (e?.code || e?.name || 'network-error');
|
|
399
|
-
|
|
399
|
+
// connection refused surfaces as fetch's bare TypeError ("fetch failed") — say it plainly
|
|
400
|
+
const refused = e?.name === 'TypeError' || e?.code === 'ECONNREFUSED' || e?.cause?.code === 'ECONNREFUSED';
|
|
401
|
+
return { reachable: false, modelListed: null, status: String(cls), note: refused ? `nothing answering at ${req.url}` : `request failed: ${cls}` };
|
|
400
402
|
} finally {
|
|
401
403
|
clearTimeout(timer);
|
|
402
404
|
}
|
package/src/run.js
CHANGED
|
@@ -95,6 +95,11 @@ async function primitiveSmoke(workdir) {
|
|
|
95
95
|
* metered claude-json text provider for the toolless drafter); ignored on every Loop-driven provider
|
|
96
96
|
* @param {(type: string, data?: object) => object} opts.emit spine emitter
|
|
97
97
|
* @param {(tier: string) => any} [opts.providerFor] P: per-step model-tier provider factory (forwarded to the plan flow)
|
|
98
|
+
* @param {{in: number, out: number}|null} [opts.rates] the CUSTOMER's own price for the worker's key row
|
|
99
|
+
* (`ratesFor`, src/providerrows.js — USD per 1K tokens), resolved once by the door and handed to every
|
|
100
|
+
* worker/planner Loop as bare-agent's `rates` (rounds then read `rateSource:'caller'`). Absent = the
|
|
101
|
+
* built-in guess, exactly as before. A Loop-driven provider only: a native session reports its own cost.
|
|
102
|
+
* @param {{in: number, out: number}|null} [opts.judgeRates] the same, for the judge's own key row
|
|
98
103
|
* @param {string|null} [opts.judgeModel] SOFTGREEN — WHICH model the judge provider drives
|
|
99
104
|
* (PRD item 32.1), resolved by the CALLER (`resolveJudge`, src/judged.js) and passed
|
|
100
105
|
* through untouched. Travels with `judgeProvider`; absent, a judged stage stops as a
|
|
@@ -265,7 +270,7 @@ async function primitiveSmoke(workdir) {
|
|
|
265
270
|
* deliberately NOT in the resumable/checkpoint set (`src/reuse.js`) — an
|
|
266
271
|
* unknown in-flight state is not a known-safe resume point.
|
|
267
272
|
*/
|
|
268
|
-
export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, closeDir = null, layerRoot = false, readShim = false, scout = true, bridge = null, draftSpentUsd = 0, draftSpendComplete = true, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, reviewDoor = null, doorRerun = null, resumable = true }) {
|
|
273
|
+
export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, judgeModel = null, rates = null, judgeRates = null, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, closeDir = null, layerRoot = false, readShim = false, scout = true, bridge = null, draftSpentUsd = 0, draftSpendComplete = true, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, reviewDoor = null, doorRerun = null, resumable = true }) {
|
|
269
274
|
// THE READ SHIM's ARM, resolved at the door — the FIRST thing this entry does,
|
|
270
275
|
// before the ledger, before the approval gate, before a byte of the spec is read.
|
|
271
276
|
// An unrecognised spelling throws here at zero cost instead of being coerced by
|
|
@@ -532,7 +537,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
|
|
|
532
537
|
// accounts it natively (F12) and the job-end money contract is unchanged.
|
|
533
538
|
{
|
|
534
539
|
const outcome = await runPlan(job, {
|
|
535
|
-
workdir, provider, nativeProvider, providerFor, judgeProvider, judgeModel, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, closeDir, layerRoot, readShim, scout, bridge, priorWallMs: chainWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, reviewDoor, doorRerun, priorSpentUsd: chainFoldUsd, resumable,
|
|
540
|
+
workdir, provider, nativeProvider, providerFor, judgeProvider, judgeModel, rates, judgeRates, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, closeDir, layerRoot, readShim, scout, bridge, priorWallMs: chainWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, reviewDoor, doorRerun, priorSpentUsd: chainFoldUsd, resumable,
|
|
536
541
|
// hamr's ruling 2026-09-28 — ONE cap covers drafting + run: the run's
|
|
537
542
|
// enforced ceiling is the signed `budgetUsd` (unchanged, still in the
|
|
538
543
|
// spec hash) MINUS whatever drafting already spent on this job before
|
package/src/runlist.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// caller doesn't know one) — a relative path here would resolve differently
|
|
14
14
|
// depending on who later reads the file. `via` says how the row was minted:
|
|
15
15
|
// `'run-u'` (src/userrun.js, the person-path run), `'bundle'`
|
|
16
|
-
// (src/
|
|
16
|
+
// (src/userrun.js's bundle door, `bareloop run`), or `'backfill'` (`bareloop runs
|
|
17
17
|
// backfill`, reconstructed from an archived spine already on disk).
|
|
18
18
|
//
|
|
19
19
|
// SECRETS: a row carries only paths/ids/names — never a key, a prompt, or any
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
|
|
30
30
|
import {
|
|
31
31
|
existsSync, mkdirSync, appendFileSync, chmodSync, readdirSync, statSync,
|
|
32
|
+
readFileSync,
|
|
32
33
|
} from 'node:fs';
|
|
33
34
|
import { homedir } from 'node:os';
|
|
34
35
|
import {
|
|
@@ -57,7 +58,9 @@ export function runlistPath(home) {
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
/**
|
|
60
|
-
* @typedef {{ at: string, runid: string, job: string, spine: string, patient: string|null, via: 'run-u'|'bundle'|'backfill' }} RunRow
|
|
61
|
+
* @typedef {{ at: string, runid: string, job: string, spine: string, patient: string|null, via: 'run-u'|'bundle'|'backfill', pid?: number, capUsd?: number }} RunRow
|
|
62
|
+
* `pid`/`capUsd` (optional; older and backfilled rows carry neither): the runner's process id and the
|
|
63
|
+
* leg's $ cap — what the monthly limit holds while that pid is a live bareloop runner (src/monthly.js).
|
|
61
64
|
*/
|
|
62
65
|
|
|
63
66
|
/**
|
|
@@ -87,7 +90,8 @@ export function appendRun(row, opts = {}) {
|
|
|
87
90
|
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
88
91
|
if (existsSync(path)) {
|
|
89
92
|
const { records } = parseJsonl(path);
|
|
90
|
-
|
|
93
|
+
// only RUN rows count: a settled/released event names the same runid for another purpose
|
|
94
|
+
if (records.some((r) => r && !r.type && r.runid === row.runid)) {
|
|
91
95
|
return { appended: false, reason: 'duplicate-runid' };
|
|
92
96
|
}
|
|
93
97
|
}
|
|
@@ -96,18 +100,86 @@ export function appendRun(row, opts = {}) {
|
|
|
96
100
|
return { appended: true };
|
|
97
101
|
}
|
|
98
102
|
|
|
103
|
+
/**
|
|
104
|
+
* @typedef {{ runid: string, type: 'settled'|'released', by: string, at: string, reason?: string, spentUsd?: number, spendComplete?: boolean }} RunEvent
|
|
105
|
+
* A claim's write-back, appended (never rewritten) beside the run rows. `settled` = the run's claim on the
|
|
106
|
+
* monthly limit is over (`spentUsd`/`spendComplete` are its final figure, `by` is WHO wrote it: the run itself
|
|
107
|
+
* at its `job-end`, or the next run that found its process gone, `reason: 'process gone'`). `released` = the
|
|
108
|
+
* claim was taken and then given back unspent, its run never started. The month's totals keep reading real
|
|
109
|
+
* spend off the spines; an event only releases the hold and records who wrote it.
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Append one {@link RunEvent}. Not locked: one short line, one append. THROWS on IO failure.
|
|
114
|
+
* @param {RunEvent} event
|
|
115
|
+
* @param {{ home?: string }} [opts]
|
|
116
|
+
* @returns {void}
|
|
117
|
+
*/
|
|
118
|
+
export function appendRunEvent(event, opts = {}) {
|
|
119
|
+
const path = runlistPath(opts.home);
|
|
120
|
+
mkdirSync(runlistHome(opts.home), { recursive: true, mode: 0o700 });
|
|
121
|
+
appendFileSync(path, `${JSON.stringify(event)}\n`);
|
|
122
|
+
try { chmodSync(path, 0o600); } catch { /* best-effort perms */ }
|
|
123
|
+
}
|
|
124
|
+
|
|
99
125
|
/**
|
|
100
126
|
* Tolerant reader — reuses {@link parseJsonl} (never a second hand-rolled
|
|
101
127
|
* parser). An absent file reads as an empty list, not an error: a fresh
|
|
102
|
-
* install has never run `appendRun` yet.
|
|
128
|
+
* install has never run `appendRun` yet. FOLDS the file: `rows` are the RUNS
|
|
129
|
+
* only (an event line is never listed as a run, and a run whose claim was
|
|
130
|
+
* `released` never started, so it is not listed either); `events` are the
|
|
131
|
+
* write-backs, in file order.
|
|
103
132
|
* @param {{ home?: string }} [opts]
|
|
104
|
-
* @returns {{ rows: RunRow[], skipped: number }}
|
|
133
|
+
* @returns {{ rows: RunRow[], skipped: number, events: RunEvent[] }}
|
|
105
134
|
*/
|
|
106
135
|
export function readRunList(opts = {}) {
|
|
107
136
|
const path = runlistPath(opts.home);
|
|
108
|
-
if (!existsSync(path)) return { rows: [], skipped: 0 };
|
|
137
|
+
if (!existsSync(path)) return { rows: [], skipped: 0, events: [] };
|
|
109
138
|
const { records, skipped } = parseJsonl(path);
|
|
110
|
-
|
|
139
|
+
// the FIRST settle for a runid is authoritative: two runs may both close the same dead claim, and the
|
|
140
|
+
// second note is a duplicate (a `released` line likewise); file order decides, later copies are ignored
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
/** @type {RunEvent[]} */
|
|
143
|
+
const events = records.filter((r) => {
|
|
144
|
+
if (!r || (r.type !== 'settled' && r.type !== 'released')) return false;
|
|
145
|
+
const key = `${r.type}:${r.runid}`;
|
|
146
|
+
if (seen.has(key)) return false;
|
|
147
|
+
seen.add(key);
|
|
148
|
+
return true;
|
|
149
|
+
});
|
|
150
|
+
const released = new Set(events.filter((e) => e.type === 'released').map((e) => e.runid));
|
|
151
|
+
const rows = records.filter((r) => r && !r.type && !released.has(r.runid));
|
|
152
|
+
return { rows, skipped, events };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Is this pid a live bareloop runner? `process.kill(pid, 0)` says whether the
|
|
157
|
+
* pid exists (EPERM = it exists, just not ours); `/proc/<pid>/cmdline` says whether it is a
|
|
158
|
+
* bareloop runner — a recycled pid that now belongs to some other program is NOT. A runner is
|
|
159
|
+
* a node executable (argv[0] is `node`, `nodejs`, `node22`, ...) with a later argv entry named
|
|
160
|
+
* `bareloop`, `bareloop.mjs`, `run-u.mjs` or `u-watchdog.mjs` (the basename, so the npm bin
|
|
161
|
+
* symlink counts), or a process whose argv[0] is itself one of those names. A non-node program
|
|
162
|
+
* that merely has such a name in its arguments (`nvim /x/bareloop`, `git -C /x/bareloop`) is not
|
|
163
|
+
* a runner. Residual, accepted: a recycled pid that is a node program with such an argument
|
|
164
|
+
* still reads as a runner. The wrong direction to err is "not a runner" on a real live run — it
|
|
165
|
+
* would release that run's cap. Where `/proc` is unreadable (not Linux, or not ours) the
|
|
166
|
+
* existence test alone decides: alive. The ONE spelling — the monthly limit's holds, and
|
|
167
|
+
* `--resume`'s old-pid check share it.
|
|
168
|
+
* @param {number} pid
|
|
169
|
+
* @returns {boolean}
|
|
170
|
+
*/
|
|
171
|
+
export function isLiveRunner(pid) {
|
|
172
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
173
|
+
try { process.kill(pid, 0); } catch (/** @type {any} */ e) { if (e?.code !== 'EPERM') return false; }
|
|
174
|
+
/** @type {string|null} */
|
|
175
|
+
let cmdline = null;
|
|
176
|
+
try { cmdline = readFileSync(`/proc/${pid}/cmdline`, 'utf8'); } catch { /* no /proc, or not ours */ }
|
|
177
|
+
if (cmdline === null) return true;
|
|
178
|
+
const names = ['bareloop', 'bareloop.mjs', 'run-u.mjs', 'u-watchdog.mjs'];
|
|
179
|
+
const [argv0 = '', ...rest] = cmdline.split('\0');
|
|
180
|
+
const first = basename(argv0);
|
|
181
|
+
if (names.includes(first)) return true;
|
|
182
|
+
return first.startsWith('node') && rest.some((a) => names.includes(basename(a)));
|
|
111
183
|
}
|
|
112
184
|
|
|
113
185
|
/**
|
|
@@ -128,6 +200,19 @@ function runidForSpine(spinePath) {
|
|
|
128
200
|
return resolveSiblings(spinePath).runId;
|
|
129
201
|
}
|
|
130
202
|
|
|
203
|
+
// DIED (hamr's ruling B, 2026-09-25): a run with no `job-end` never shares
|
|
204
|
+
// the failed glyph — [✗] stays reserved for a run whose close/arbiter
|
|
205
|
+
// actually rendered a "no" (a real result). A spine that just stops, with
|
|
206
|
+
// no ending ever recorded, is a DIFFERENT fact (killed, crashed, or the
|
|
207
|
+
// machine slept) and gets its own [?] glyph. The distinguishing signal is
|
|
208
|
+
// the spine FILE's own mtime, never wall-clock "now minus job-start" (a
|
|
209
|
+
// resumed/paused run can legitimately sit quiet for a long time without
|
|
210
|
+
// having died): still fresh (written to within this window) reads as the
|
|
211
|
+
// existing `running` [▶] state; older than this reads as died. Tighten-only,
|
|
212
|
+
// named so a future change is a deliberate, visible edit. Lives here (not in
|
|
213
|
+
// the panel) because the monthly limit reads the same rule (src/monthly.js).
|
|
214
|
+
export const DIED_MTIME_MS = 10 * 60 * 1000;
|
|
215
|
+
|
|
131
216
|
// A backfill scan of a real patients directory can be arbitrarily deep
|
|
132
217
|
// (person-path runs archive several levels down: `<dir>/<proj>/out/
|
|
133
218
|
// source-<x>/<proj>-bareloop/u-<id>.jsonl`, 4 levels under the dir a person
|
package/src/source.js
CHANGED
|
@@ -441,11 +441,15 @@ async function fetchOnce(url, timeoutMs) {
|
|
|
441
441
|
* never hands a repo source's destination to `copyOut` — wiring it into
|
|
442
442
|
* `writeScope` is M3/M4's job, not this one.
|
|
443
443
|
* @param {{source: string, into: string, destination?: string,
|
|
444
|
-
* fetchTimeoutMs?: number
|
|
445
|
-
* callers omit it and get
|
|
444
|
+
* fetchTimeoutMs?: number, afterScan?: () => (void|Promise<void>)}} args
|
|
445
|
+
* `fetchTimeoutMs` is test-only — production callers omit it and get
|
|
446
|
+
* `PROVIDER_TIMEOUT_MS`. `afterScan` is a test seam too: called exactly once,
|
|
447
|
+
* after the secret scan finished and before the freeze loop re-reads any
|
|
448
|
+
* file's bytes, so a test can swap a file inside that window
|
|
449
|
+
* deterministically. Production never sets it; absent, behaviour is unchanged.
|
|
446
450
|
* @returns {Promise<{stop: null, into: string, tree: string, manifestPath: string, manifest: object}|SourceRefusal>}
|
|
447
451
|
*/
|
|
448
|
-
export async function prepareSource({ source, into, destination, fetchTimeoutMs = PROVIDER_TIMEOUT_MS }) {
|
|
452
|
+
export async function prepareSource({ source, into, destination, fetchTimeoutMs = PROVIDER_TIMEOUT_MS, afterScan }) {
|
|
449
453
|
const intoAbs = resolve(into);
|
|
450
454
|
if (existsSync(intoAbs)) {
|
|
451
455
|
return refuse('into-exists', `${intoAbs} already exists — a source door writes a FRESH tree, never reuses one (the export worktree rule)`);
|
|
@@ -674,6 +678,7 @@ export async function prepareSource({ source, into, destination, fetchTimeoutMs
|
|
|
674
678
|
// (verified: 0o755 in, 0o755 out; a plain `writeFile` defaults to 0o644),
|
|
675
679
|
// so the replacement chmod's the destination from a fresh `stat` to keep
|
|
676
680
|
// that behaviour unchanged.
|
|
681
|
+
if (afterScan) await afterScan();
|
|
677
682
|
for (let i = 0; i < frozen.files.length; i += 1) {
|
|
678
683
|
const f = frozen.files[i];
|
|
679
684
|
const dest = join(treeDir, treeRel(f.rel));
|