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
package/src/monthly.js
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
// PANEL-BUILD.md P4a item 2 — the monthly $ limit (R1, R2, R3).
|
|
2
|
+
//
|
|
3
|
+
// `monthSpend` sums what this local calendar month's runs spent, read off each run's own
|
|
4
|
+
// spine (the run list `~/.config/bareloop/runs.jsonl` carries no money). `checkMonthlyRoom`
|
|
5
|
+
// asks whether a run's $ cap fits in what is left. It is called at the ONE run-start seam
|
|
6
|
+
// both the CLI (`bareloop run-u`) and the panel (which spawns `run-u`) pass through
|
|
7
|
+
// (`execute` in src/userrun.js), before any token spends, and by the panel's cap note.
|
|
8
|
+
// The limit REFUSES, never warns; the page and the chat can never raise it (a person
|
|
9
|
+
// edits it by hand in Settings — src/config.js).
|
|
10
|
+
//
|
|
11
|
+
// HOLD UNTIL DONE (hamr 2026-09-30): a run CLAIMS its whole leg cap when it starts (`claimRun`: append
|
|
12
|
+
// the run's row FIRST, carrying its `pid` and `capUsd`, then read the list — only the claims ABOVE its row
|
|
13
|
+
// count, so whoever runs first holds) and holds it until it is done. While the row's pid is a live bareloop runner the
|
|
14
|
+
// run is counted at max(spend so far, capUsd) however quiet its spine; a run with a `job-end` counts
|
|
15
|
+
// its real spend; a claim whose process is gone counts its floor and is settled — with an attributed
|
|
16
|
+
// `settled` entry — by whichever later run finds it (the first note wins). An older row with no `pid` keeps the spine
|
|
17
|
+
// mtime rule (a fresh spine, written to within DIED_MTIME_MS, is in flight). Only the refusal check
|
|
18
|
+
// reserves; the Money tab keeps showing real spend (`usd`).
|
|
19
|
+
//
|
|
20
|
+
// Honesty: a run whose spend is not fully known (a died/still-running spine, an unpriced
|
|
21
|
+
// round, a missing spine file) makes the month total an "at least" figure — never a clean
|
|
22
|
+
// number. The refusal text stays exactly `Max $X (monthly limit)`; `atLeast` travels beside it.
|
|
23
|
+
import { existsSync, statSync } from 'node:fs';
|
|
24
|
+
import { readConfig, configPath, ConfigError } from './config.js';
|
|
25
|
+
import { readRunList, appendRun, appendRunEvent, isLiveRunner, DIED_MTIME_MS } from './runlist.js';
|
|
26
|
+
export { isLiveRunner };
|
|
27
|
+
import { parseJsonl } from './replayio.js';
|
|
28
|
+
import { SPEND_RECORD_TYPES, spendProvenance, floorsFromRecords } from './ledger.js';
|
|
29
|
+
import { findRow } from './providerrows.js';
|
|
30
|
+
|
|
31
|
+
// The Money tab's per-provider breakdown groups a run by the endpoint it ran on and names
|
|
32
|
+
// the groups it knows the vendor of. This is HISTORY labelling for spend already recorded
|
|
33
|
+
// (an archived run keeps its provider whatever Settings holds today) — not the Providers
|
|
34
|
+
// rows, which come from the keys file (src/providerrows.js).
|
|
35
|
+
/** @type {readonly {id: string, name: string}[]} */
|
|
36
|
+
const MONEY_LABELS = Object.freeze([
|
|
37
|
+
{ id: 'anthropic', name: 'Anthropic' }, { id: 'openai', name: 'OpenAI' },
|
|
38
|
+
{ id: 'gemini', name: 'Gemini' }, { id: 'deepseek', name: 'DeepSeek' },
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Which known vendor a run's `(provider, baseUrl)` belongs to. `openai-api` with no override
|
|
43
|
+
* is OpenAI; with DeepSeek's host it is DeepSeek; with any OTHER override it is none (null —
|
|
44
|
+
* never silently pooled into OpenAI's figures).
|
|
45
|
+
* @param {string|null|undefined} provider
|
|
46
|
+
* @param {string|null|undefined} baseUrl
|
|
47
|
+
* @returns {string|null} a MONEY_LABELS id, or null
|
|
48
|
+
*/
|
|
49
|
+
function rowIdFor(provider, baseUrl) {
|
|
50
|
+
if (provider === 'anthropic-api') return 'anthropic';
|
|
51
|
+
if (provider === 'gemini-api') return 'gemini';
|
|
52
|
+
if (provider === 'openai-api') {
|
|
53
|
+
if (!baseUrl) return 'openai';
|
|
54
|
+
try {
|
|
55
|
+
const host = new URL(baseUrl).hostname;
|
|
56
|
+
if (host === 'api.deepseek.com') return 'deepseek';
|
|
57
|
+
} catch { /* an unparseable override is no known vendor */ }
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* One run's own spend, off its spine records: this LEG's figure (never the chain fold),
|
|
65
|
+
* plus the drafting spend the run was signed with (counted once — on the first leg only).
|
|
66
|
+
* @param {any[]} records
|
|
67
|
+
* @returns {{ usd: number, complete: boolean }}
|
|
68
|
+
*/
|
|
69
|
+
export function legSpend(records) {
|
|
70
|
+
const jobStart = records.find((r) => r && r.type === 'job-start') ?? null;
|
|
71
|
+
const jobEnd = records.findLast((r) => r && r.type === 'job-end') ?? null;
|
|
72
|
+
const rounds = records.filter((r) => r && SPEND_RECORD_TYPES.includes(r.type));
|
|
73
|
+
const pricedSum = rounds.reduce((a, r) => (typeof r.costUsd === 'number' && Number.isFinite(r.costUsd) ? a + r.costUsd : a), 0);
|
|
74
|
+
const unpriced = rounds.filter((r) => !(typeof r.costUsd === 'number' && Number.isFinite(r.costUsd))).length;
|
|
75
|
+
let usd = pricedSum;
|
|
76
|
+
let complete = false;
|
|
77
|
+
if (jobEnd) {
|
|
78
|
+
const eng = jobEnd.engagementSpentUsd;
|
|
79
|
+
usd = typeof eng === 'number' && Number.isFinite(eng) ? eng : pricedSum;
|
|
80
|
+
complete = jobEnd.spendComplete === true && unpriced === 0;
|
|
81
|
+
}
|
|
82
|
+
// drafting spend: a RESUMED / door-rerun leg carries the flag again but the first leg already counted it
|
|
83
|
+
const resumed = !!jobStart && 'priorSpentUsd' in jobStart;
|
|
84
|
+
if (jobStart && !resumed && typeof jobStart.draftSpentUsd === 'number' && Number.isFinite(jobStart.draftSpentUsd) && jobStart.draftSpentUsd > 0) {
|
|
85
|
+
usd += jobStart.draftSpentUsd;
|
|
86
|
+
if (jobStart.draftSpendComplete === false) complete = false;
|
|
87
|
+
}
|
|
88
|
+
return { usd, complete };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* One run's wall time off its spine: job-start -> job-end when the run ended (exact), else the
|
|
93
|
+
* first -> last record floor (the SAME floor a died / still-running run's "at least" wall uses,
|
|
94
|
+
* `floorsFromRecords`). `ms` null = unknown — never 0.
|
|
95
|
+
* @param {any[]} records
|
|
96
|
+
* @returns {{ ms: number|null, complete: boolean }}
|
|
97
|
+
*/
|
|
98
|
+
export function legWall(records) {
|
|
99
|
+
const jobStart = records.find((r) => r && r.type === 'job-start') ?? null;
|
|
100
|
+
const jobEnd = records.findLast((r) => r && r.type === 'job-end') ?? null;
|
|
101
|
+
const a = Date.parse(jobStart?.ts);
|
|
102
|
+
const b = Date.parse(jobEnd?.ts);
|
|
103
|
+
if (jobEnd && Number.isFinite(a) && Number.isFinite(b) && b >= a) return { ms: b - a, complete: true };
|
|
104
|
+
return { ms: floorsFromRecords(records).wallFloorMs, complete: false };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Tokens a spine's rounds processed (input + output + cache read + cache write — all four,
|
|
109
|
+
* so a cache-heavy run is not under-reported), off the real `usage` field names.
|
|
110
|
+
* @param {any[]} records
|
|
111
|
+
* @returns {number}
|
|
112
|
+
*/
|
|
113
|
+
export function legTokens(records) {
|
|
114
|
+
let n = 0;
|
|
115
|
+
for (const r of records) {
|
|
116
|
+
if (!r || !SPEND_RECORD_TYPES.includes(r.type) || !r.usage) continue;
|
|
117
|
+
for (const k of ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheCreationTokens']) {
|
|
118
|
+
const v = r.usage[k];
|
|
119
|
+
if (typeof v === 'number' && Number.isFinite(v)) n += v;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return n;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @typedef {object} Leg
|
|
127
|
+
* @property {Date} at when the run was listed
|
|
128
|
+
* @property {string|null} provider job-start's provider (null = an older spine that carried none)
|
|
129
|
+
* @property {string|null} baseUrl
|
|
130
|
+
* @property {number} usd this leg's spend (a floor when `!complete`)
|
|
131
|
+
* @property {boolean} complete every figure behind `usd` is known
|
|
132
|
+
* @property {number} tokens
|
|
133
|
+
* @property {string|null} model job-start's model (older spines carry a model but no provider)
|
|
134
|
+
* @property {number|null} wallMs this leg's wall time (null = unknown; a floor when `!wallComplete`)
|
|
135
|
+
* @property {boolean} wallComplete the wall is the exact job-start -> job-end span
|
|
136
|
+
* @property {number} vouchedRounds rounds priced by a rate somebody vouched for (`provider`/`caller`)
|
|
137
|
+
* @property {number} otherRounds every other round: a built-in guess, unpriced, or no provenance on record
|
|
138
|
+
* @property {number} reservedUsd what the refusal check counts: `usd`, or the leg's cap when the run is in flight
|
|
139
|
+
* @property {boolean} unreadable the spine was missing/unreadable or the listed date was bad (spend unknown)
|
|
140
|
+
*/
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* What the refusal check counts for one run: its spend, unless it is IN FLIGHT (`job-start`, no
|
|
144
|
+
* `job-end`, spine written to within DIED_MTIME_MS of `nowMs`), when it is the larger of that
|
|
145
|
+
* spend and the leg's own cap — the signed `budgetUsd` on the spine's `job-start` less the fold
|
|
146
|
+
* a resumed leg inherited (`priorSpentUsd`), the same figure the run-start seam computes
|
|
147
|
+
* (`legCapUsd`, src/userrun.js). A spine that records no `budgetUsd` reserves nothing extra.
|
|
148
|
+
* @param {any[]} records
|
|
149
|
+
* @param {string} spinePath
|
|
150
|
+
* @param {number} nowMs
|
|
151
|
+
* @param {number} usd the leg's spend so far
|
|
152
|
+
* @returns {number}
|
|
153
|
+
*/
|
|
154
|
+
function inFlightCapUsd(records, spinePath, nowMs, usd) {
|
|
155
|
+
if (records.some((r) => r && r.type === 'job-end')) return usd;
|
|
156
|
+
const start = records.find((r) => r && r.type === 'job-start') ?? null;
|
|
157
|
+
if (!start || !(typeof start.budgetUsd === 'number' && Number.isFinite(start.budgetUsd))) return usd;
|
|
158
|
+
let mtimeMs;
|
|
159
|
+
try { mtimeMs = statSync(spinePath).mtimeMs; } catch { return usd; }
|
|
160
|
+
if (nowMs - mtimeMs > DIED_MTIME_MS) return usd; // stale — died, counts its floor
|
|
161
|
+
const fold = typeof start.priorSpentUsd === 'number' && Number.isFinite(start.priorSpentUsd) ? start.priorSpentUsd : 0;
|
|
162
|
+
return Math.max(usd, Math.max(0, start.budgetUsd - fold));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The rows listed EARLIER in the run list than `runid`'s own row (file order is claim order). No
|
|
167
|
+
* `runid`, or a runid not in the list, = every row.
|
|
168
|
+
* @param {import('./runlist.js').RunRow[]} rows
|
|
169
|
+
* @param {string} [runid]
|
|
170
|
+
* @returns {import('./runlist.js').RunRow[]}
|
|
171
|
+
*/
|
|
172
|
+
function rowsAbove(rows, runid) {
|
|
173
|
+
if (runid === undefined) return rows;
|
|
174
|
+
const i = rows.findIndex((r) => r.runid === runid);
|
|
175
|
+
return i < 0 ? rows : rows.slice(0, i);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Every listed run as one {@link Leg}. The ONE reader the month total, the all-time total
|
|
180
|
+
* and the per-provider figures share.
|
|
181
|
+
* @param {{ home?: string, now?: () => number, aboveRunid?: string }} [opts] `now` = the clock for the no-pid in-flight test; `aboveRunid` = read only the runs listed EARLIER in the file than that run (a claim yields to the claims above it, never below)
|
|
182
|
+
* @returns {Leg[]}
|
|
183
|
+
*/
|
|
184
|
+
export function readLegs(opts = {}) {
|
|
185
|
+
const nowMs = (opts.now ?? Date.now)();
|
|
186
|
+
const { rows: allRows, events } = readRunList({ home: opts.home });
|
|
187
|
+
const rows = rowsAbove(allRows, opts.aboveRunid);
|
|
188
|
+
const settled = new Set(events.filter((e) => e.type === 'settled').map((e) => e.runid));
|
|
189
|
+
/** @type {Leg[]} */
|
|
190
|
+
const legs = [];
|
|
191
|
+
for (const row of rows) {
|
|
192
|
+
const at = new Date(row.at);
|
|
193
|
+
const base = { at, provider: null, baseUrl: null, model: null, wallMs: null, wallComplete: false, usd: 0, complete: false, tokens: 0, vouchedRounds: 0, otherRounds: 0, reservedUsd: 0, unreadable: true };
|
|
194
|
+
// Rule 2 (hold-until-done): a row that carries its runner's `pid` is HELD at its cap while that
|
|
195
|
+
// pid is a live bareloop runner, however quiet its spine — killed = process gone, not silence.
|
|
196
|
+
// An older row with no `pid` keeps the spine-mtime rule (`inFlightCapUsd`), never guessed alive.
|
|
197
|
+
const hasPid = Number.isInteger(row.pid);
|
|
198
|
+
const heldCap = hasPid && !settled.has(row.runid) && typeof row.capUsd === 'number' && Number.isFinite(row.capUsd) && isLiveRunner(/** @type {number} */ (row.pid)) ? row.capUsd : null;
|
|
199
|
+
if (Number.isNaN(at.getTime()) || !existsSync(row.spine)) { legs.push({ ...base, reservedUsd: heldCap ?? 0 }); continue; }
|
|
200
|
+
/** @type {any[]} */
|
|
201
|
+
let records;
|
|
202
|
+
try { records = parseJsonl(row.spine).records; } catch { legs.push({ ...base, reservedUsd: heldCap ?? 0 }); continue; }
|
|
203
|
+
const start = records.find((r) => r && r.type === 'job-start') ?? null;
|
|
204
|
+
const leg = legSpend(records);
|
|
205
|
+
const prov = spendProvenance(records);
|
|
206
|
+
const wall = legWall(records);
|
|
207
|
+
legs.push({
|
|
208
|
+
at,
|
|
209
|
+
model: typeof start?.model === 'string' ? start.model : null,
|
|
210
|
+
wallMs: wall.ms,
|
|
211
|
+
wallComplete: wall.complete,
|
|
212
|
+
provider: typeof start?.provider === 'string' ? start.provider : null,
|
|
213
|
+
baseUrl: typeof start?.baseUrl === 'string' ? start.baseUrl : null,
|
|
214
|
+
usd: leg.usd,
|
|
215
|
+
complete: leg.complete,
|
|
216
|
+
tokens: legTokens(records),
|
|
217
|
+
vouchedRounds: prov.vouched.rounds,
|
|
218
|
+
otherRounds: prov.guessed.rounds + prov.unpriced.rounds + prov.unknown.rounds,
|
|
219
|
+
reservedUsd: hasPid ? (records.some((r) => r && r.type === 'job-end') || heldCap === null ? leg.usd : Math.max(leg.usd, heldCap))
|
|
220
|
+
: inFlightCapUsd(records, row.spine, nowMs, leg.usd),
|
|
221
|
+
unreadable: false,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
return legs;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* @param {Date} at
|
|
229
|
+
* @param {Date} now
|
|
230
|
+
* @returns {boolean} same LOCAL calendar month
|
|
231
|
+
*/
|
|
232
|
+
const sameLocalMonth = (at, now) => at.getFullYear() === now.getFullYear() && at.getMonth() === now.getMonth();
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* @param {{ home?: string, now?: () => number, aboveRunid?: string }} [opts] `aboveRunid`: see {@link readLegs}
|
|
236
|
+
* @returns {{ usd: number, atLeast: boolean, runs: number, reservedUsd: number }} `usd` = real spend (the Money tab's figure); `reservedUsd` = the same with in-flight runs at their cap (the refusal check's)
|
|
237
|
+
*/
|
|
238
|
+
export function monthSpend(opts = {}) {
|
|
239
|
+
const nowDate = new Date((opts.now ?? Date.now)());
|
|
240
|
+
let usd = 0;
|
|
241
|
+
let reservedUsd = 0;
|
|
242
|
+
let atLeast = false;
|
|
243
|
+
let runs = 0;
|
|
244
|
+
for (const leg of readLegs({ home: opts.home, now: opts.now, aboveRunid: opts.aboveRunid })) {
|
|
245
|
+
// an unreadable date could belong to this month — unknown, never dropped
|
|
246
|
+
if (Number.isNaN(leg.at.getTime())) { atLeast = true; continue; }
|
|
247
|
+
if (!sameLocalMonth(leg.at, nowDate)) continue;
|
|
248
|
+
runs += 1;
|
|
249
|
+
if (leg.unreadable) { atLeast = true; reservedUsd += leg.reservedUsd; continue; }
|
|
250
|
+
usd += leg.usd;
|
|
251
|
+
reservedUsd += leg.reservedUsd;
|
|
252
|
+
if (!leg.complete) atLeast = true;
|
|
253
|
+
}
|
|
254
|
+
return { usd, atLeast, runs, reservedUsd };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The Money tab's figures: all-time and this-month spend, and per-provider breakdown
|
|
259
|
+
* (rows from `rowIdFor`; a run on no known row lands under its own provider name, never
|
|
260
|
+
* pooled into another row). Any leg with unknown spend makes the figure it belongs to
|
|
261
|
+
* an "at least".
|
|
262
|
+
* @param {{ home?: string, now?: () => number, rows?: readonly import('./providerrows.js').KeyRow[] }} [opts] `rows` = the Providers rows: when given, `tokensByRow` sums each row's tokens
|
|
263
|
+
* @returns {{ tokensByRow: Record<string, number>, total: {usd: number, atLeast: boolean, tokens: number}, month: {usd: number, atLeast: boolean, tokens: number},
|
|
264
|
+
* byProvider: Record<string, {label: string, monthUsd: number, monthAtLeast: boolean, totalUsd: number, totalAtLeast: boolean, tokens: number, vouchedRounds: number, otherRounds: number, monthWallMs: number|null, monthWallAtLeast: boolean, totalWallMs: number|null, totalWallAtLeast: boolean}> }}
|
|
265
|
+
*/
|
|
266
|
+
export function spendSummary(opts = {}) {
|
|
267
|
+
const nowDate = new Date((opts.now ?? Date.now)());
|
|
268
|
+
const total = { usd: 0, atLeast: false, tokens: 0 };
|
|
269
|
+
const month = { usd: 0, atLeast: false, tokens: 0 };
|
|
270
|
+
/** @type {Record<string, {label: string, monthUsd: number, monthAtLeast: boolean, totalUsd: number, totalAtLeast: boolean, tokens: number, vouchedRounds: number, otherRounds: number, monthWallMs: number|null, monthWallAtLeast: boolean, totalWallMs: number|null, totalWallAtLeast: boolean}>} */
|
|
271
|
+
const byProvider = {};
|
|
272
|
+
/** runs per provider in each scope, and how many had a known wall — unknown is never 0
|
|
273
|
+
* @type {Record<string, {monthRuns: number, monthKnown: number, totalKnown: number}>} */
|
|
274
|
+
const wallSeen = {};
|
|
275
|
+
/** total tokens of the runs each Providers row served (matched by shape + endpoint + model) @type {Record<string, number>} */
|
|
276
|
+
const tokensByRow = {};
|
|
277
|
+
for (const leg of readLegs({ home: opts.home })) {
|
|
278
|
+
let served = opts.rows ? findRow(opts.rows, { provider: leg.provider, baseUrl: leg.baseUrl, model: leg.model }) : null;
|
|
279
|
+
// an older spine records a model but no provider: it counts toward the row whose Name EXACTLY
|
|
280
|
+
// equals that model. Two rows sharing the Name make it ambiguous, so it counts toward neither.
|
|
281
|
+
if (opts.rows && leg.provider === null && leg.model !== null) {
|
|
282
|
+
const named = opts.rows.filter((r) => r.name === leg.model);
|
|
283
|
+
served = named.length === 1 ? named[0] : null;
|
|
284
|
+
}
|
|
285
|
+
if (served) tokensByRow[served.envName] = (tokensByRow[served.envName] ?? 0) + leg.tokens;
|
|
286
|
+
const inMonth = Number.isNaN(leg.at.getTime()) ? true : sameLocalMonth(leg.at, nowDate);
|
|
287
|
+
const id = rowIdFor(leg.provider, leg.baseUrl);
|
|
288
|
+
// an older spine carries a model but no provider: say so honestly, one row per model —
|
|
289
|
+
// never a provider guessed from the model's name
|
|
290
|
+
const notRecorded = id === null && leg.provider === null;
|
|
291
|
+
const key = id ?? (notRecorded ? `not-recorded:${leg.model ?? ''}` : `other:${leg.provider}${leg.baseUrl ? ` @ ${leg.baseUrl}` : ''}`);
|
|
292
|
+
const label = id ? (MONEY_LABELS.find((r) => r.id === id)?.name ?? id)
|
|
293
|
+
: (notRecorded ? (leg.model ? `not recorded (model ${leg.model})` : 'not recorded') : key.slice('other:'.length));
|
|
294
|
+
const p = (byProvider[key] ??= { label, monthUsd: 0, monthAtLeast: false, totalUsd: 0, totalAtLeast: false, tokens: 0, vouchedRounds: 0, otherRounds: 0, monthWallMs: 0, monthWallAtLeast: false, totalWallMs: 0, totalWallAtLeast: false });
|
|
295
|
+
const w = (wallSeen[key] ??= { monthRuns: 0, monthKnown: 0, totalKnown: 0 });
|
|
296
|
+
const unknown = leg.unreadable || !leg.complete;
|
|
297
|
+
const wallKnown = !leg.unreadable && leg.wallMs !== null;
|
|
298
|
+
const wallAtLeast = !wallKnown || !leg.wallComplete;
|
|
299
|
+
total.usd += leg.usd; total.tokens += leg.tokens; if (unknown) total.atLeast = true;
|
|
300
|
+
p.totalUsd += leg.usd; p.tokens += leg.tokens; p.vouchedRounds += leg.vouchedRounds; p.otherRounds += leg.otherRounds; if (unknown) p.totalAtLeast = true;
|
|
301
|
+
if (wallKnown) { p.totalWallMs = /** @type {number} */ (p.totalWallMs) + /** @type {number} */ (leg.wallMs); w.totalKnown += 1; }
|
|
302
|
+
if (wallAtLeast) p.totalWallAtLeast = true;
|
|
303
|
+
if (inMonth) {
|
|
304
|
+
month.usd += leg.usd; month.tokens += leg.tokens; if (unknown) month.atLeast = true;
|
|
305
|
+
p.monthUsd += leg.usd; if (unknown) p.monthAtLeast = true;
|
|
306
|
+
w.monthRuns += 1;
|
|
307
|
+
if (wallKnown) { p.monthWallMs = /** @type {number} */ (p.monthWallMs) + /** @type {number} */ (leg.wallMs); w.monthKnown += 1; }
|
|
308
|
+
if (wallAtLeast) p.monthWallAtLeast = true;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// runs in scope but not one with a known wall = unknown (null), never a clean 0
|
|
312
|
+
for (const [key, p] of Object.entries(byProvider)) {
|
|
313
|
+
const w = wallSeen[key];
|
|
314
|
+
if (w.totalKnown === 0) p.totalWallMs = null;
|
|
315
|
+
if (w.monthRuns > 0 && w.monthKnown === 0) p.monthWallMs = null;
|
|
316
|
+
}
|
|
317
|
+
return { tokensByRow, total, month, byProvider };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* @typedef {object} MonthlyRoom
|
|
322
|
+
* @property {boolean} ok
|
|
323
|
+
* @property {number|null} leftUsd what is left this month (null = no limit set)
|
|
324
|
+
* @property {number|null} limitUsd the person's monthly limit (null = none)
|
|
325
|
+
* @property {boolean} atLeast the month's spend is a floor, so `leftUsd` is a ceiling
|
|
326
|
+
*/
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The person's monthly limit off a parsed config.json: key absent (or null) = no limit (null);
|
|
330
|
+
* a number above 0 = the limit; ANY other value present is a broken setting and throws
|
|
331
|
+
* `ConfigError` — a limit the file names but this code cannot read never reads as "no limit".
|
|
332
|
+
* @param {Record<string, any>} config
|
|
333
|
+
* @param {string} [home]
|
|
334
|
+
* @returns {number|null}
|
|
335
|
+
*/
|
|
336
|
+
export function monthlyLimitOf(config, home) {
|
|
337
|
+
const limit = config.monthlyLimitUsd;
|
|
338
|
+
if (limit === undefined || limit === null) return null;
|
|
339
|
+
if (typeof limit === 'number' && Number.isFinite(limit) && limit > 0) return limit;
|
|
340
|
+
const shown = typeof limit === 'number' ? String(limit) : JSON.stringify(limit);
|
|
341
|
+
throw new ConfigError(`${configPath(home)} has monthlyLimitUsd = ${shown}, which is not a number above 0 — fix it or remove the line`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Does a run with this $ cap fit in what is left this month (a run still held counts at its full cap)? No limit set (key absent or null) = ok, no check; a limit that is present but not a
|
|
346
|
+
* number above 0 throws `ConfigError`.
|
|
347
|
+
* Compared in whole cents. Also throws `ConfigError` when config.json is unreadable — a gate
|
|
348
|
+
* whose own instrument is broken refuses; it never silently runs with no limit.
|
|
349
|
+
* @param {{ capUsd: number, home?: string, now?: () => number, aboveRunid?: string }} args `aboveRunid`: count only the claims listed above that run (a claim in progress)
|
|
350
|
+
* @returns {MonthlyRoom}
|
|
351
|
+
*/
|
|
352
|
+
export function checkMonthlyRoom({ capUsd, home, now, aboveRunid }) {
|
|
353
|
+
const cfg = readConfig({ home });
|
|
354
|
+
if (cfg.problem) throw new ConfigError(cfg.problem);
|
|
355
|
+
const limit = monthlyLimitOf(cfg.config, home);
|
|
356
|
+
if (limit === null) {
|
|
357
|
+
return { ok: true, leftUsd: null, limitUsd: null, atLeast: false };
|
|
358
|
+
}
|
|
359
|
+
const spent = monthSpend({ home, now, aboveRunid });
|
|
360
|
+
const leftCents = Math.max(0, Math.floor((limit - spent.reservedUsd) * 100 + 1e-6));
|
|
361
|
+
const capCents = Math.ceil((Number.isFinite(capUsd) ? capUsd : 0) * 100 - 1e-6);
|
|
362
|
+
return { ok: capCents <= leftCents, leftUsd: leftCents / 100, limitUsd: limit, atLeast: spent.atLeast };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The ONE refusal text, everywhere (CLI, panel cap note, panel Sign refusal).
|
|
367
|
+
* @param {MonthlyRoom} room
|
|
368
|
+
* @returns {string|null} null when the run fits
|
|
369
|
+
*/
|
|
370
|
+
export function monthlyRefusalText(room) {
|
|
371
|
+
if (room.ok || room.leftUsd === null) return null;
|
|
372
|
+
return `Max $${room.leftUsd.toFixed(2)} (monthly limit)`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Close every claim ABOVE `aboveRunid` (file order) whose process is gone: a pid-bearing run row with no
|
|
377
|
+
* `settled` entry whose pid is not a live bareloop runner gets `{ type: 'settled', by: <closer>, reason:
|
|
378
|
+
* 'process gone', spentUsd: <its spine's floor>, spendComplete: false }` appended — so every claim gets a
|
|
379
|
+
* write-back, attributed. Two runs may both close the same dead claim; readers take the FIRST note.
|
|
380
|
+
* @param {{ home?: string, by: string, aboveRunid?: string }} args `by` = the run doing the closing
|
|
381
|
+
* @returns {number} how many notes were appended
|
|
382
|
+
*/
|
|
383
|
+
export function settleDeadClaims({ home, by, aboveRunid }) {
|
|
384
|
+
const { rows, events } = readRunList({ home });
|
|
385
|
+
const settled = new Set(events.filter((e) => e.type === 'settled').map((e) => e.runid));
|
|
386
|
+
let n = 0;
|
|
387
|
+
for (const row of rowsAbove(rows, aboveRunid)) {
|
|
388
|
+
if (!Number.isInteger(row.pid) || settled.has(row.runid) || isLiveRunner(/** @type {number} */ (row.pid))) continue;
|
|
389
|
+
let spentUsd = 0;
|
|
390
|
+
try { spentUsd = legSpend(parseJsonl(row.spine).records).usd; } catch { /* no spine: nothing was recorded spent */ }
|
|
391
|
+
appendRunEvent({ runid: row.runid, type: 'settled', by, reason: 'process gone', spentUsd, spendComplete: false, at: new Date().toISOString() }, { home });
|
|
392
|
+
n += 1;
|
|
393
|
+
}
|
|
394
|
+
return n;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The CLAIM (hamr 2026-09-30: whoever runs first claims the limit and holds it; no lock file, records
|
|
399
|
+
* only): the run APPENDS its row first (`row` carries its `pid` and `capUsd`), then reads the run list.
|
|
400
|
+
* Only the claims ABOVE its own row count — a live one is held at its cap (a dead one is closed with an
|
|
401
|
+
* attributed note and counts its real spend); rows below yield to it. If `capUsd` fits, it runs; if not
|
|
402
|
+
* it appends `{type:'released', reason:'refused'}` and reports the refusal, nothing spent. No limit set =
|
|
403
|
+
* no claim (`claimed: false`; the caller lists the run as it always did). An unreadable config, or a row
|
|
404
|
+
* that cannot be written, throws `ConfigError` — the gate refuses, never reads "no limit".
|
|
405
|
+
* @param {{ row: import('./runlist.js').RunRow, capUsd: number, home?: string, now?: () => number }} args
|
|
406
|
+
* @returns {{ room: MonthlyRoom, claimed: boolean }}
|
|
407
|
+
*/
|
|
408
|
+
export function claimRun({ row, capUsd, home, now }) {
|
|
409
|
+
const cfg = readConfig({ home });
|
|
410
|
+
if (cfg.problem) throw new ConfigError(cfg.problem);
|
|
411
|
+
if (monthlyLimitOf(cfg.config, home) === null) return { room: { ok: true, leftUsd: null, limitUsd: null, atLeast: false }, claimed: false };
|
|
412
|
+
try { appendRun(row, { home }); } catch (/** @type {any} */ e) {
|
|
413
|
+
throw new ConfigError(`cannot write this run's claim to the run list (${e.message})`);
|
|
414
|
+
}
|
|
415
|
+
settleDeadClaims({ home, by: row.runid, aboveRunid: row.runid });
|
|
416
|
+
const room = checkMonthlyRoom({ capUsd, home, now, aboveRunid: row.runid });
|
|
417
|
+
if (!room.ok) {
|
|
418
|
+
try { appendRunEvent({ runid: row.runid, type: 'released', by: row.runid, reason: 'refused', at: new Date().toISOString() }, { home }); } catch { /* the refusal stands; a stranded row of a dead process is closed by the next run */ }
|
|
419
|
+
return { room, claimed: false };
|
|
420
|
+
}
|
|
421
|
+
return { room, claimed: true };
|
|
422
|
+
}
|
|
@@ -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
|