bareloop 0.30.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 +154 -0
- package/bareloop.context.md +205 -10
- package/bin/bareloop.mjs +0 -1
- package/package.json +1 -1
- package/src/authorflow.js +22 -42
- package/src/authorjob.js +5 -8
- package/src/authorrun.js +42 -16
- 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 +328 -0
- package/src/panel/authorsession.js +555 -0
- package/src/panel/index.html +1247 -50
- package/src/panel/server.js +449 -51
- package/src/panel/settingsroutes.js +230 -0
- package/src/planrun.js +61 -5
- package/src/promptregisters.js +3 -0
- package/src/providerrows.js +217 -0
- package/src/providers.js +84 -0
- package/src/replay.js +80 -2
- package/src/replayio.js +14 -3
- package/src/run.js +48 -2
- package/src/runlist.js +13 -0
- package/src/userrun.js +134 -13
- package/types/authorflow.d.ts +4 -21
- 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 +67 -0
- package/types/panel/authorsession.d.ts +53 -0
- package/types/panel/server.d.ts +42 -5
- package/types/panel/settingsroutes.d.ts +14 -0
- package/types/providerrows.d.ts +164 -0
- package/types/providers.d.ts +34 -0
- package/types/replay.d.ts +31 -0
- package/types/replayio.d.ts +12 -1
- package/types/run.d.ts +26 -1
- package/types/runlist.d.ts +1 -0
- package/types/userrun.d.ts +22 -0
package/src/monthly.js
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
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
|
+
// A run still IN FLIGHT (a spine with `job-start`, no `job-end`, and a spine file written to
|
|
12
|
+
// within DIED_MTIME_MS — the panel's own died rule) is counted at its full leg cap, not its
|
|
13
|
+
// spend so far: `checkMonthlyRoom` reserves what the run may still spend, so two runs cannot
|
|
14
|
+
// both start against a limit only one of them fits. A died run (stale spine) counts its floor.
|
|
15
|
+
// The one exception is the spine a RESUME continues (`resumingSpine`): it counts its real spend
|
|
16
|
+
// only, because the resume's own leg cap is the remainder and already covers its unspent cap.
|
|
17
|
+
// Only the refusal check reserves; the Money tab keeps showing real spend (`usd`).
|
|
18
|
+
//
|
|
19
|
+
// Honesty: a run whose spend is not fully known (a died/still-running spine, an unpriced
|
|
20
|
+
// round, a missing spine file) makes the month total an "at least" figure — never a clean
|
|
21
|
+
// number. The refusal text stays exactly `Max $X (monthly limit)`; `atLeast` travels beside it.
|
|
22
|
+
import { existsSync, statSync } from 'node:fs';
|
|
23
|
+
import { resolve } from 'node:path';
|
|
24
|
+
import { readConfig, configPath, ConfigError } from './config.js';
|
|
25
|
+
import { readRunList, DIED_MTIME_MS } from './runlist.js';
|
|
26
|
+
import { parseJsonl } from './replayio.js';
|
|
27
|
+
import { SPEND_RECORD_TYPES, spendProvenance, floorsFromRecords } from './ledger.js';
|
|
28
|
+
import { findRow } from './providerrows.js';
|
|
29
|
+
|
|
30
|
+
// The Money tab's per-provider breakdown groups a run by the endpoint it ran on and names
|
|
31
|
+
// the groups it knows the vendor of. This is HISTORY labelling for spend already recorded
|
|
32
|
+
// (an archived run keeps its provider whatever Settings holds today) — not the Providers
|
|
33
|
+
// rows, which come from the keys file (src/providerrows.js).
|
|
34
|
+
/** @type {readonly {id: string, name: string}[]} */
|
|
35
|
+
const MONEY_LABELS = Object.freeze([
|
|
36
|
+
{ id: 'anthropic', name: 'Anthropic' }, { id: 'openai', name: 'OpenAI' },
|
|
37
|
+
{ id: 'gemini', name: 'Gemini' }, { id: 'deepseek', name: 'DeepSeek' },
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Which known vendor a run's `(provider, baseUrl)` belongs to. `openai-api` with no override
|
|
42
|
+
* is OpenAI; with DeepSeek's host it is DeepSeek; with any OTHER override it is none (null —
|
|
43
|
+
* never silently pooled into OpenAI's figures).
|
|
44
|
+
* @param {string|null|undefined} provider
|
|
45
|
+
* @param {string|null|undefined} baseUrl
|
|
46
|
+
* @returns {string|null} a MONEY_LABELS id, or null
|
|
47
|
+
*/
|
|
48
|
+
function rowIdFor(provider, baseUrl) {
|
|
49
|
+
if (provider === 'anthropic-api') return 'anthropic';
|
|
50
|
+
if (provider === 'gemini-api') return 'gemini';
|
|
51
|
+
if (provider === 'openai-api') {
|
|
52
|
+
if (!baseUrl) return 'openai';
|
|
53
|
+
try {
|
|
54
|
+
const host = new URL(baseUrl).hostname;
|
|
55
|
+
if (host === 'api.deepseek.com') return 'deepseek';
|
|
56
|
+
} catch { /* an unparseable override is no known vendor */ }
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* One run's own spend, off its spine records: this LEG's figure (never the chain fold),
|
|
64
|
+
* plus the drafting spend the run was signed with (counted once — on the first leg only).
|
|
65
|
+
* @param {any[]} records
|
|
66
|
+
* @returns {{ usd: number, complete: boolean }}
|
|
67
|
+
*/
|
|
68
|
+
export function legSpend(records) {
|
|
69
|
+
const jobStart = records.find((r) => r && r.type === 'job-start') ?? null;
|
|
70
|
+
const jobEnd = records.findLast((r) => r && r.type === 'job-end') ?? null;
|
|
71
|
+
const rounds = records.filter((r) => r && SPEND_RECORD_TYPES.includes(r.type));
|
|
72
|
+
const pricedSum = rounds.reduce((a, r) => (typeof r.costUsd === 'number' && Number.isFinite(r.costUsd) ? a + r.costUsd : a), 0);
|
|
73
|
+
const unpriced = rounds.filter((r) => !(typeof r.costUsd === 'number' && Number.isFinite(r.costUsd))).length;
|
|
74
|
+
let usd = pricedSum;
|
|
75
|
+
let complete = false;
|
|
76
|
+
if (jobEnd) {
|
|
77
|
+
const eng = jobEnd.engagementSpentUsd;
|
|
78
|
+
usd = typeof eng === 'number' && Number.isFinite(eng) ? eng : pricedSum;
|
|
79
|
+
complete = jobEnd.spendComplete === true && unpriced === 0;
|
|
80
|
+
}
|
|
81
|
+
// drafting spend: a RESUMED / door-rerun leg carries the flag again but the first leg already counted it
|
|
82
|
+
const resumed = !!jobStart && 'priorSpentUsd' in jobStart;
|
|
83
|
+
if (jobStart && !resumed && typeof jobStart.draftSpentUsd === 'number' && Number.isFinite(jobStart.draftSpentUsd) && jobStart.draftSpentUsd > 0) {
|
|
84
|
+
usd += jobStart.draftSpentUsd;
|
|
85
|
+
if (jobStart.draftSpendComplete === false) complete = false;
|
|
86
|
+
}
|
|
87
|
+
return { usd, complete };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One run's wall time off its spine: job-start -> job-end when the run ended (exact), else the
|
|
92
|
+
* first -> last record floor (the SAME floor a died / still-running run's "at least" wall uses,
|
|
93
|
+
* `floorsFromRecords`). `ms` null = unknown — never 0.
|
|
94
|
+
* @param {any[]} records
|
|
95
|
+
* @returns {{ ms: number|null, complete: boolean }}
|
|
96
|
+
*/
|
|
97
|
+
export function legWall(records) {
|
|
98
|
+
const jobStart = records.find((r) => r && r.type === 'job-start') ?? null;
|
|
99
|
+
const jobEnd = records.findLast((r) => r && r.type === 'job-end') ?? null;
|
|
100
|
+
const a = Date.parse(jobStart?.ts);
|
|
101
|
+
const b = Date.parse(jobEnd?.ts);
|
|
102
|
+
if (jobEnd && Number.isFinite(a) && Number.isFinite(b) && b >= a) return { ms: b - a, complete: true };
|
|
103
|
+
return { ms: floorsFromRecords(records).wallFloorMs, complete: false };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Tokens a spine's rounds processed (input + output + cache read + cache write — all four,
|
|
108
|
+
* so a cache-heavy run is not under-reported), off the real `usage` field names.
|
|
109
|
+
* @param {any[]} records
|
|
110
|
+
* @returns {number}
|
|
111
|
+
*/
|
|
112
|
+
export function legTokens(records) {
|
|
113
|
+
let n = 0;
|
|
114
|
+
for (const r of records) {
|
|
115
|
+
if (!r || !SPEND_RECORD_TYPES.includes(r.type) || !r.usage) continue;
|
|
116
|
+
for (const k of ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheCreationTokens']) {
|
|
117
|
+
const v = r.usage[k];
|
|
118
|
+
if (typeof v === 'number' && Number.isFinite(v)) n += v;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return n;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @typedef {object} Leg
|
|
126
|
+
* @property {Date} at when the run was listed
|
|
127
|
+
* @property {string|null} provider job-start's provider (null = an older spine that carried none)
|
|
128
|
+
* @property {string|null} baseUrl
|
|
129
|
+
* @property {number} usd this leg's spend (a floor when `!complete`)
|
|
130
|
+
* @property {boolean} complete every figure behind `usd` is known
|
|
131
|
+
* @property {number} tokens
|
|
132
|
+
* @property {string|null} model job-start's model (older spines carry a model but no provider)
|
|
133
|
+
* @property {number|null} wallMs this leg's wall time (null = unknown; a floor when `!wallComplete`)
|
|
134
|
+
* @property {boolean} wallComplete the wall is the exact job-start -> job-end span
|
|
135
|
+
* @property {number} vouchedRounds rounds priced by a rate somebody vouched for (`provider`/`caller`)
|
|
136
|
+
* @property {number} otherRounds every other round: a built-in guess, unpriced, or no provenance on record
|
|
137
|
+
* @property {number} reservedUsd what the refusal check counts: `usd`, or the leg's cap when the run is in flight
|
|
138
|
+
* @property {boolean} unreadable the spine was missing/unreadable or the listed date was bad (spend unknown)
|
|
139
|
+
*/
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* What the refusal check counts for one run: its spend, unless it is IN FLIGHT (`job-start`, no
|
|
143
|
+
* `job-end`, spine written to within DIED_MTIME_MS of `nowMs`), when it is the larger of that
|
|
144
|
+
* spend and the leg's own cap — the signed `budgetUsd` on the spine's `job-start` less the fold
|
|
145
|
+
* a resumed leg inherited (`priorSpentUsd`), the same figure the run-start seam computes
|
|
146
|
+
* (`legCapUsd`, src/userrun.js). A spine that records no `budgetUsd` reserves nothing extra.
|
|
147
|
+
* @param {any[]} records
|
|
148
|
+
* @param {string} spinePath
|
|
149
|
+
* @param {number} nowMs
|
|
150
|
+
* @param {number} usd the leg's spend so far
|
|
151
|
+
* @returns {number}
|
|
152
|
+
*/
|
|
153
|
+
function inFlightCapUsd(records, spinePath, nowMs, usd) {
|
|
154
|
+
if (records.some((r) => r && r.type === 'job-end')) return usd;
|
|
155
|
+
const start = records.find((r) => r && r.type === 'job-start') ?? null;
|
|
156
|
+
if (!start || !(typeof start.budgetUsd === 'number' && Number.isFinite(start.budgetUsd))) return usd;
|
|
157
|
+
let mtimeMs;
|
|
158
|
+
try { mtimeMs = statSync(spinePath).mtimeMs; } catch { return usd; }
|
|
159
|
+
if (nowMs - mtimeMs > DIED_MTIME_MS) return usd; // stale — died, counts its floor
|
|
160
|
+
const fold = typeof start.priorSpentUsd === 'number' && Number.isFinite(start.priorSpentUsd) ? start.priorSpentUsd : 0;
|
|
161
|
+
return Math.max(usd, Math.max(0, start.budgetUsd - fold));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Every listed run as one {@link Leg}. The ONE reader the month total, the all-time total
|
|
166
|
+
* and the per-provider figures share.
|
|
167
|
+
* @param {{ home?: string, now?: () => number, resumingSpine?: string|null }} [opts] `now` = the clock for the in-flight test; `resumingSpine` = the spine a resume continues: that leg reserves its spend only, never its unspent cap (the resume's own leg cap is that remainder)
|
|
168
|
+
* @returns {Leg[]}
|
|
169
|
+
*/
|
|
170
|
+
export function readLegs(opts = {}) {
|
|
171
|
+
const nowMs = (opts.now ?? Date.now)();
|
|
172
|
+
const { rows } = readRunList({ home: opts.home });
|
|
173
|
+
const resuming = typeof opts.resumingSpine === 'string' ? resolve(opts.resumingSpine) : null;
|
|
174
|
+
/** @type {Leg[]} */
|
|
175
|
+
const legs = [];
|
|
176
|
+
for (const row of rows) {
|
|
177
|
+
const at = new Date(row.at);
|
|
178
|
+
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 };
|
|
179
|
+
if (Number.isNaN(at.getTime()) || !existsSync(row.spine)) { legs.push(base); continue; }
|
|
180
|
+
/** @type {any[]} */
|
|
181
|
+
let records;
|
|
182
|
+
try { records = parseJsonl(row.spine).records; } catch { legs.push(base); continue; }
|
|
183
|
+
const start = records.find((r) => r && r.type === 'job-start') ?? null;
|
|
184
|
+
const leg = legSpend(records);
|
|
185
|
+
const prov = spendProvenance(records);
|
|
186
|
+
const wall = legWall(records);
|
|
187
|
+
legs.push({
|
|
188
|
+
at,
|
|
189
|
+
model: typeof start?.model === 'string' ? start.model : null,
|
|
190
|
+
wallMs: wall.ms,
|
|
191
|
+
wallComplete: wall.complete,
|
|
192
|
+
provider: typeof start?.provider === 'string' ? start.provider : null,
|
|
193
|
+
baseUrl: typeof start?.baseUrl === 'string' ? start.baseUrl : null,
|
|
194
|
+
usd: leg.usd,
|
|
195
|
+
complete: leg.complete,
|
|
196
|
+
tokens: legTokens(records),
|
|
197
|
+
vouchedRounds: prov.vouched.rounds,
|
|
198
|
+
otherRounds: prov.guessed.rounds + prov.unpriced.rounds + prov.unknown.rounds,
|
|
199
|
+
reservedUsd: resuming !== null && resolve(row.spine) === resuming ? leg.usd : inFlightCapUsd(records, row.spine, nowMs, leg.usd),
|
|
200
|
+
unreadable: false,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return legs;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @param {Date} at
|
|
208
|
+
* @param {Date} now
|
|
209
|
+
* @returns {boolean} same LOCAL calendar month
|
|
210
|
+
*/
|
|
211
|
+
const sameLocalMonth = (at, now) => at.getFullYear() === now.getFullYear() && at.getMonth() === now.getMonth();
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* @param {{ home?: string, now?: () => number, resumingSpine?: string|null }} [opts] `resumingSpine`: see {@link readLegs}
|
|
215
|
+
* @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)
|
|
216
|
+
*/
|
|
217
|
+
export function monthSpend(opts = {}) {
|
|
218
|
+
const nowDate = new Date((opts.now ?? Date.now)());
|
|
219
|
+
let usd = 0;
|
|
220
|
+
let reservedUsd = 0;
|
|
221
|
+
let atLeast = false;
|
|
222
|
+
let runs = 0;
|
|
223
|
+
for (const leg of readLegs({ home: opts.home, now: opts.now, resumingSpine: opts.resumingSpine })) {
|
|
224
|
+
// an unreadable date could belong to this month — unknown, never dropped
|
|
225
|
+
if (Number.isNaN(leg.at.getTime())) { atLeast = true; continue; }
|
|
226
|
+
if (!sameLocalMonth(leg.at, nowDate)) continue;
|
|
227
|
+
runs += 1;
|
|
228
|
+
if (leg.unreadable) { atLeast = true; continue; }
|
|
229
|
+
usd += leg.usd;
|
|
230
|
+
reservedUsd += leg.reservedUsd;
|
|
231
|
+
if (!leg.complete) atLeast = true;
|
|
232
|
+
}
|
|
233
|
+
return { usd, atLeast, runs, reservedUsd };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The Money tab's figures: all-time and this-month spend, and per-provider breakdown
|
|
238
|
+
* (rows from `rowIdFor`; a run on no known row lands under its own provider name, never
|
|
239
|
+
* pooled into another row). Any leg with unknown spend makes the figure it belongs to
|
|
240
|
+
* an "at least".
|
|
241
|
+
* @param {{ home?: string, now?: () => number, rows?: readonly import('./providerrows.js').KeyRow[] }} [opts] `rows` = the Providers rows: when given, `tokensByRow` sums each row's tokens
|
|
242
|
+
* @returns {{ tokensByRow: Record<string, number>, total: {usd: number, atLeast: boolean, tokens: number}, month: {usd: number, atLeast: boolean, tokens: number},
|
|
243
|
+
* 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}> }}
|
|
244
|
+
*/
|
|
245
|
+
export function spendSummary(opts = {}) {
|
|
246
|
+
const nowDate = new Date((opts.now ?? Date.now)());
|
|
247
|
+
const total = { usd: 0, atLeast: false, tokens: 0 };
|
|
248
|
+
const month = { usd: 0, atLeast: false, tokens: 0 };
|
|
249
|
+
/** @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}>} */
|
|
250
|
+
const byProvider = {};
|
|
251
|
+
/** runs per provider in each scope, and how many had a known wall — unknown is never 0
|
|
252
|
+
* @type {Record<string, {monthRuns: number, monthKnown: number, totalKnown: number}>} */
|
|
253
|
+
const wallSeen = {};
|
|
254
|
+
/** total tokens of the runs each Providers row served (matched by shape + endpoint + model) @type {Record<string, number>} */
|
|
255
|
+
const tokensByRow = {};
|
|
256
|
+
for (const leg of readLegs({ home: opts.home })) {
|
|
257
|
+
let served = opts.rows ? findRow(opts.rows, { provider: leg.provider, baseUrl: leg.baseUrl, model: leg.model }) : null;
|
|
258
|
+
// an older spine records a model but no provider: it counts toward the row whose Name EXACTLY
|
|
259
|
+
// equals that model. Two rows sharing the Name make it ambiguous, so it counts toward neither.
|
|
260
|
+
if (opts.rows && leg.provider === null && leg.model !== null) {
|
|
261
|
+
const named = opts.rows.filter((r) => r.name === leg.model);
|
|
262
|
+
served = named.length === 1 ? named[0] : null;
|
|
263
|
+
}
|
|
264
|
+
if (served) tokensByRow[served.envName] = (tokensByRow[served.envName] ?? 0) + leg.tokens;
|
|
265
|
+
const inMonth = Number.isNaN(leg.at.getTime()) ? true : sameLocalMonth(leg.at, nowDate);
|
|
266
|
+
const id = rowIdFor(leg.provider, leg.baseUrl);
|
|
267
|
+
// an older spine carries a model but no provider: say so honestly, one row per model —
|
|
268
|
+
// never a provider guessed from the model's name
|
|
269
|
+
const notRecorded = id === null && leg.provider === null;
|
|
270
|
+
const key = id ?? (notRecorded ? `not-recorded:${leg.model ?? ''}` : `other:${leg.provider}${leg.baseUrl ? ` @ ${leg.baseUrl}` : ''}`);
|
|
271
|
+
const label = id ? (MONEY_LABELS.find((r) => r.id === id)?.name ?? id)
|
|
272
|
+
: (notRecorded ? (leg.model ? `not recorded (model ${leg.model})` : 'not recorded') : key.slice('other:'.length));
|
|
273
|
+
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 });
|
|
274
|
+
const w = (wallSeen[key] ??= { monthRuns: 0, monthKnown: 0, totalKnown: 0 });
|
|
275
|
+
const unknown = leg.unreadable || !leg.complete;
|
|
276
|
+
const wallKnown = !leg.unreadable && leg.wallMs !== null;
|
|
277
|
+
const wallAtLeast = !wallKnown || !leg.wallComplete;
|
|
278
|
+
total.usd += leg.usd; total.tokens += leg.tokens; if (unknown) total.atLeast = true;
|
|
279
|
+
p.totalUsd += leg.usd; p.tokens += leg.tokens; p.vouchedRounds += leg.vouchedRounds; p.otherRounds += leg.otherRounds; if (unknown) p.totalAtLeast = true;
|
|
280
|
+
if (wallKnown) { p.totalWallMs = /** @type {number} */ (p.totalWallMs) + /** @type {number} */ (leg.wallMs); w.totalKnown += 1; }
|
|
281
|
+
if (wallAtLeast) p.totalWallAtLeast = true;
|
|
282
|
+
if (inMonth) {
|
|
283
|
+
month.usd += leg.usd; month.tokens += leg.tokens; if (unknown) month.atLeast = true;
|
|
284
|
+
p.monthUsd += leg.usd; if (unknown) p.monthAtLeast = true;
|
|
285
|
+
w.monthRuns += 1;
|
|
286
|
+
if (wallKnown) { p.monthWallMs = /** @type {number} */ (p.monthWallMs) + /** @type {number} */ (leg.wallMs); w.monthKnown += 1; }
|
|
287
|
+
if (wallAtLeast) p.monthWallAtLeast = true;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// runs in scope but not one with a known wall = unknown (null), never a clean 0
|
|
291
|
+
for (const [key, p] of Object.entries(byProvider)) {
|
|
292
|
+
const w = wallSeen[key];
|
|
293
|
+
if (w.totalKnown === 0) p.totalWallMs = null;
|
|
294
|
+
if (w.monthRuns > 0 && w.monthKnown === 0) p.monthWallMs = null;
|
|
295
|
+
}
|
|
296
|
+
return { tokensByRow, total, month, byProvider };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* @typedef {object} MonthlyRoom
|
|
301
|
+
* @property {boolean} ok
|
|
302
|
+
* @property {number|null} leftUsd what is left this month (null = no limit set)
|
|
303
|
+
* @property {number|null} limitUsd the person's monthly limit (null = none)
|
|
304
|
+
* @property {boolean} atLeast the month's spend is a floor, so `leftUsd` is a ceiling
|
|
305
|
+
*/
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The person's monthly limit off a parsed config.json: key absent (or null) = no limit (null);
|
|
309
|
+
* a number above 0 = the limit; ANY other value present is a broken setting and throws
|
|
310
|
+
* `ConfigError` — a limit the file names but this code cannot read never reads as "no limit".
|
|
311
|
+
* @param {Record<string, any>} config
|
|
312
|
+
* @param {string} [home]
|
|
313
|
+
* @returns {number|null}
|
|
314
|
+
*/
|
|
315
|
+
export function monthlyLimitOf(config, home) {
|
|
316
|
+
const limit = config.monthlyLimitUsd;
|
|
317
|
+
if (limit === undefined || limit === null) return null;
|
|
318
|
+
if (typeof limit === 'number' && Number.isFinite(limit) && limit > 0) return limit;
|
|
319
|
+
const shown = typeof limit === 'number' ? String(limit) : JSON.stringify(limit);
|
|
320
|
+
throw new ConfigError(`${configPath(home)} has monthlyLimitUsd = ${shown}, which is not a number above 0 — fix it or remove the line`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Does a run with this $ cap fit in what is left this month (running jobs counted at their full cap, except the spine a resume continues — `resumingSpine` — which counts its real spend)? No limit set (key absent or null) = ok, no check; a limit that is present but not a
|
|
325
|
+
* number above 0 throws `ConfigError`.
|
|
326
|
+
* Compared in whole cents. Also throws `ConfigError` when config.json is unreadable — a gate
|
|
327
|
+
* whose own instrument is broken refuses; it never silently runs with no limit.
|
|
328
|
+
* @param {{ capUsd: number, home?: string, now?: () => number, resumingSpine?: string|null }} args
|
|
329
|
+
* @returns {MonthlyRoom}
|
|
330
|
+
*/
|
|
331
|
+
export function checkMonthlyRoom({ capUsd, home, now, resumingSpine }) {
|
|
332
|
+
const cfg = readConfig({ home });
|
|
333
|
+
if (cfg.problem) throw new ConfigError(cfg.problem);
|
|
334
|
+
const limit = monthlyLimitOf(cfg.config, home);
|
|
335
|
+
if (limit === null) {
|
|
336
|
+
return { ok: true, leftUsd: null, limitUsd: null, atLeast: false };
|
|
337
|
+
}
|
|
338
|
+
const spent = monthSpend({ home, now, resumingSpine });
|
|
339
|
+
const leftCents = Math.max(0, Math.floor((limit - spent.reservedUsd) * 100 + 1e-6));
|
|
340
|
+
const capCents = Math.ceil((Number.isFinite(capUsd) ? capUsd : 0) * 100 - 1e-6);
|
|
341
|
+
return { ok: capCents <= leftCents, leftUsd: leftCents / 100, limitUsd: limit, atLeast: spent.atLeast };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The ONE refusal text, everywhere (CLI, panel cap note, panel Sign refusal).
|
|
346
|
+
* @param {MonthlyRoom} room
|
|
347
|
+
* @returns {string|null} null when the run fits
|
|
348
|
+
*/
|
|
349
|
+
export function monthlyRefusalText(room) {
|
|
350
|
+
if (room.ok || room.leftUsd === null) return null;
|
|
351
|
+
return `Max $${room.leftUsd.toFixed(2)} (monthly limit)`;
|
|
352
|
+
}
|