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.
Files changed (51) hide show
  1. package/README.md +53 -10
  2. package/THIRD_PARTY_NOTICES.md +123 -15
  3. package/bin/deck.js +264 -21
  4. package/dist/web/assets/BrowserWatchModal-DD6srgsk.js +1 -0
  5. package/dist/web/assets/UsageHistoryModal-zExZpRWU.js +1 -0
  6. package/dist/web/assets/index-Bf-qcZ6x.css +1 -0
  7. package/dist/web/assets/index-Dsca2TwS.js +296 -0
  8. package/dist/web/index.html +9 -5
  9. package/hook/hook.js +631 -99
  10. package/package.json +1 -1
  11. package/release-notes.json +62 -1
  12. package/src/server/activity.mjs +42 -4
  13. package/src/server/args.mjs +30 -4
  14. package/src/server/browser-history.mjs +164 -38
  15. package/src/server/browser-presence.mjs +60 -0
  16. package/src/server/browser-react.mjs +160 -7
  17. package/src/server/browser-watch-store.mjs +319 -17
  18. package/src/server/browser-watch.mjs +304 -60
  19. package/src/server/ccusage.mjs +20 -4
  20. package/src/server/claude-accounts.mjs +79 -34
  21. package/src/server/claude-identity.mjs +121 -0
  22. package/src/server/cswap-admin.mjs +160 -133
  23. package/src/server/cswap-auto.mjs +86 -7
  24. package/src/server/cswap-install.mjs +280 -43
  25. package/src/server/deck-home.mjs +1 -1
  26. package/src/server/deck-prefs.mjs +252 -36
  27. package/src/server/deck-probe.mjs +1 -1
  28. package/src/server/exec.mjs +84 -3
  29. package/src/server/global-install.mjs +13 -0
  30. package/src/server/index.mjs +1454 -182
  31. package/src/server/installer.mjs +136 -8
  32. package/src/server/lan-about.mjs +8 -3
  33. package/src/server/lan-engine.mjs +385 -51
  34. package/src/server/lan-socket.mjs +384 -57
  35. package/src/server/lan-sync.mjs +495 -22
  36. package/src/server/log-writer.mjs +575 -2
  37. package/src/server/login-service.mjs +31 -4
  38. package/src/server/open-url.mjs +22 -2
  39. package/src/server/output-watch.mjs +59 -8
  40. package/src/server/purge-key.mjs +135 -0
  41. package/src/server/quota.mjs +112 -7
  42. package/src/server/running-deck.mjs +15 -1
  43. package/src/server/self-update.mjs +98 -4
  44. package/src/server/session-recap.mjs +89 -0
  45. package/src/server/static-cache.mjs +67 -0
  46. package/src/server/stop-deck.mjs +2 -2
  47. package/src/server/store-lock.mjs +51 -0
  48. package/src/server/supervisor.mjs +1 -1
  49. package/src/server/system-metrics.mjs +143 -17
  50. package/dist/web/assets/index-BdYsAtN8.js +0 -274
  51. 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
- .catch(() => {});
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.