agent-dag 1.43.0 → 1.45.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/README.md +13 -7
- package/bin/agent-dag.js +87 -9
- package/bin/deck.js +133 -22
- package/dist/web/assets/index-Bdl1LX0-.css +1 -0
- package/dist/web/assets/index-jXBjwwZC.js +89 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +120 -31
- package/package.json +2 -2
- package/src/server/args.mjs +113 -15
- package/src/server/ccusage.mjs +105 -1
- package/src/server/claude-accounts.mjs +145 -1
- package/src/server/codex-auth.mjs +9 -4
- package/src/server/codex-quota.mjs +95 -3
- package/src/server/codex-usage.mjs +163 -9
- package/src/server/cswap-admin.mjs +346 -40
- package/src/server/cswap-auto.mjs +365 -12
- package/src/server/cswap-install.mjs +238 -17
- package/src/server/exec.mjs +233 -26
- package/src/server/index.mjs +1994 -157
- package/src/server/installer.mjs +173 -11
- package/src/server/invoked-as.mjs +16 -14
- package/src/server/quota.mjs +131 -34
- package/src/server/retire-sound-hook.mjs +315 -0
- package/src/server/self-update.mjs +262 -21
- package/src/server/supervisor.mjs +103 -0
- package/src/server/system-metrics.mjs +105 -7
- package/src/server/uv-bootstrap.mjs +43 -11
- package/dist/web/assets/index-BxAZQc7O.css +0 -1
- package/dist/web/assets/index-DRgZVqF-.js +0 -78
- package/hook/notify.js +0 -60
- package/src/server/sound-hook.mjs +0 -390
|
@@ -48,6 +48,85 @@ let _cacheAt = 0;
|
|
|
48
48
|
// tracks what claude-swap is doing. No network cost to amortise.
|
|
49
49
|
const CACHE_MS = 5_000;
|
|
50
50
|
|
|
51
|
+
// ── what a forced read may cost ──────────────────────────────────────────────
|
|
52
|
+
//
|
|
53
|
+
// Until #604 the cache above was the whole of the admission control here, and
|
|
54
|
+
// `force` walked straight past it. `handleClaudeAccounts` reads `refresh=1` off
|
|
55
|
+
// the query string and passes it through, and reads on this server are
|
|
56
|
+
// deliberately open — `isTrustedRead` does not apply the `Sec-Fetch-Site` test
|
|
57
|
+
// that `isTrustedMutation` does, because a cross-site read of
|
|
58
|
+
// `http://127.0.0.1:4317` is an ordinary top-level navigation — so any page the
|
|
59
|
+
// user had open could run a `?refresh=1` loop and get one roster read per
|
|
60
|
+
// request, concurrently.
|
|
61
|
+
//
|
|
62
|
+
// What that buys is the cheapest of the six forcible routes and it is worth
|
|
63
|
+
// saying so plainly rather than dressing it up: two small local JSON reads,
|
|
64
|
+
// sequence.json and cache/usage.json, plus a call into nudgeCollector that is
|
|
65
|
+
// throttled on its own terms and spawns nothing when nothing is due. There is no
|
|
66
|
+
// network here by design — see the note at the top of this file — and no
|
|
67
|
+
// subprocess per request. This is the SHAPE the four routes before it were fixed
|
|
68
|
+
// for, not a cost anyone would have noticed.
|
|
69
|
+
//
|
|
70
|
+
// The reason to fix it anyway is the second half. This is the only one of the
|
|
71
|
+
// six whose cache is invalidated from elsewhere — nine call sites in four
|
|
72
|
+
// modules: six mutations in cswap-admin.mjs, an auto-switch in cswap-auto.mjs, a
|
|
73
|
+
// manual one in index.mjs, and the first `cswap add` below — and #582 has
|
|
74
|
+
// already shown what a read that started before an invalidation does when it
|
|
75
|
+
// lands after one. So the in-flight slot this route was missing arrives with the
|
|
76
|
+
// generation guard that makes it safe, rather than after the next bug report.
|
|
77
|
+
const FORCE_POLL_MS = 60_000;
|
|
78
|
+
|
|
79
|
+
// A read in progress, offered to callers that arrive while it is running.
|
|
80
|
+
let _inflight = null;
|
|
81
|
+
// Stamped when a read STARTS rather than when it lands: what the floor rations
|
|
82
|
+
// is the trip to disk, and one that is still running has already been paid for.
|
|
83
|
+
let _lastReadAt = 0;
|
|
84
|
+
// Which roster the reading below is about — as a counter, because the answer is
|
|
85
|
+
// about whichever account claude-swap's store says is active, and that can move
|
|
86
|
+
// under a read that is already running. invalidateClaudeAccountsCache bumps it;
|
|
87
|
+
// every write is stamped with the value that was current when the read STARTED.
|
|
88
|
+
// quota.mjs's `_generation`, for quota.mjs's reason (#582).
|
|
89
|
+
let _generation = 0;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Whether we may go to disk for the roster again.
|
|
93
|
+
*
|
|
94
|
+
* The same shape and the same minute as quota.mjs's `maySelfPoll`, and exported
|
|
95
|
+
* for the same reason it is: this is the rule, it is pure, and it belongs
|
|
96
|
+
* somewhere a test can point at it.
|
|
97
|
+
*
|
|
98
|
+
* Two intervals, like `maySelfPoll`'s. A forced read may beat the cache, but not
|
|
99
|
+
* turn into a poll loop when the button is held down, so it takes the minute the
|
|
100
|
+
* other four forcible routes use. An unforced read takes the cache's own
|
|
101
|
+
* interval — it is the panel's ordinary poll, the cache above has already
|
|
102
|
+
* answered it, and measuring from the START of the last read rather than from
|
|
103
|
+
* its end is the only difference between the two rules.
|
|
104
|
+
*/
|
|
105
|
+
export function mayReadAccounts({ now, force, lastReadAt }) {
|
|
106
|
+
return now - lastReadAt >= (force ? FORCE_POLL_MS : CACHE_MS);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The answer to a read the floor refused.
|
|
111
|
+
*
|
|
112
|
+
* A reading, not an error. AccountsPanel renders `data.accounts`, and an
|
|
113
|
+
* `{ ok: false }` refusal would empty the roster for a minute — the deck
|
|
114
|
+
* teaching itself a new failure mode in order to defend against a loop nobody
|
|
115
|
+
* ran. `stale` is the flag quota.mjs, codex-quota.mjs and codex-usage.mjs all
|
|
116
|
+
* use for exactly this, and every account row already carries its own
|
|
117
|
+
* `fetchedAt` from claude-swap's store, which is the age the panel draws and
|
|
118
|
+
* which nothing here touches.
|
|
119
|
+
*/
|
|
120
|
+
function heldReading(now) {
|
|
121
|
+
if (_cache) return { ..._cache, stale: true };
|
|
122
|
+
// Unreachable in practice, and spelled the way codex-quota.mjs and
|
|
123
|
+
// codex-usage.mjs spell the same state. Every outcome below is cached, a read
|
|
124
|
+
// still running is served by `_inflight`, and the one moment `_cache` is empty
|
|
125
|
+
// with a recent stamp — just after an invalidation — is exactly the moment
|
|
126
|
+
// invalidateClaudeAccountsCache clears the stamp as well.
|
|
127
|
+
return { ok: false, reason: "waiting", fetchedAt: now };
|
|
128
|
+
}
|
|
129
|
+
|
|
51
130
|
// Past this, claude-swap's own numbers are old enough that showing them
|
|
52
131
|
// without a marker would misrepresent them (its own trust ceiling is 3600s).
|
|
53
132
|
const STALE_AFTER_MS = 15 * 60_000;
|
|
@@ -255,8 +334,51 @@ function lane(id, label, win) {
|
|
|
255
334
|
export async function fetchClaudeAccounts({ force = false } = {}) {
|
|
256
335
|
const now = Date.now();
|
|
257
336
|
if (!force && _cache && now - _cacheAt < CACHE_MS) return _cache;
|
|
337
|
+
// Offered before the floor: a read that has not finished yet is a reading
|
|
338
|
+
// newer than the cache, which is what refresh asked for, and joining it costs
|
|
339
|
+
// nothing.
|
|
340
|
+
if (_inflight) return _inflight;
|
|
341
|
+
if (!mayReadAccounts({ now, force, lastReadAt: _lastReadAt })) return heldReading(now);
|
|
342
|
+
_lastReadAt = now;
|
|
343
|
+
|
|
344
|
+
// `_inflight === mine` rather than a bare clear, which is quota.mjs's guard
|
|
345
|
+
// and is here for quota.mjs's reason: invalidateClaudeAccountsCache drops
|
|
346
|
+
// `_inflight` so the next caller starts a read that knows the roster moved,
|
|
347
|
+
// and that read installs its own promise here. A read from before the switch
|
|
348
|
+
// finishing afterwards would otherwise clear the NEW one on its way out.
|
|
349
|
+
const mine = readRoster(now, _generation)
|
|
350
|
+
.finally(() => { if (_inflight === mine) _inflight = null; });
|
|
351
|
+
_inflight = mine;
|
|
352
|
+
return mine;
|
|
353
|
+
}
|
|
258
354
|
|
|
259
|
-
|
|
355
|
+
/**
|
|
356
|
+
* The read itself, split out from the admission control above it so the guard is
|
|
357
|
+
* readable as the four lines it is.
|
|
358
|
+
*
|
|
359
|
+
* `gen` is the generation that was current when this read STARTED, and finish()
|
|
360
|
+
* refuses to write anything under it once that has moved.
|
|
361
|
+
*
|
|
362
|
+
* Every write here happens after at least one await, and
|
|
363
|
+
* invalidateClaudeAccountsCache clears variables — which does nothing to a
|
|
364
|
+
* function that is already running and still holds the old roster in a local. So
|
|
365
|
+
* a switch landing mid-read was followed, milliseconds later, by the pre-switch
|
|
366
|
+
* roster being written straight back over the cleared cache: the invalidation
|
|
367
|
+
* looked like it worked and was undone before the next poll could observe it,
|
|
368
|
+
* and the panel went on showing the account the user had just switched away
|
|
369
|
+
* from. That is #582's defect, in the module #582 did not touch.
|
|
370
|
+
*
|
|
371
|
+
* The read is deliberately not cancelled. Whoever asked for it is still owed an
|
|
372
|
+
* answer, and the answer is not wrong — it is about a roster that has since
|
|
373
|
+
* moved, which makes it a fine return value and a bad cached one.
|
|
374
|
+
*/
|
|
375
|
+
async function readRoster(now, gen) {
|
|
376
|
+
const finish = (r) => {
|
|
377
|
+
if (gen !== _generation) return r;
|
|
378
|
+
_cache = r;
|
|
379
|
+
_cacheAt = Date.now();
|
|
380
|
+
return r;
|
|
381
|
+
};
|
|
260
382
|
|
|
261
383
|
const root = backupRoot();
|
|
262
384
|
const seq = await readJson(join(root, "sequence.json"));
|
|
@@ -395,9 +517,31 @@ export async function requestCollection() {
|
|
|
395
517
|
return _lastNudge !== before;
|
|
396
518
|
}
|
|
397
519
|
|
|
520
|
+
/**
|
|
521
|
+
* Forget the roster, because something just made it wrong.
|
|
522
|
+
*
|
|
523
|
+
* Called from nine places in four modules — every `cswap` mutation the deck
|
|
524
|
+
* performs — and every one of them is behind a POST that `isTrustedMutation`
|
|
525
|
+
* guards, so nothing a page can send in a loop reaches this.
|
|
526
|
+
*
|
|
527
|
+
* Three things go besides the reading itself:
|
|
528
|
+
*
|
|
529
|
+
* `_generation` so a read that STARTED before this call cannot write its
|
|
530
|
+
* answer into the cache afterwards. See finish() in readRoster.
|
|
531
|
+
* `_inflight` so a caller arriving after the switch is not handed the read
|
|
532
|
+
* that began before it — joining a run is only free when the
|
|
533
|
+
* run is still about the right thing.
|
|
534
|
+
* `_lastReadAt` so the very next read is real work rather than a refusal.
|
|
535
|
+
* Every one of these call sites is followed by the panel
|
|
536
|
+
* reloading with ?refresh=1, and a floor that answered THAT
|
|
537
|
+
* with the pre-switch roster would make the guard the bug.
|
|
538
|
+
*/
|
|
398
539
|
export function invalidateClaudeAccountsCache() {
|
|
399
540
|
_cache = null;
|
|
400
541
|
_cacheAt = 0;
|
|
542
|
+
_generation++;
|
|
543
|
+
_inflight = null;
|
|
544
|
+
_lastReadAt = 0;
|
|
401
545
|
}
|
|
402
546
|
|
|
403
547
|
/**
|
|
@@ -12,10 +12,10 @@
|
|
|
12
12
|
// immediately before it is spent, a response that does not clearly carry a
|
|
13
13
|
// new access token is never treated as success, and nothing here throws —
|
|
14
14
|
// a rejected promise from a background poll would take the server down.
|
|
15
|
-
import { readFile, chmod, unlink
|
|
15
|
+
import { readFile, chmod, unlink } from "node:fs/promises";
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
import { CODEX_HOME } from "./codex-dir.mjs";
|
|
18
|
-
import { createTemp, renameWithRetry } from "./installer.mjs";
|
|
18
|
+
import { createTemp, renameWithRetry, resolveWriteTarget } from "./installer.mjs";
|
|
19
19
|
import { PRODUCT } from "./brand.mjs";
|
|
20
20
|
|
|
21
21
|
// This file used to resolve CODEX_HOME itself, as `process.env.CODEX_HOME ??
|
|
@@ -149,7 +149,12 @@ async function readAuthFile() {
|
|
|
149
149
|
*
|
|
150
150
|
* Resolves symlinks first — `~/.codex/auth.json` is often a link into a
|
|
151
151
|
* dotfiles repo or an encrypted volume, and renaming onto the link would
|
|
152
|
-
* replace it with a regular file, quietly detaching the user's setup.
|
|
152
|
+
* replace it with a regular file, quietly detaching the user's setup. That
|
|
153
|
+
* resolution is the installer's resolveWriteTarget rather than a bare realpath
|
|
154
|
+
* here, because settings.json needed the identical rule (#673) and a rule
|
|
155
|
+
* written twice is a rule that drifts: the shared one also follows a DANGLING
|
|
156
|
+
* link to the file it names, which a realpath cannot answer at all and which is
|
|
157
|
+
* exactly the state a dotfiles repo is in before its first apply.
|
|
153
158
|
*
|
|
154
159
|
* The temp file comes from the installer's createTemp, which numbers every
|
|
155
160
|
* write and creates it with O_EXCL, rather than from a name built out of the
|
|
@@ -177,7 +182,7 @@ async function readAuthFile() {
|
|
|
177
182
|
* happen" rather than swallowing it.
|
|
178
183
|
*/
|
|
179
184
|
async function persistAuth(auth) {
|
|
180
|
-
const target = await
|
|
185
|
+
const target = await resolveWriteTarget(AUTH_PATH);
|
|
181
186
|
const { tmp, handle } = await createTemp(target, { mode: 0o600 });
|
|
182
187
|
|
|
183
188
|
let ok = false;
|
|
@@ -243,9 +243,84 @@ async function requestUsage(base, auth) {
|
|
|
243
243
|
// to race over the single-use refresh token.
|
|
244
244
|
let _inflight = null;
|
|
245
245
|
|
|
246
|
+
// ── what a forced read may cost ────────────────────────────────────────────
|
|
247
|
+
// The floor between two reads WE pay for, and it is the same number and the
|
|
248
|
+
// same rule quota.mjs gives the Claude half — see FORCE_POLL_MS and maySelfPoll
|
|
249
|
+
// there. The two routes are four lines apart in the router and had no business
|
|
250
|
+
// disagreeing about what `?refresh=1` costs.
|
|
251
|
+
//
|
|
252
|
+
// `force` used to mean "skip the cache", and the cache was the ONLY thing
|
|
253
|
+
// between a caller and chatgpt.com. `_inflight` deduplicates callers that
|
|
254
|
+
// overlap and nothing else, so a caller that waits for one fetch to settle and
|
|
255
|
+
// then asks again got a fresh round trip every time — two authenticated HTTPS
|
|
256
|
+
// GETs carrying the user's live ChatGPT session, as fast as the round trip
|
|
257
|
+
// allows, from any page the user happens to have open (#580). Reads on this
|
|
258
|
+
// server are deliberately open (isTrustedRead), so "any page" is the real
|
|
259
|
+
// threat model rather than a hypothetical one.
|
|
260
|
+
//
|
|
261
|
+
// The sharper half is the credential rather than the traffic. On a 401
|
|
262
|
+
// doFetchCodexQuota spends the SINGLE-USE refresh token via forceCodexRefresh,
|
|
263
|
+
// and `staleAccessToken` only stops that happening twice for the same rejected
|
|
264
|
+
// token — every turn re-reads auth.json and sees the token the previous turn
|
|
265
|
+
// rotated to, so a backend that keeps answering 401 rotated a fresh credential
|
|
266
|
+
// once per request, racing the Codex CLI for each one. codex-auth.mjs's own
|
|
267
|
+
// EXPIRY_SKEW_MS comment says what losing that race costs the user: a
|
|
268
|
+
// `refresh_token_reused` that reads as "your login is broken", recoverable only
|
|
269
|
+
// with `codex login`.
|
|
270
|
+
const FORCE_POLL_MS = 60_000;
|
|
271
|
+
|
|
272
|
+
// Set from a 429 or a rejected refresh: a backend that is refusing us must not
|
|
273
|
+
// be asked once per request, whoever is asking. Same shape as quota.mjs's
|
|
274
|
+
// _rateLimitedUntil, which is likewise never beaten by force.
|
|
275
|
+
let _rateLimitedUntil = 0;
|
|
276
|
+
const COOLDOWN_MS = 5 * 60_000;
|
|
277
|
+
|
|
278
|
+
// Stamped when a fetch STARTS rather than when it lands: what the floor is
|
|
279
|
+
// rationing is the round trip, and one that is still in flight has already been
|
|
280
|
+
// paid for.
|
|
281
|
+
let _lastFetchAt = 0;
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Whether we may spend a request of the user's ChatGPT session right now.
|
|
285
|
+
*
|
|
286
|
+
* Exported for tests, for the same reason quota.mjs exports maySelfPoll: this
|
|
287
|
+
* is the rule, it is pure, and it is worth pinning down away from the fetch it
|
|
288
|
+
* guards.
|
|
289
|
+
*/
|
|
290
|
+
export function mayFetchQuota({ now, lastFetchAt, rateLimitedUntil }) {
|
|
291
|
+
if (now < rateLimitedUntil) return false;
|
|
292
|
+
return now - lastFetchAt >= FORCE_POLL_MS;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The answer to a read the floor refused.
|
|
297
|
+
*
|
|
298
|
+
* A reading, not an error — a user who clicks ↻ twice in a second must get the
|
|
299
|
+
* numbers they already have rather than a red hint, which is exactly what
|
|
300
|
+
* quota.mjs does with `{ ...held, stale: true }`. The timestamp stays the one
|
|
301
|
+
* the data was fetched at, so the panel's age label keeps telling the truth
|
|
302
|
+
* instead of vouching for a reading it did not take.
|
|
303
|
+
*/
|
|
304
|
+
function heldReading(now) {
|
|
305
|
+
if (_cache) return { ..._cache, stale: true };
|
|
306
|
+
// Only reachable before the first fetch has ever landed — `finish` caches
|
|
307
|
+
// every outcome, failures included — and spelled the way the Claude side
|
|
308
|
+
// spells the same two states.
|
|
309
|
+
return { ok: false, reason: now < _rateLimitedUntil ? "rate_limited" : "waiting", fetchedAt: now };
|
|
310
|
+
}
|
|
311
|
+
|
|
246
312
|
export function fetchCodexQuota({ force = false } = {}) {
|
|
247
|
-
|
|
248
|
-
|
|
313
|
+
const now = Date.now();
|
|
314
|
+
if (!force && _cache && now - _cacheAt < CACHE_MS) return Promise.resolve(_cache);
|
|
315
|
+
// Joining a run already in flight costs nothing, so it is offered before the
|
|
316
|
+
// floor: what refresh asks for is a reading newer than the cache, and a fetch
|
|
317
|
+
// that has not landed yet is one.
|
|
318
|
+
if (_inflight) return _inflight;
|
|
319
|
+
if (!mayFetchQuota({ now, lastFetchAt: _lastFetchAt, rateLimitedUntil: _rateLimitedUntil })) {
|
|
320
|
+
return Promise.resolve(heldReading(now));
|
|
321
|
+
}
|
|
322
|
+
_lastFetchAt = now;
|
|
323
|
+
_inflight = doFetchCodexQuota().finally(() => { _inflight = null; });
|
|
249
324
|
return _inflight;
|
|
250
325
|
}
|
|
251
326
|
|
|
@@ -256,6 +331,13 @@ async function doFetchCodexQuota() {
|
|
|
256
331
|
// shortens the effective TTL for no reason.
|
|
257
332
|
const finish = (r) => { _cache = r; _cacheAt = Date.now(); return r; };
|
|
258
333
|
const fail = (reason) => finish({ ok: false, reason, fetchedAt: started });
|
|
334
|
+
// A refusal we were told about, rather than one we inferred: back off further
|
|
335
|
+
// than the ordinary floor before asking again. `retry-after` is honoured when
|
|
336
|
+
// the backend sends one, because it knows better than the constant does.
|
|
337
|
+
const cooldown = (res) => {
|
|
338
|
+
const after = parseInt(res?.headers?.get?.("retry-after") ?? "", 10);
|
|
339
|
+
_rateLimitedUntil = Date.now() + (Number.isFinite(after) ? after * 1000 : COOLDOWN_MS);
|
|
340
|
+
};
|
|
259
341
|
|
|
260
342
|
let auth, base, res;
|
|
261
343
|
try {
|
|
@@ -294,12 +376,22 @@ async function doFetchCodexQuota() {
|
|
|
294
376
|
// destroyed.
|
|
295
377
|
if (res.status === 401) {
|
|
296
378
|
const refreshed = await forceCodexRefresh(auth.accessToken);
|
|
297
|
-
if (!refreshed.ok)
|
|
379
|
+
if (!refreshed.ok) {
|
|
380
|
+
// The credential is gone and only `codex login` brings it back, so
|
|
381
|
+
// rotating another single-use token at the next request would burn the
|
|
382
|
+
// one the CLI is still holding. Wait.
|
|
383
|
+
if (refreshed.reason === "refresh_rejected") cooldown(null);
|
|
384
|
+
return fail(refreshed.reason);
|
|
385
|
+
}
|
|
298
386
|
auth = refreshed;
|
|
299
387
|
res = await requestUsage(base, auth);
|
|
300
388
|
}
|
|
301
389
|
|
|
302
390
|
if (!res.ok) {
|
|
391
|
+
// A second 401 means the token we just rotated to was rejected as well —
|
|
392
|
+
// the case that turned into one rotation per request. 429 is the backend
|
|
393
|
+
// saying the same thing in the ordinary way.
|
|
394
|
+
if (res.status === 401 || res.status === 429) cooldown(res);
|
|
303
395
|
return fail(res.status === 401 ? "refresh_rejected" : `http_${res.status}`);
|
|
304
396
|
}
|
|
305
397
|
} catch (err) {
|
|
@@ -12,6 +12,88 @@ let _cache = null;
|
|
|
12
12
|
let _cacheAt = 0;
|
|
13
13
|
const CACHE_MS = 60_000;
|
|
14
14
|
|
|
15
|
+
// ── what a forced read may cost ─────────────────────────────────────────────
|
|
16
|
+
// Until #600 the cache above was the whole of the admission control here, and
|
|
17
|
+
// `force` walked straight past it. That made one GET worth a full week of the
|
|
18
|
+
// rollout tree: listRolloutFiles(WINDOW_7D_MS) and then a read of every file it
|
|
19
|
+
// returns. Measured on this repo's machine against 280 rollouts of ~90KB — a
|
|
20
|
+
// week of ordinary use — one forced call is 685ms, 280 file opens and a peak of
|
|
21
|
+
// four descriptors. Nothing bounded how many of those calls ran at once, and
|
|
22
|
+
// the cost scaled exactly linearly: 16 concurrent forced reads were 4,480 opens
|
|
23
|
+
// and 64 descriptors, 128 were 35,840 opens, 512 descriptors and 54.7s.
|
|
24
|
+
//
|
|
25
|
+
// Reads on this server are deliberately open — `isTrustedRead` does not apply
|
|
26
|
+
// the `Sec-Fetch-Site` test that `isTrustedMutation` does, because a cross-site
|
|
27
|
+
// read of `http://127.0.0.1:4317` is an ordinary top-level navigation — so any
|
|
28
|
+
// page the user has open could run
|
|
29
|
+
//
|
|
30
|
+
// for (;;) fetch("http://127.0.0.1:4317/api/codex-usage?refresh=1",
|
|
31
|
+
// { mode: "no-cors" });
|
|
32
|
+
//
|
|
33
|
+
// and get one of those scans per request. MAX_PARALLEL_READS below bounds the
|
|
34
|
+
// fan-out WITHIN one call, and it exists because opening a week of rollouts at
|
|
35
|
+
// once risked EMFILE — which readTokenSeries swallows into `return null`, a
|
|
36
|
+
// silent undercount rather than an error. Unbounded calls let that EMFILE back
|
|
37
|
+
// in through the door the pool does not cover.
|
|
38
|
+
//
|
|
39
|
+
// The two things between a caller and a scan are the ones quota.mjs established
|
|
40
|
+
// and codex-quota.mjs adopted in #597, spelled the same way in all three:
|
|
41
|
+
//
|
|
42
|
+
// _inflight — callers that overlap wait on the one scan already running,
|
|
43
|
+
// `force` included. What ?refresh=1 asks for is a reading
|
|
44
|
+
// newer than the cache, and a scan in progress is one, so
|
|
45
|
+
// joining it costs nothing and is offered before the floor.
|
|
46
|
+
// FORCE_POLL_MS — the minimum interval between two scans WE pay for.
|
|
47
|
+
// `_inflight` deduplicates callers that overlap and nothing
|
|
48
|
+
// else, so a caller that waits for one scan to settle and then
|
|
49
|
+
// asks again was a fresh week of the disk every time.
|
|
50
|
+
//
|
|
51
|
+
// This module has neither of the extra parts its two siblings carry, and
|
|
52
|
+
// deliberately: there is no upstream backend to rate-limit us, so no cooldown,
|
|
53
|
+
// and nothing outside this file invalidates the cache, so no generation guard.
|
|
54
|
+
let _inflight = null;
|
|
55
|
+
|
|
56
|
+
// Stamped when a scan STARTS rather than when it lands: what the floor rations
|
|
57
|
+
// is the walk of the disk, and one that is still running has already been paid
|
|
58
|
+
// for.
|
|
59
|
+
let _lastScanAt = 0;
|
|
60
|
+
|
|
61
|
+
// quota.mjs's number, and codex-quota.mjs's, for the reason those two give it:
|
|
62
|
+
// "The refresh button may beat that floor, but not turn into a poll loop when
|
|
63
|
+
// held down." Three routes within a few lines of each other in the router have
|
|
64
|
+
// no business disagreeing about what `?refresh=1` costs.
|
|
65
|
+
const FORCE_POLL_MS = 60_000;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Whether we may walk a week of the rollout tree right now.
|
|
69
|
+
*
|
|
70
|
+
* Exported for tests, for the same reason quota.mjs exports `maySelfPoll` and
|
|
71
|
+
* codex-quota.mjs exports `mayFetchQuota`: this is the rule, it is pure, and it
|
|
72
|
+
* is worth pinning down away from the scan it guards.
|
|
73
|
+
*/
|
|
74
|
+
export function mayScanUsage({ now, lastScanAt }) {
|
|
75
|
+
return now - lastScanAt >= FORCE_POLL_MS;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The answer to a read the floor refused.
|
|
80
|
+
*
|
|
81
|
+
* A reading, not an error. The panel draws this number from `codexUsage?.ok &&
|
|
82
|
+
* window7d.sessionCount > 0`, so an `{ ok: false }` refusal would make the token
|
|
83
|
+
* line vanish for a minute — the deck teaching itself a new failure mode in
|
|
84
|
+
* order to defend against a loop nobody ran. `stale` is the flag quota.mjs and
|
|
85
|
+
* codex-quota.mjs both use for exactly this, and `fetchedAt` keeps the moment
|
|
86
|
+
* the DATA was read rather than the moment of the read that was refused, so an
|
|
87
|
+
* age label drawn from it never vouches for a scan that did not happen.
|
|
88
|
+
*/
|
|
89
|
+
function heldReading(now) {
|
|
90
|
+
if (_cache) return { ..._cache, stale: true };
|
|
91
|
+
// Only reachable before the first scan has ever landed — every outcome below
|
|
92
|
+
// is cached, failures included, and a scan that is still running is served by
|
|
93
|
+
// `_inflight` — and spelled the way codex-quota.mjs spells the same state.
|
|
94
|
+
return { ok: false, reason: "waiting", fetchedAt: now };
|
|
95
|
+
}
|
|
96
|
+
|
|
15
97
|
const WINDOW_5H_MS = 5 * 60 * 60 * 1000;
|
|
16
98
|
const WINDOW_7D_MS = 7 * 24 * 60 * 60 * 1000;
|
|
17
99
|
|
|
@@ -161,14 +243,62 @@ function windowDelta(series, windowStartMs) {
|
|
|
161
243
|
// Parse session start time from rollout filename.
|
|
162
244
|
// Format: rollout-YYYY-MM-DDTHH-MM-SS-<uuid>.jsonl
|
|
163
245
|
// The timestamp portion uses dashes instead of colons (Windows-safe).
|
|
246
|
+
//
|
|
247
|
+
// THAT WALL CLOCK IS LOCAL. This used to append a "Z" and hand the result to
|
|
248
|
+
// `Date.parse`, which declares it UTC, and every rollout the walk below
|
|
249
|
+
// considered was therefore mis-dated by the machine's offset — the whole
|
|
250
|
+
// membership test shifted by however far the machine sits from Greenwich
|
|
251
|
+
// (#609). Measured against the ten rollouts under `$CODEX_HOME` on this
|
|
252
|
+
// machine, TZ=Europe/Chisinau, offset +3: read as UTC the filename sits
|
|
253
|
+
// 179.3, 173.4, 178.5, 179.6, 179.7, 180.0, 179.8, 180.0 and 179.9 minutes
|
|
254
|
+
// ahead of the first event in its own file; read as local it lands 0.0 to 6.6
|
|
255
|
+
// minutes BEFORE it, which is the gap between naming a file and writing the
|
|
256
|
+
// first line into it. Ten out of ten, and the sign is the tell — a session
|
|
257
|
+
// cannot log an event before it starts.
|
|
258
|
+
//
|
|
259
|
+
// The tenth file is the one worth spelling out, because it is the reason this
|
|
260
|
+
// keys off the name and not the contents. In
|
|
261
|
+
// `rollout-2026-08-18T08-00-24-01a0133d-…` the envelope timestamp on line 1 is
|
|
262
|
+
// 06:33:07.513Z — 92 minutes AFTER the name, since the session sat idle before
|
|
263
|
+
// its first turn — while the `session_meta` payload nested inside that same
|
|
264
|
+
// line reads 05:00:24.355Z, which is 08:00:24 local, the filename to the
|
|
265
|
+
// second. So the outer timestamp is when the file was first APPENDED TO and
|
|
266
|
+
// the name is when the session STARTED; the two differ by as much as the user
|
|
267
|
+
// leaves the prompt sitting there.
|
|
268
|
+
//
|
|
269
|
+
// Reading that inner field would mean opening every rollout in the tree just
|
|
270
|
+
// to decide which rollouts to open, which is the one cost this function exists
|
|
271
|
+
// to avoid: the module's own measurements put a week at 280 files, and the
|
|
272
|
+
// files ruled out by the name are exactly the ones never touched again. It
|
|
273
|
+
// would also need an answer for a rollout whose first line is truncated,
|
|
274
|
+
// unparseable or simply not there yet — and the only two answers are to open
|
|
275
|
+
// it anyway (paying the cost the filter was for) or to drop it (a silent
|
|
276
|
+
// undercount, which is the bug being fixed here wearing a different hat). The
|
|
277
|
+
// name is on disk, free to read, and by the measurement above it is the more
|
|
278
|
+
// accurate of the two.
|
|
164
279
|
function parseRolloutTime(filename) {
|
|
165
280
|
// e.g. rollout-2026-06-17T12-39-01-019ed4f2-c821-...jsonl
|
|
166
|
-
const m = filename.match(/^rollout-(\d{4}
|
|
281
|
+
const m = filename.match(/^rollout-(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})-/);
|
|
167
282
|
if (!m) return null;
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
283
|
+
const [y, mo, d, h, mi, s] = m.slice(1).map(Number);
|
|
284
|
+
// Built from parts rather than parsed from a string, so the conversion uses
|
|
285
|
+
// the zone rules in force ON THAT DATE rather than any single offset. An
|
|
286
|
+
// offset is not a constant: America/Los_Angeles is -8 in January and -7 in
|
|
287
|
+
// July, so a fix that subtracted `new Date().getTimezoneOffset()` would be
|
|
288
|
+
// wrong for half the window it filters, twice a year, and wrong by an hour
|
|
289
|
+
// for the whole of it on the days either side of a transition.
|
|
290
|
+
const dt = new Date(y, mo - 1, d, h, mi, s);
|
|
291
|
+
// `Date.parse` used to reject a nonsense date for free; the constructor
|
|
292
|
+
// instead rolls it over (month 13 becomes next January), which would turn a
|
|
293
|
+
// file that is not a rollout at all into one dated in the future — and a
|
|
294
|
+
// future date passes the window test below. Month and day are enough to
|
|
295
|
+
// catch that, and deliberately not the hour: a local time inside a
|
|
296
|
+
// spring-forward gap does not exist, and V8 normalises it to the hour after,
|
|
297
|
+
// which is the right answer and not a rollover. It subsumes the `isNaN` test
|
|
298
|
+
// that used to stand at the end of this function, since an invalid Date
|
|
299
|
+
// answers NaN to `getMonth()` and NaN matches nothing.
|
|
300
|
+
if (dt.getMonth() !== mo - 1 || dt.getDate() !== d) return null;
|
|
301
|
+
return dt.getTime();
|
|
172
302
|
}
|
|
173
303
|
|
|
174
304
|
// List rollout files whose start times fall within the given window.
|
|
@@ -184,8 +314,18 @@ async function listRolloutFiles(sinceMs) {
|
|
|
184
314
|
const nowMs = Date.now();
|
|
185
315
|
// Years arrive newest-first, so the first one that cannot hold a file in the
|
|
186
316
|
// window ends the walk: everything after it is older still. The extra day of
|
|
187
|
-
// slack covers a session that started just before the window
|
|
188
|
-
//
|
|
317
|
+
// slack covers a session that started just before the window.
|
|
318
|
+
//
|
|
319
|
+
// It used to also claim to cover "a filename timestamp that is UTC while the
|
|
320
|
+
// year directory is local time", which asserted the opposite of what the
|
|
321
|
+
// files say — see parseRolloutTime. Both are local now and `getFullYear()`
|
|
322
|
+
// here is local too, so the two sides of this comparison finally speak the
|
|
323
|
+
// same clock. What the day of slack still earns, beyond the session that
|
|
324
|
+
// started just before the window: an ambiguous local time on the day the
|
|
325
|
+
// clocks go back happens twice, V8 resolves it to the first of the two, and
|
|
326
|
+
// a session started during the second is dated an hour early. That is a
|
|
327
|
+
// one-hour error on one or two days a year against a seven-day window,
|
|
328
|
+
// where the old bug was an offset-wide error on every day of it.
|
|
189
329
|
const oldestYear = new Date(nowMs - sinceMs - 86400000).getFullYear();
|
|
190
330
|
await walkRolloutDays(
|
|
191
331
|
(dir, files) => {
|
|
@@ -206,10 +346,24 @@ function emptyWindow() {
|
|
|
206
346
|
return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, totalTokens: 0, sessionCount: 0 };
|
|
207
347
|
}
|
|
208
348
|
|
|
209
|
-
export
|
|
349
|
+
export function fetchCodexUsage({ force = false } = {}) {
|
|
210
350
|
const now = Date.now();
|
|
211
|
-
if (!force && _cache && now - _cacheAt < CACHE_MS) return _cache;
|
|
351
|
+
if (!force && _cache && now - _cacheAt < CACHE_MS) return Promise.resolve(_cache);
|
|
352
|
+
// Offered before the floor: a scan that has not finished yet is a reading
|
|
353
|
+
// newer than the cache, which is what refresh asked for, and joining it costs
|
|
354
|
+
// nothing.
|
|
355
|
+
if (_inflight) return _inflight;
|
|
356
|
+
if (!mayScanUsage({ now, lastScanAt: _lastScanAt })) return Promise.resolve(heldReading(now));
|
|
357
|
+
_lastScanAt = now;
|
|
358
|
+
// A bare clear rather than quota.mjs's `_inflight === mine` check: that guard
|
|
359
|
+
// is there because invalidateQuotaCache drops the slot mid-flight, and this
|
|
360
|
+
// module has no invalidator to race with. If one is ever added, it needs the
|
|
361
|
+
// same check adding with it.
|
|
362
|
+
_inflight = scanCodexUsage(now).finally(() => { _inflight = null; });
|
|
363
|
+
return _inflight;
|
|
364
|
+
}
|
|
212
365
|
|
|
366
|
+
async function scanCodexUsage(now) {
|
|
213
367
|
const w5h = emptyWindow();
|
|
214
368
|
const w7d = emptyWindow();
|
|
215
369
|
const start5h = now - WINDOW_5H_MS;
|