ccdeck 3.22.14 → 3.23.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 +53 -10
- package/THIRD_PARTY_NOTICES.md +123 -15
- package/bin/deck.js +264 -21
- package/dist/web/assets/BrowserWatchModal-DD6srgsk.js +1 -0
- package/dist/web/assets/UsageHistoryModal-zExZpRWU.js +1 -0
- package/dist/web/assets/index-Bf-qcZ6x.css +1 -0
- package/dist/web/assets/index-Dsca2TwS.js +296 -0
- package/dist/web/index.html +9 -5
- package/hook/hook.js +631 -99
- package/package.json +1 -1
- package/release-notes.json +62 -1
- package/src/server/activity.mjs +42 -4
- package/src/server/args.mjs +30 -4
- package/src/server/browser-history.mjs +164 -38
- package/src/server/browser-presence.mjs +60 -0
- package/src/server/browser-react.mjs +160 -7
- package/src/server/browser-watch-store.mjs +319 -17
- package/src/server/browser-watch.mjs +304 -60
- package/src/server/ccusage.mjs +20 -4
- package/src/server/claude-accounts.mjs +79 -34
- package/src/server/claude-identity.mjs +121 -0
- package/src/server/cswap-admin.mjs +160 -133
- package/src/server/cswap-auto.mjs +86 -7
- package/src/server/cswap-install.mjs +280 -43
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +252 -36
- package/src/server/deck-probe.mjs +1 -1
- package/src/server/exec.mjs +84 -3
- package/src/server/global-install.mjs +13 -0
- package/src/server/index.mjs +1454 -182
- package/src/server/installer.mjs +136 -8
- package/src/server/lan-about.mjs +8 -3
- package/src/server/lan-engine.mjs +385 -51
- package/src/server/lan-socket.mjs +384 -57
- package/src/server/lan-sync.mjs +495 -22
- package/src/server/log-writer.mjs +575 -2
- package/src/server/login-service.mjs +31 -4
- package/src/server/open-url.mjs +22 -2
- package/src/server/output-watch.mjs +59 -8
- package/src/server/purge-key.mjs +135 -0
- package/src/server/quota.mjs +112 -7
- package/src/server/running-deck.mjs +15 -1
- package/src/server/self-update.mjs +98 -4
- package/src/server/session-recap.mjs +89 -0
- package/src/server/static-cache.mjs +67 -0
- package/src/server/stop-deck.mjs +2 -2
- package/src/server/store-lock.mjs +51 -0
- package/src/server/supervisor.mjs +1 -1
- package/src/server/system-metrics.mjs +143 -17
- package/dist/web/assets/index-BdYsAtN8.js +0 -274
- package/dist/web/assets/index-CgwmcBHv.css +0 -1
|
@@ -20,9 +20,10 @@
|
|
|
20
20
|
// by the host CLI, with no path back to the module it came from — the same
|
|
21
21
|
// reason it re-derives the Claude config dir inline. The two copies are pinned
|
|
22
22
|
// equal by a test, as challengeProof's pair already is.
|
|
23
|
-
import { open } from "node:fs/promises";
|
|
23
|
+
import { open, truncate, unlink } from "node:fs/promises";
|
|
24
24
|
import { realpathSync } from "node:fs";
|
|
25
25
|
import { basename, dirname, join, resolve, win32, posix } from "node:path";
|
|
26
|
+
import { PRODUCT } from "./brand.mjs";
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* Does this platform's filesystem treat two spellings that differ only in case
|
|
@@ -154,6 +155,32 @@ export function codexCwdInWorkspace(cwd, workspace, platform = process.platform)
|
|
|
154
155
|
return a.startsWith(b.endsWith(p.sep) ? b : b + p.sep);
|
|
155
156
|
}
|
|
156
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Do two discovery records name the same Codex tree? Each argument is a
|
|
160
|
+
* record's `codexHome`: the canonical path of the CODEX_HOME that deck tails.
|
|
161
|
+
*
|
|
162
|
+
* Anything that is not a non-empty string answers yes. That is a record written
|
|
163
|
+
* before the field existed — a machine halfway through an upgrade is the
|
|
164
|
+
* ordinary way to meet one — and guessing "a different tree" there would elect
|
|
165
|
+
* a second writer for one log on every machine with an older deck still up.
|
|
166
|
+
* Guessing "the same tree" is what every deck did before the field existed,
|
|
167
|
+
* which is the fail-safe writesCodexLog below already takes for a record too old
|
|
168
|
+
* to carry `codex` at all.
|
|
169
|
+
*
|
|
170
|
+
* Case is folded where the filesystem folds it, exactly as electWriters folds
|
|
171
|
+
* the log path: on Windows and macOS two spellings that differ only in case are
|
|
172
|
+
* one directory with one reader, and on Linux they are two directories, each
|
|
173
|
+
* read by its own deck. The other ways to spell one directory — a symlinked
|
|
174
|
+
* ~/.codex, an 8.3-shortened USERPROFILE, /tmp against /private/tmp — are
|
|
175
|
+
* settled before the value is published (writeDiscovery in installer.mjs runs
|
|
176
|
+
* it through canonicalLogPath), so two records compared here already agree on
|
|
177
|
+
* everything but case.
|
|
178
|
+
*/
|
|
179
|
+
export function sameCodexTree(a, b, platform = process.platform) {
|
|
180
|
+
if (typeof a !== "string" || a === "" || typeof b !== "string" || b === "") return true;
|
|
181
|
+
return foldsCase(platform) ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
182
|
+
}
|
|
183
|
+
|
|
157
184
|
/**
|
|
158
185
|
* Does this deck append a rollout's events to its log, or is another deck doing
|
|
159
186
|
* it? `decks` is every deck registered right now, `pid` identifies this one
|
|
@@ -168,6 +195,18 @@ export function codexCwdInWorkspace(cwd, workspace, platform = process.platform)
|
|
|
168
195
|
* mean the rollout's events reach no log at all. A deck too old to say either
|
|
169
196
|
* way is assumed to be tailing, which is what it was doing before this field
|
|
170
197
|
* existed.
|
|
198
|
+
*
|
|
199
|
+
* A deck reading a different Codex tree is left out for the same reason (#982).
|
|
200
|
+
* CODEX_HOME moves the whole tree, so two decks on one machine can share one
|
|
201
|
+
* events.jsonl while reading two different sets of rollouts — and for an
|
|
202
|
+
* unscoped deck the workspace test above answers yes to every cwd, so it tells
|
|
203
|
+
* them apart not at all. The record's `codex: true` said a deck tails rollouts
|
|
204
|
+
* and never said whose. So the lower-port deck won the line for a rollout its
|
|
205
|
+
* own tree does not hold and appended nothing, the deck that was reading it
|
|
206
|
+
* stood down, and the shared log recorded none of the session while the canvas
|
|
207
|
+
* drew all of it: #695's symptom, from a deck that is alive, answers its
|
|
208
|
+
* challenge, and is simply looking somewhere else. The same deck launched with
|
|
209
|
+
* a stale CODEX_HOME is the duller version of it.
|
|
171
210
|
*/
|
|
172
211
|
export function writesCodexLog({ decks, pid, cwd, platform = process.platform }) {
|
|
173
212
|
const live = Array.isArray(decks) ? decks : [];
|
|
@@ -183,6 +222,7 @@ export function writesCodexLog({ decks, pid, cwd, platform = process.platform })
|
|
|
183
222
|
for (const d of live) {
|
|
184
223
|
if (!d || d.pid === self.pid) continue;
|
|
185
224
|
if (d.codex === false) continue;
|
|
225
|
+
if (!sameCodexTree(self.codexHome, d.codexHome, platform)) continue;
|
|
186
226
|
if (!codexCwdInWorkspace(cwd, d.workspace ?? "", platform)) continue;
|
|
187
227
|
group.push(d);
|
|
188
228
|
}
|
|
@@ -260,6 +300,257 @@ export function writesCodexLog({ decks, pid, cwd, platform = process.platform })
|
|
|
260
300
|
// already paid — open, write, close — minus the extra writes.
|
|
261
301
|
const appendTails = new Map();
|
|
262
302
|
|
|
303
|
+
// ─── What the queue is allowed to weigh ───────────────────────────────────
|
|
304
|
+
//
|
|
305
|
+
// The chain above ORDERS appends. Nothing bounded them. Every line handed over
|
|
306
|
+
// is retained by the closure that will eventually write it, and the only brake
|
|
307
|
+
// on how many of those exist at once is how fast write(2) returns — while
|
|
308
|
+
// pushEvent hands them over fire-and-forget and answers `{ok:true, seq}` on the
|
|
309
|
+
// next statement, so `POST /api/event` admits lines as fast as a socket can
|
|
310
|
+
// deliver them.
|
|
311
|
+
//
|
|
312
|
+
// MEASURED (Linux 7.0 / Node 24.21 / ext4 on NVMe), eight sockets posting 1 MB
|
|
313
|
+
// `tool_response` bodies to a sandboxed deck for four seconds, sampled twice a
|
|
314
|
+
// second:
|
|
315
|
+
//
|
|
316
|
+
// t=1.0s log_MB=19 rss_MB=881
|
|
317
|
+
// t=2.5s log_MB=51 rss_MB=1741
|
|
318
|
+
// t=4.1s log_MB=85 rss_MB=2260 <- ingest stops; 1540 MB acknowledged
|
|
319
|
+
// t=5.5s log_MB=1135 rss_MB=935
|
|
320
|
+
// t=6.0s log_MB=1540 rss_MB=546 <- queue finally drained
|
|
321
|
+
//
|
|
322
|
+
// 85 MB on disk against 1540 MB the deck had said it had: 1455 MB of serialized
|
|
323
|
+
// lines held in the heap, and an RSS peak of 2260 MB — 17x MAX_BUFFER_CHARS,
|
|
324
|
+
// the budget the ring beside this keeps exactly. The disk is not the amplifier;
|
|
325
|
+
// the event loop is. Ingest and the writer share it, so the log grew at 21 MB/s
|
|
326
|
+
// while the posting continued and at 800 MB/s the instant it stopped.
|
|
327
|
+
//
|
|
328
|
+
// The same burst under `--max-old-space-size=1024`:
|
|
329
|
+
//
|
|
330
|
+
// FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
|
|
331
|
+
// [deck exited code=null sig=SIGABRT] 3.2s in, 50 MB written
|
|
332
|
+
//
|
|
333
|
+
// That abort is not catchable, so the SSE stream, the hook ingest and the log
|
|
334
|
+
// stop together — and `/api/event` is a deliberate OPEN_MUTATION, so this is a
|
|
335
|
+
// few hundred unauthenticated posts from any local process ending the deck.
|
|
336
|
+
// It is the failure MAX_BUFFER_CHARS was added to close (#625), reached through
|
|
337
|
+
// the door beside the one it guards (#1030).
|
|
338
|
+
//
|
|
339
|
+
// Who gets there without trying: `--history` accepts any path. The 40 events/s
|
|
340
|
+
// of 1 MB that is trivial on NVMe is roughly 30 MB/s of pure accumulation on a
|
|
341
|
+
// 5-10 MB/s network mount or a slow external drive, where the queue's only
|
|
342
|
+
// brake is that much slower than the traffic filling it.
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The ceiling on pending append bytes, and the ring's sibling on purpose.
|
|
346
|
+
*
|
|
347
|
+
* 128 MiB, the same number and the same three readings that pick
|
|
348
|
+
* MAX_BUFFER_CHARS in index.mjs:
|
|
349
|
+
*
|
|
350
|
+
* - It is 26 times the largest single line ingest can produce (5,000,000
|
|
351
|
+
* characters plus the deck's envelope), so a burst of maximum-size tool
|
|
352
|
+
* responses — eight subagents each returning a big Read — is queued whole
|
|
353
|
+
* rather than shed.
|
|
354
|
+
* - It is thirteen times a completely full 2000-event ring of ordinary
|
|
355
|
+
* traffic. The mean serialized event is about 5 KB, so the ~26,000 lines
|
|
356
|
+
* this holds are far more than any honest burst produces: ordinary traffic
|
|
357
|
+
* never meets this bound at all.
|
|
358
|
+
* - Its worst case is a bounded fraction of the heap, and a bounded ADDITION
|
|
359
|
+
* to the ring's. 128 MiB of charged characters is at most 256 MiB retained
|
|
360
|
+
* — a two-byte string costs twice what it is charged, the same 2x
|
|
361
|
+
* MAX_CLIENT_BUFFER_BYTES' note describes — so the ring's 320 MiB and this
|
|
362
|
+
* together are about 576 MiB of the 2 GB heap an 8 GB laptop picks, against
|
|
363
|
+
* the 2260 MB one four-second burst reached with no bound here at all.
|
|
364
|
+
*
|
|
365
|
+
* CHARGED IN CHARACTERS, not in UTF-8 bytes, and the unit is deliberate. These
|
|
366
|
+
* lines are already serialized, so `line.length` is the size of the thing being
|
|
367
|
+
* retained and costs nothing to read; `Buffer.byteLength` would be a second
|
|
368
|
+
* full scan of a five-megabyte string on the hottest path in the process for a
|
|
369
|
+
* number that is FURTHER from the retained heap, not closer — JSON.stringify
|
|
370
|
+
* leaves non-ASCII unescaped, so a CJK line is two bytes of heap per character
|
|
371
|
+
* and three bytes of UTF-8.
|
|
372
|
+
*
|
|
373
|
+
* Deliberately NOT summed into MAX_BUFFER_CHARS, which #1030 raises as an
|
|
374
|
+
* option. The two hold different things — the ring holds parsed payloads
|
|
375
|
+
* charged by payloadChars' walk, this holds their serialization — so one
|
|
376
|
+
* counter would be adding two units; and eviction cannot free a queued line
|
|
377
|
+
* anyway, so a shared budget would answer a slow disk by collapsing the replay
|
|
378
|
+
* depth of the ring, which is the one part of this that was never broken.
|
|
379
|
+
*/
|
|
380
|
+
export const MAX_PENDING_APPEND_CHARS = 128 * 1024 * 1024;
|
|
381
|
+
|
|
382
|
+
// What has been accepted and not yet written, what has been refused because of
|
|
383
|
+
// it, and whether we are inside an episode of refusing. These move with the
|
|
384
|
+
// chain in appendLogLine and nowhere else — the same rule `bufferedChars` and
|
|
385
|
+
// `events` keep in index.mjs, and for the same reason: a total that names lines
|
|
386
|
+
// the queue no longer holds is a permanent debt against the budget.
|
|
387
|
+
let pendingLines = 0;
|
|
388
|
+
let pendingChars = 0;
|
|
389
|
+
let droppedLines = 0;
|
|
390
|
+
let droppedChars = 0;
|
|
391
|
+
let dropEpisodes = 0;
|
|
392
|
+
// Reset at the start of each episode, so the line printed when the queue
|
|
393
|
+
// drains describes THAT burst rather than the life of the process.
|
|
394
|
+
let episodeLines = 0;
|
|
395
|
+
let episodeChars = 0;
|
|
396
|
+
let shedding = false;
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* What the append queue holds right now, and what it has refused.
|
|
400
|
+
*
|
|
401
|
+
* Exported for exactly the reason eventBufferStats and MAX_BUFFER_CHARS are —
|
|
402
|
+
* "a bound whose only observable failure is the process running out of memory
|
|
403
|
+
* is a bound no test can assert". With this, the bound is observable without
|
|
404
|
+
* watching a process die, and so is the loss it trades for.
|
|
405
|
+
*
|
|
406
|
+
* `droppedLines` / `droppedChars` are cumulative for the life of the process.
|
|
407
|
+
* They count lines that were never ATTEMPTED; an append that was attempted and
|
|
408
|
+
* failed is a different number, which #991 is open about and which belongs
|
|
409
|
+
* beside these rather than tangled into them.
|
|
410
|
+
*/
|
|
411
|
+
export function appendQueueStats() {
|
|
412
|
+
return { pendingLines, pendingChars, droppedLines, droppedChars, dropEpisodes };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const mb = chars => `${(chars / 1024 / 1024).toFixed(0)}MB`;
|
|
416
|
+
// ─── What the queue failed to write ───────────────────────────────────────
|
|
417
|
+
//
|
|
418
|
+
// `.catch(() => {})` was the only handler anywhere on this path, and a failed
|
|
419
|
+
// `open(filePath, "a")` was indistinguishable from a successful append from
|
|
420
|
+
// every vantage point in the process. MEASURED, on a sandboxed deck whose
|
|
421
|
+
// `--history` names a file inside a directory the user cannot write:
|
|
422
|
+
//
|
|
423
|
+
// POST /api/event acknowledged: 10 of 10
|
|
424
|
+
// log file on disk? false
|
|
425
|
+
// /api/health mentions the log? false
|
|
426
|
+
// /api/version canRestart: true
|
|
427
|
+
//
|
|
428
|
+
// Ten events the deck told the hook it had recorded, nothing on disk, nothing
|
|
429
|
+
// said on any console, and the Restart button still offering itself. That last
|
|
430
|
+
// line is the one that costs: the button exists precisely BECAUSE a restart
|
|
431
|
+
// without a log wipes the canvas irrecoverably, and it was gating on whether a
|
|
432
|
+
// log had been CONFIGURED rather than on whether one was being written. The
|
|
433
|
+
// press then lands on `replayLog`'s `if (!existsSync(filePath)) return 0` and
|
|
434
|
+
// the whole session history is gone.
|
|
435
|
+
//
|
|
436
|
+
// Who gets there without trying: `--history` accepts any path — a read-only or
|
|
437
|
+
// full volume, a removable drive unmounted while the deck runs, a directory
|
|
438
|
+
// whose permissions changed under it. `startServer` even creates the parent
|
|
439
|
+
// inside a bare `try {} catch {}`, so the failure is already swallowed once
|
|
440
|
+
// before the first line is ever written.
|
|
441
|
+
//
|
|
442
|
+
// So the chain counts what it could not write, and says so once. The numbers
|
|
443
|
+
// are what make the loss observable without watching a canvas come back empty,
|
|
444
|
+
// for the same reason `eventBufferStats` exists one file over.
|
|
445
|
+
let failedLines = 0;
|
|
446
|
+
let failedChars = 0;
|
|
447
|
+
/**
|
|
448
|
+
* The paths currently inside an EPISODE of failing, and how many lines each
|
|
449
|
+
* episode has lost so far: path -> { lines }.
|
|
450
|
+
*
|
|
451
|
+
* An episode rather than a one-shot flag, because the interesting failures are
|
|
452
|
+
* transient — a volume that fills and is emptied, a drive that is unplugged and
|
|
453
|
+
* returns — and a path complained about once and never again would report the
|
|
454
|
+
* first outage of a long-lived deck and stay silent through every later one.
|
|
455
|
+
* The episode closes on the next append to that path that LANDS, which is the
|
|
456
|
+
* only evidence available that the condition is over.
|
|
457
|
+
*/
|
|
458
|
+
const failingPaths = new Map();
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* How many lines have LANDED on each path in this process: path -> count.
|
|
462
|
+
*
|
|
463
|
+
* The other half of the evidence `failingPaths` holds, and the half the restart
|
|
464
|
+
* gate was missing (#1130). An open episode says the newest append to a path
|
|
465
|
+
* failed. Nothing said the opposite — that one has succeeded SINCE some moment
|
|
466
|
+
* — because a path that never failed has no episode to close, and a log that
|
|
467
|
+
* could not be opened at boot and could be a minute later never failed an
|
|
468
|
+
* append at all: its first one simply landed. A count answers that by being
|
|
469
|
+
* compared with itself. index.mjs reads it once, when its boot probe answers,
|
|
470
|
+
* and again whenever it asks; any difference is a line that reached the disk
|
|
471
|
+
* after the probe spoke.
|
|
472
|
+
*
|
|
473
|
+
* One number per path this process has ever appended to, which on a deck is
|
|
474
|
+
* one. Never deleted, unlike the entries in appendTails, because a count that
|
|
475
|
+
* went back to zero would read as "nothing has landed" to whoever took a
|
|
476
|
+
* reading before it did.
|
|
477
|
+
*/
|
|
478
|
+
const landedLines = new Map();
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* What the appender has failed to write, and whether it is failing now.
|
|
482
|
+
*
|
|
483
|
+
* Exported for the reason `eventBufferStats` and `MAX_BUFFER_CHARS` are: a loss
|
|
484
|
+
* whose only observable effect is a canvas that comes back empty after a
|
|
485
|
+
* restart is a loss no test can assert. `failing` is the number of paths inside
|
|
486
|
+
* an open failure episode — this process appends to exactly one log, so on a
|
|
487
|
+
* deck it is 0 or 1, and 1 means the log being drawn is not the log being kept.
|
|
488
|
+
*
|
|
489
|
+
* `failedLines` / `failedChars` are cumulative for the life of the process and
|
|
490
|
+
* count lines that were ATTEMPTED and did not land. A line the queue refused
|
|
491
|
+
* to accept in the first place is a different number and belongs beside this
|
|
492
|
+
* one rather than tangled into it.
|
|
493
|
+
*
|
|
494
|
+
* `filePath` NARROWS `failing` to one log, and a caller deciding what to do
|
|
495
|
+
* about its own log has to pass it. A process can outlive the log it was
|
|
496
|
+
* started with — `startServer` may be called again with a different `--history`
|
|
497
|
+
* on a restart, and the suite does exactly that — so an unscoped count would
|
|
498
|
+
* let a path that failed and was abandoned veto a deck whose current log is
|
|
499
|
+
* perfectly writable.
|
|
500
|
+
*
|
|
501
|
+
* @param {string|null} [filePath] the log to ask about; omit for every path
|
|
502
|
+
*/
|
|
503
|
+
export function appendFailureStats(filePath = null) {
|
|
504
|
+
const failing = filePath == null ? failingPaths.size : (failingPaths.has(filePath) ? 1 : 0);
|
|
505
|
+
return { failedLines, failedChars, failing };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* How many lines have landed on one log in this process. See landedLines.
|
|
510
|
+
*
|
|
511
|
+
* A separate export rather than a fourth field of appendFailureStats, because
|
|
512
|
+
* that object is spread whole into `/api/health`, an open route that keeps to
|
|
513
|
+
* the smallest set of facts answering its question, and a running count of
|
|
514
|
+
* successful writes is not one of them.
|
|
515
|
+
*
|
|
516
|
+
* @param {string} filePath the log to ask about
|
|
517
|
+
* @returns {number}
|
|
518
|
+
*/
|
|
519
|
+
export function appendsLanded(filePath) {
|
|
520
|
+
return landedLines.get(filePath) ?? 0;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* One append landed. Counted whatever came before it — see landedLines — and
|
|
525
|
+
* if this path was failing, that is the end of the episode and the size of the
|
|
526
|
+
* hole it left is worth one line.
|
|
527
|
+
*/
|
|
528
|
+
function noteAppendLanded(filePath) {
|
|
529
|
+
landedLines.set(filePath, (landedLines.get(filePath) ?? 0) + 1);
|
|
530
|
+
const episode = failingPaths.get(filePath);
|
|
531
|
+
if (!episode) return;
|
|
532
|
+
failingPaths.delete(filePath);
|
|
533
|
+
console.error(`${PRODUCT}: writing ${filePath} works again — ${episode.lines} event(s) were dropped while it did not, and are not in the log`);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* One append did not land. Count it, and open an episode if this is the first.
|
|
538
|
+
*
|
|
539
|
+
* ONE LINE PER EPISODE, not one per failure. A read-only log fails on every
|
|
540
|
+
* event the deck draws, so per-failure this would be thousands of lines onto
|
|
541
|
+
* the terminal the deck paints its own banner over — which is its own version
|
|
542
|
+
* of the problem being fixed.
|
|
543
|
+
*/
|
|
544
|
+
function noteAppendFailed(filePath, line, err) {
|
|
545
|
+
failedLines++;
|
|
546
|
+
failedChars += typeof line === "string" ? line.length : 0;
|
|
547
|
+
const episode = failingPaths.get(filePath);
|
|
548
|
+
if (episode) { episode.lines++; return; }
|
|
549
|
+
failingPaths.set(filePath, { lines: 1 });
|
|
550
|
+
const why = err && err.message ? err.message : String(err);
|
|
551
|
+
console.error(`${PRODUCT}: cannot write the event log ${filePath} (${why}) — events are being drawn but not recorded, and a restart will not bring them back`);
|
|
552
|
+
}
|
|
553
|
+
|
|
263
554
|
/**
|
|
264
555
|
* Append one already-serialized line to the shared log, whole.
|
|
265
556
|
*
|
|
@@ -275,13 +566,77 @@ const appendTails = new Map();
|
|
|
275
566
|
* about anyway. The chain deliberately continues past a failure — one ENOSPC
|
|
276
567
|
* must not stop every later event from being recorded once space is back.
|
|
277
568
|
*
|
|
569
|
+
* BOUNDED, by MAX_PENDING_APPEND_CHARS above. Past it the line is refused at
|
|
570
|
+
* the door, counted, and reported once per episode.
|
|
571
|
+
*
|
|
572
|
+
* Refused at the door, and not evicted from the middle of the queue, which is
|
|
573
|
+
* the other shape #1030 offers. A line that has been accepted has been charged,
|
|
574
|
+
* chained and ordered, and the exit drain promises to deliver it; un-accepting
|
|
575
|
+
* one later would make the only promise this module makes about the queue
|
|
576
|
+
* conditional and racy, and the head of the queue is in any case the line
|
|
577
|
+
* currently inside write(2), which cannot be recalled. The door is the one
|
|
578
|
+
* point where nothing has been promised yet.
|
|
579
|
+
*
|
|
580
|
+
* Not backpressure either — no awaiting the drain before the POST is answered.
|
|
581
|
+
* Three of pushEvent's four callers have no HTTP peer to make wait (Codex
|
|
582
|
+
* rollout lines read off disk, the boot replay, the synthetic events the
|
|
583
|
+
* transcript scanners emit), and making the ingest route wait on the filesystem
|
|
584
|
+
* is the exact shape the fire-and-forget call exists to avoid.
|
|
585
|
+
*
|
|
278
586
|
* @param {string} filePath absolute path to the log
|
|
279
587
|
* @param {string} line the line to append, INCLUDING its trailing newline
|
|
280
588
|
*/
|
|
281
589
|
export function appendLogLine(filePath, line) {
|
|
590
|
+
const charged = typeof line === "string" ? line.length : 0;
|
|
591
|
+
// The queue always accepts a line when it is EMPTY, whatever that line
|
|
592
|
+
// weighs. Ingest admits 5,000,000 characters and a Codex rollout line read
|
|
593
|
+
// off disk has no length bound at all, so refusing an oversized line outright
|
|
594
|
+
// would mean a deck that silently never records its largest events — and the
|
|
595
|
+
// ring one file over makes the same exception for the same reason ("a single
|
|
596
|
+
// event is allowed to be larger than the entire budget"). So the true ceiling
|
|
597
|
+
// is MAX_PENDING_APPEND_CHARS plus one line, stated here rather than
|
|
598
|
+
// pretended away.
|
|
599
|
+
if (pendingLines > 0 && pendingChars + charged > MAX_PENDING_APPEND_CHARS) {
|
|
600
|
+
droppedLines++;
|
|
601
|
+
droppedChars += charged;
|
|
602
|
+
episodeLines++;
|
|
603
|
+
episodeChars += charged;
|
|
604
|
+
if (!shedding) {
|
|
605
|
+
shedding = true;
|
|
606
|
+
dropEpisodes++;
|
|
607
|
+
// One line, at the start of the episode. Per refusal it would be
|
|
608
|
+
// thousands of lines onto the terminal the deck paints over, which is its
|
|
609
|
+
// own version of the problem being fixed.
|
|
610
|
+
console.error(`${PRODUCT}: the log append queue is full (${mb(pendingChars)} waiting for ${filePath}) — dropping events until it drains`);
|
|
611
|
+
}
|
|
612
|
+
// Resolved rather than rejected, and resolved rather than the tail: the
|
|
613
|
+
// contract every caller has is "never rejects, never makes you wait".
|
|
614
|
+
return Promise.resolve();
|
|
615
|
+
}
|
|
616
|
+
pendingLines++;
|
|
617
|
+
pendingChars += charged;
|
|
282
618
|
const tail = (appendTails.get(filePath) ?? Promise.resolve())
|
|
283
619
|
.then(() => writeWholeLine(filePath, line))
|
|
284
|
-
.
|
|
620
|
+
// Counted, and reported once per episode — see noteAppendFailed. The order
|
|
621
|
+
// of these four steps is the whole of the union between the append-queue
|
|
622
|
+
// ceiling (#1030) and the failure counter (#991), and each link needs the
|
|
623
|
+
// one before it:
|
|
624
|
+
//
|
|
625
|
+
// * the failure handler comes BEFORE the `catch`, or the rejection has
|
|
626
|
+
// already been swallowed by the time anything could count it;
|
|
627
|
+
// * the `catch` stays, because the map cleanup below chains a bare
|
|
628
|
+
// `.then` on this tail and "never rejects" is the contract every caller
|
|
629
|
+
// of this function has — neither handler above may be what breaks it;
|
|
630
|
+
// * `finally` comes LAST, so the charge is given back whether the write
|
|
631
|
+
// landed, failed, or a handler here threw on its way past. It would run
|
|
632
|
+
// on a rejection anyway; what the ordering buys is that it cannot be
|
|
633
|
+
// skipped by anything the two lines above do.
|
|
634
|
+
.then(() => noteAppendLanded(filePath), err => noteAppendFailed(filePath, line, err))
|
|
635
|
+
.catch(() => {})
|
|
636
|
+
// The charge is released when the line is written or has failed trying —
|
|
637
|
+
// which is the moment the closure holding it becomes collectable, and so
|
|
638
|
+
// the moment the heap it was standing for is actually back.
|
|
639
|
+
.finally(() => releaseCharge(charged));
|
|
285
640
|
appendTails.set(filePath, tail);
|
|
286
641
|
// Drop the chain once it drains, so a process that writes to several logs
|
|
287
642
|
// over its life does not hold a promise per path it has finished with. Only
|
|
@@ -292,6 +647,224 @@ export function appendLogLine(filePath, line) {
|
|
|
292
647
|
return tail;
|
|
293
648
|
}
|
|
294
649
|
|
|
650
|
+
/**
|
|
651
|
+
* Wait for every queued append to land, bounded.
|
|
652
|
+
*
|
|
653
|
+
* The deck answers `{ok:true, seq}` before the line reaches disk — pushEvent
|
|
654
|
+
* calls appendLogLine fire-and-forget, and the 200 goes out on the next
|
|
655
|
+
* statement. That is the right shape for the hook, which holds a 1.9s cap and
|
|
656
|
+
* must not wait on a filesystem. It is the wrong shape for an exit: shutdown
|
|
657
|
+
* waited for the listener to drain and for nothing else, so a backlog was
|
|
658
|
+
* abandoned. Measured on a sandboxed deck, 40 concurrent posts then SIGTERM:
|
|
659
|
+
*
|
|
660
|
+
* acknowledged 200: 40 of 40
|
|
661
|
+
* lines written: 12
|
|
662
|
+
*
|
|
663
|
+
* Twenty-eight events the deck had told the hook were recorded. The twelve
|
|
664
|
+
* that landed were whole — the single write(2) holds — so this is the queue
|
|
665
|
+
* being dropped, not a torn line.
|
|
666
|
+
*
|
|
667
|
+
* The restart path is the one that costs most: the replacement deck rebuilds
|
|
668
|
+
* its canvas from events.jsonl before it binds, so the sessions and tool calls
|
|
669
|
+
* in the dropped tail leave the board permanently.
|
|
670
|
+
*
|
|
671
|
+
* BOUNDED, because a wedged filesystem must not hold the exit hostage — the
|
|
672
|
+
* whole point of the fire-and-forget shape is that no caller waits on the disk
|
|
673
|
+
* indefinitely, and that has to stay true of the last caller too. A deadline
|
|
674
|
+
* reached is the old behaviour, which is no worse than before.
|
|
675
|
+
*/
|
|
676
|
+
export function drainAppends(ms = 3000) {
|
|
677
|
+
const pending = [...appendTails.values()];
|
|
678
|
+
if (!pending.length) return Promise.resolve(true);
|
|
679
|
+
let bell;
|
|
680
|
+
const deadline = new Promise(resolve => {
|
|
681
|
+
bell = setTimeout(() => resolve(false), ms);
|
|
682
|
+
bell.unref?.();
|
|
683
|
+
});
|
|
684
|
+
// `catch` on each: a failed append has already been swallowed by the chain,
|
|
685
|
+
// and a rejection here would skip the rest of the drain.
|
|
686
|
+
return Promise.race([
|
|
687
|
+
Promise.all(pending.map(p => Promise.resolve(p).catch(() => {}))).then(() => true),
|
|
688
|
+
deadline,
|
|
689
|
+
]).finally(() => clearTimeout(bell));
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Wait for everything queued for one log to land, bounded.
|
|
694
|
+
*
|
|
695
|
+
* `appendTails` is this module's alone: pushEvent calls appendLogLine
|
|
696
|
+
* fire-and-forget and the 200 goes out on the next statement, so nothing
|
|
697
|
+
* outside here could see the queue, let alone wait on it. The two operations
|
|
698
|
+
* that MOVE the file out from under it consulted neither.
|
|
699
|
+
*
|
|
700
|
+
* MEASURED, on a sandboxed deck — one 3 MB PostToolUse on the wire with eight
|
|
701
|
+
* small envelopes queued behind it, then `POST /api/clear`:
|
|
702
|
+
*
|
|
703
|
+
* POST /api/clear answered: {"ok":true,"log":"cleared", ...}
|
|
704
|
+
* file right after the truncate: 0 bytes
|
|
705
|
+
* file 2.5s after the Clear: 564 bytes, 3 line(s)
|
|
706
|
+
* sessions surviving the Clear: before-clear
|
|
707
|
+
*
|
|
708
|
+
* The ring was emptied, the file was truncated, `__clear` went out over SSE and
|
|
709
|
+
* the canvas was blank — and then the queue drained into the now-empty file.
|
|
710
|
+
* The next restart replays those lines, so sessions the user explicitly and
|
|
711
|
+
* irreversibly cleared come back. That is #698's residue by a different route,
|
|
712
|
+
* and it survives the ownership gate that fixed #698 because it happens on the
|
|
713
|
+
* deck that DOES own the file.
|
|
714
|
+
*
|
|
715
|
+
* And the rename, with one 24 MB line and one small line queued, the rename
|
|
716
|
+
* performed while the chain was still running:
|
|
717
|
+
*
|
|
718
|
+
* events.jsonl size: 65
|
|
719
|
+
* events.jsonl.1 size: 25165897
|
|
720
|
+
*
|
|
721
|
+
* A descriptor opened before the rename completes into the renamed inode, so
|
|
722
|
+
* the line being appended lands in the archive rather than the live log.
|
|
723
|
+
*
|
|
724
|
+
* BOUNDED, for the reason the fire-and-forget shape exists at all: no caller
|
|
725
|
+
* may wait on the disk indefinitely, and that has to stay true of a caller that
|
|
726
|
+
* is answering an HTTP request. A deadline reached is the old behaviour, which
|
|
727
|
+
* is no worse than before.
|
|
728
|
+
*
|
|
729
|
+
* THE SIBLING OF drainAppends ABOVE, and deliberately not folded into it. That
|
|
730
|
+
* one is the exit's: every path, once, on the way out. This one is asked of a
|
|
731
|
+
* single log by two operations that are about to MOVE it, and it is asked while
|
|
732
|
+
* the deck goes on running — so the paths it must not wait for are the other
|
|
733
|
+
* logs a long-lived process has written, which drainAppends is right to include
|
|
734
|
+
* and this one would be wrong to.
|
|
735
|
+
*
|
|
736
|
+
* @param {string} filePath the log to wait on
|
|
737
|
+
* @param {number} [ms] how long to wait before giving up
|
|
738
|
+
* @returns {Promise<boolean>} whether the queue actually drained
|
|
739
|
+
*/
|
|
740
|
+
export function flushAppends(filePath, ms = 3000) {
|
|
741
|
+
const tail = appendTails.get(filePath);
|
|
742
|
+
if (!tail) return Promise.resolve(true);
|
|
743
|
+
let bell;
|
|
744
|
+
const deadline = new Promise(resolve => {
|
|
745
|
+
bell = setTimeout(() => resolve(false), ms);
|
|
746
|
+
bell.unref?.();
|
|
747
|
+
});
|
|
748
|
+
return Promise.race([
|
|
749
|
+
// The tail never rejects — appendLogLine's own catch sees to that — but a
|
|
750
|
+
// rejection here would skip the clearTimeout and leave the caller hanging,
|
|
751
|
+
// so it is handled rather than assumed away.
|
|
752
|
+
Promise.resolve(tail).then(() => true, () => true),
|
|
753
|
+
deadline,
|
|
754
|
+
]).finally(() => clearTimeout(bell));
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Empty one log IN ITS TURN on the queue, and its older generations with it.
|
|
759
|
+
*
|
|
760
|
+
* `/api/clear` waited for the queue and then truncated beside it (#1005), and
|
|
761
|
+
* beside was the flaw. `flushAppends` waits for the tail as it stood when it
|
|
762
|
+
* was called, so a line queued while the Clear was waiting went on behind that
|
|
763
|
+
* tail, and nothing ordered it against the truncate — two separate operations
|
|
764
|
+
* on the threadpool. MEASURED on a sandboxed deck (#1130), a session still
|
|
765
|
+
* posting while Clear was pressed, five runs: 4, 5, 3, 3 and 2 events left in
|
|
766
|
+
* the file that the Clear had taken off the board, every one of which the next
|
|
767
|
+
* boot replays.
|
|
768
|
+
*
|
|
769
|
+
* So the truncate is chained on the tail exactly as a line would be, and the
|
|
770
|
+
* chain orders it the way it already orders lines. Every line queued before
|
|
771
|
+
* this call is written before the truncate and erased by it. Every line queued
|
|
772
|
+
* after it cannot so much as open the file until the truncate has settled,
|
|
773
|
+
* because each step on the chain starts only when the one before it has, and
|
|
774
|
+
* the chain is the only way this process writes to the log. That is the whole
|
|
775
|
+
* of what a Clear needs, and none of it depends on timing.
|
|
776
|
+
*
|
|
777
|
+
* WRITTEN AND ERASED, NOT SKIPPED. A generation stamped on each line and
|
|
778
|
+
* compared when its turn comes would spare the lines queued before the Clear
|
|
779
|
+
* their writes, and it is the other shape #1130 offers. It is not what makes
|
|
780
|
+
* this correct: without the truncate on the chain, a line queued after the
|
|
781
|
+
* Clear would still be free to land before the truncate and be erased by it;
|
|
782
|
+
* with the truncate on the chain, it is an optimisation — one that would reach
|
|
783
|
+
* into appendLogLine's four ordered steps, whose order is load-bearing and
|
|
784
|
+
* says so, to save the writes of a burst that is about to be erased anyway.
|
|
785
|
+
*
|
|
786
|
+
* `archives` go in the same turn and AFTER the truncate, and the order is what
|
|
787
|
+
* makes this safe beside a rotation, which moves the live log into the archive
|
|
788
|
+
* with an unlink and a rename of its own and queues behind nothing. However its
|
|
789
|
+
* two steps interleave with these two, what this removes is the previous
|
|
790
|
+
* archive, or the file the truncate has just emptied, or the live log the
|
|
791
|
+
* rename moved there before the truncate could reach it — never a pre-Clear
|
|
792
|
+
* generation left standing. Removing the archive first would leave exactly
|
|
793
|
+
* that, if the rename fell between the two.
|
|
794
|
+
*
|
|
795
|
+
* Never rejects, like every other call into this queue. A file that is not
|
|
796
|
+
* there is already empty. A truncate that fails for any other reason leaves the
|
|
797
|
+
* file as it was — the ring is emptied either way, and the older generations
|
|
798
|
+
* are still removed — and that failure is written into `outcome`, because the
|
|
799
|
+
* caller is about to tell somebody the history is gone (#1140). It used to be
|
|
800
|
+
* swallowed here and nowhere read it back, so a Clear on a read-only volume
|
|
801
|
+
* answered "cleared" over a log the next boot replayed in full.
|
|
802
|
+
*
|
|
803
|
+
* BOUNDED, by flushAppends and for its reason: the caller is answering an HTTP
|
|
804
|
+
* request. A deadline reached leaves the turn on the chain, where it is still
|
|
805
|
+
* in order; only the answer goes out first — and `outcome.error` is then still
|
|
806
|
+
* undefined, which means "not known yet" rather than "worked".
|
|
807
|
+
*
|
|
808
|
+
* THE ARCHIVE'S REMOVAL IS REPORTED TOO. An archive still there after a Clear is
|
|
809
|
+
* the whole history again at the next boot — the replay falls back to it once
|
|
810
|
+
* the live log is empty (#1130) — so a Clear that emptied the live file but could
|
|
811
|
+
* not remove the archive has not done what it says. This used to swallow that
|
|
812
|
+
* unlink's error the way it once swallowed the truncate's. A missing archive
|
|
813
|
+
* counts as removed, as a missing log counts as empty.
|
|
814
|
+
*
|
|
815
|
+
* @param {string} filePath the log to empty
|
|
816
|
+
* @param {string[]} [archives] older generations of it, removed in the same turn
|
|
817
|
+
* @param {number} [ms] how long to wait for the turn to be over
|
|
818
|
+
* @param {{ error?: Error | null, archiveError?: Error | null }} [outcome]
|
|
819
|
+
* filled in when the turn runs: `error` is the truncate's failure, or null
|
|
820
|
+
* once the file is empty; `archiveError` is the first archive that could
|
|
821
|
+
* not be removed, or null once none is left
|
|
822
|
+
* @returns {Promise<boolean>} whether it was over before the deadline
|
|
823
|
+
*/
|
|
824
|
+
export function emptyLog(filePath, archives = [], ms = 3000, outcome = {}) {
|
|
825
|
+
const turn = (appendTails.get(filePath) ?? Promise.resolve())
|
|
826
|
+
.then(() => truncate(filePath, 0))
|
|
827
|
+
.then(
|
|
828
|
+
() => { outcome.error = null; },
|
|
829
|
+
err => { outcome.error = err?.code === "ENOENT" ? null : (err ?? new Error("truncate failed")); },
|
|
830
|
+
)
|
|
831
|
+
.then(async () => {
|
|
832
|
+
let first = null;
|
|
833
|
+
for (const older of archives) {
|
|
834
|
+
try { await unlink(older); }
|
|
835
|
+
catch (err) { if (err?.code !== "ENOENT" && !first) first = err ?? new Error("unlink failed"); }
|
|
836
|
+
}
|
|
837
|
+
outcome.archiveError = first;
|
|
838
|
+
})
|
|
839
|
+
.catch(() => {});
|
|
840
|
+
appendTails.set(filePath, turn);
|
|
841
|
+
// The same cleanup appendLogLine does, for the same reason: only the tail
|
|
842
|
+
// installed here, never one that has chained on since.
|
|
843
|
+
turn.then(() => { if (appendTails.get(filePath) === turn) appendTails.delete(filePath); });
|
|
844
|
+
return flushAppends(filePath, ms);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* One line has left the queue. Give its charge back, and close the episode if
|
|
849
|
+
* that was the last of them.
|
|
850
|
+
*
|
|
851
|
+
* The episode ends when the queue is EMPTY rather than the moment it dips back
|
|
852
|
+
* under the bound, because it dips under the bound once per completed write:
|
|
853
|
+
* keyed on the bound this would print a pair of lines per event for the length
|
|
854
|
+
* of the burst. Empty is also the honest boundary for the number being
|
|
855
|
+
* reported — while anything is still queued the next line can still be refused,
|
|
856
|
+
* and the total would have to be retracted.
|
|
857
|
+
*/
|
|
858
|
+
function releaseCharge(charged) {
|
|
859
|
+
pendingLines--;
|
|
860
|
+
pendingChars -= charged;
|
|
861
|
+
if (pendingLines > 0 || !shedding) return;
|
|
862
|
+
console.error(`${PRODUCT}: the log append queue drained — ${episodeLines} event(s) (${mb(episodeChars)}) were dropped and are not in the log`);
|
|
863
|
+
shedding = false;
|
|
864
|
+
episodeLines = 0;
|
|
865
|
+
episodeChars = 0;
|
|
866
|
+
}
|
|
867
|
+
|
|
295
868
|
/**
|
|
296
869
|
* One line, one write(2). The loop exists only for the short-write case
|
|
297
870
|
* described above; on every filesystem that does not do that it runs once.
|