ccdeck 3.29.10 → 3.30.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 (176) hide show
  1. package/README.md +1 -1
  2. package/bin/agent-dag.js +67 -118
  3. package/bin/cli/help.js +67 -0
  4. package/bin/cli/login-item.js +182 -0
  5. package/bin/cli/one-shot.js +254 -0
  6. package/bin/cli/package.js +39 -0
  7. package/bin/cli/pulse.js +98 -0
  8. package/bin/cli/restart.js +179 -0
  9. package/bin/cli/screen.js +166 -0
  10. package/bin/cli/second-start.js +120 -0
  11. package/bin/cli/startup.js +459 -0
  12. package/bin/cli/uninstall.js +169 -0
  13. package/bin/deck.js +112 -1559
  14. package/dist/web/assets/BrowserWatchModal-DP3YMtEi.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
  16. package/dist/web/assets/index-3vS6A0vt.css +1 -0
  17. package/dist/web/assets/index-BVTWHDta.js +298 -0
  18. package/dist/web/index.html +2 -2
  19. package/hook/hook.js +15 -14
  20. package/package.json +1 -1
  21. package/release-notes.json +28 -0
  22. package/src/server/account-projects.mjs +25 -11
  23. package/src/server/account-routes.mjs +254 -0
  24. package/src/server/activity.mjs +3 -3
  25. package/src/server/append-budget.mjs +185 -0
  26. package/src/server/append-failures.mjs +147 -0
  27. package/src/server/args.mjs +37 -5
  28. package/src/server/atomic-write.mjs +300 -0
  29. package/src/server/block-notify.mjs +0 -5
  30. package/src/server/boot-lock.mjs +1 -1
  31. package/src/server/brand.mjs +7 -6
  32. package/src/server/browser-react.mjs +10 -2
  33. package/src/server/browser-watch-decks.mjs +146 -0
  34. package/src/server/browser-watch-feed.mjs +69 -0
  35. package/src/server/browser-watch-log.mjs +173 -0
  36. package/src/server/browser-watch-mtime.mjs +18 -0
  37. package/src/server/browser-watch-relay.mjs +112 -0
  38. package/src/server/browser-watch-routes.mjs +172 -0
  39. package/src/server/browser-watch-seen.mjs +127 -0
  40. package/src/server/browser-watch-store.mjs +14 -143
  41. package/src/server/browser-watch.mjs +278 -626
  42. package/src/server/canonical-path.mjs +150 -0
  43. package/src/server/ccusage-failure.mjs +64 -0
  44. package/src/server/ccusage-install.mjs +459 -0
  45. package/src/server/ccusage-runner.mjs +271 -0
  46. package/src/server/ccusage.mjs +21 -756
  47. package/src/server/claude-accounts.mjs +220 -464
  48. package/src/server/claude-collector.mjs +189 -0
  49. package/src/server/claude-dir.mjs +2 -2
  50. package/src/server/claude-identity.mjs +5 -4
  51. package/src/server/claude-reset-credits.mjs +130 -0
  52. package/src/server/claude-verdicts.mjs +174 -0
  53. package/src/server/clear-route.mjs +191 -0
  54. package/src/server/codex-auth.mjs +4 -4
  55. package/src/server/codex-dir.mjs +52 -8
  56. package/src/server/codex-enrichment.mjs +310 -0
  57. package/src/server/codex-quota.mjs +5 -3
  58. package/src/server/codex-translate.mjs +407 -0
  59. package/src/server/codex-usage.mjs +8 -5
  60. package/src/server/codex-watch.mjs +496 -0
  61. package/src/server/cswap-auto-loop.mjs +342 -0
  62. package/src/server/cswap-auto-readers.mjs +303 -0
  63. package/src/server/cswap-auto.mjs +51 -609
  64. package/src/server/cswap-install.mjs +69 -265
  65. package/src/server/cswap-layout.mjs +242 -0
  66. package/src/server/deck-home.mjs +1 -1
  67. package/src/server/deck-prefs.mjs +51 -184
  68. package/src/server/deck-scope.mjs +52 -0
  69. package/src/server/desktop-update-routes.mjs +75 -0
  70. package/src/server/detach.mjs +78 -0
  71. package/src/server/discovery.mjs +298 -0
  72. package/src/server/event-log.mjs +328 -0
  73. package/src/server/event-pipeline.mjs +235 -0
  74. package/src/server/event-ring.mjs +214 -0
  75. package/src/server/event-routes.mjs +352 -0
  76. package/src/server/event-sink.mjs +26 -0
  77. package/src/server/exec-children.mjs +128 -0
  78. package/src/server/exec-not-found.mjs +213 -0
  79. package/src/server/exec-output.mjs +23 -0
  80. package/src/server/exec-spec.mjs +341 -0
  81. package/src/server/exec.mjs +74 -661
  82. package/src/server/health-route.mjs +42 -0
  83. package/src/server/hook-challenge.mjs +37 -0
  84. package/src/server/http-io.mjs +104 -0
  85. package/src/server/index.mjs +172 -7620
  86. package/src/server/install-layout.mjs +506 -0
  87. package/src/server/installer.mjs +79 -608
  88. package/src/server/jsonl-chunks.mjs +139 -0
  89. package/src/server/lan-accounts.mjs +27 -0
  90. package/src/server/lan-beacon.mjs +457 -0
  91. package/src/server/lan-call.mjs +239 -0
  92. package/src/server/lan-copies.mjs +270 -0
  93. package/src/server/lan-deck.mjs +583 -0
  94. package/src/server/lan-dials.mjs +161 -0
  95. package/src/server/lan-engine.mjs +475 -1024
  96. package/src/server/lan-hearing.mjs +49 -0
  97. package/src/server/lan-inbound.mjs +100 -0
  98. package/src/server/lan-invite-offer.mjs +77 -0
  99. package/src/server/lan-invite.mjs +207 -0
  100. package/src/server/lan-lines.mjs +73 -0
  101. package/src/server/lan-manifest.mjs +74 -0
  102. package/src/server/lan-reach.mjs +82 -69
  103. package/src/server/lan-requests.mjs +249 -0
  104. package/src/server/lan-round-record.mjs +45 -0
  105. package/src/server/lan-round-timer.mjs +62 -0
  106. package/src/server/lan-routes.mjs +143 -0
  107. package/src/server/lan-serve.mjs +112 -0
  108. package/src/server/lan-socket.mjs +243 -925
  109. package/src/server/lan-sync.mjs +58 -1026
  110. package/src/server/lan-tailnet-poll.mjs +57 -0
  111. package/src/server/lan-turns.mjs +80 -0
  112. package/src/server/lan-wire.mjs +620 -0
  113. package/src/server/lifecycle.mjs +421 -0
  114. package/src/server/listen.mjs +110 -0
  115. package/src/server/live-decks.mjs +138 -0
  116. package/src/server/load-average.mjs +32 -0
  117. package/src/server/log-election.mjs +235 -0
  118. package/src/server/log-replay.mjs +374 -0
  119. package/src/server/log-writer.mjs +72 -557
  120. package/src/server/login-service.mjs +3 -2
  121. package/src/server/macmon.mjs +1 -1
  122. package/src/server/memory-files.mjs +163 -0
  123. package/src/server/memory-metrics.mjs +178 -0
  124. package/src/server/metrics-history.mjs +94 -0
  125. package/src/server/metrics-run.mjs +100 -0
  126. package/src/server/music-routes.mjs +134 -0
  127. package/src/server/network-metrics.mjs +5 -3
  128. package/src/server/network-sampler.mjs +256 -0
  129. package/src/server/npm-latest.mjs +385 -0
  130. package/src/server/npm-upgrade.mjs +352 -0
  131. package/src/server/npx.mjs +41 -7
  132. package/src/server/output-watch.mjs +5 -4
  133. package/src/server/pinned-build.mjs +108 -0
  134. package/src/server/prefs-path.mjs +14 -0
  135. package/src/server/prefs-refusal.mjs +77 -0
  136. package/src/server/prefs-routes.mjs +76 -0
  137. package/src/server/prefs-set-aside.mjs +86 -0
  138. package/src/server/prefs-state.mjs +51 -0
  139. package/src/server/process-command.mjs +104 -0
  140. package/src/server/process-list.mjs +547 -0
  141. package/src/server/pulse-line.mjs +132 -0
  142. package/src/server/purge-key.mjs +7 -7
  143. package/src/server/quota-cli.mjs +174 -0
  144. package/src/server/quota-oauth.mjs +190 -0
  145. package/src/server/quota-shape.mjs +229 -0
  146. package/src/server/quota-store-resets.mjs +206 -0
  147. package/src/server/quota.mjs +123 -509
  148. package/src/server/relay-guard.mjs +3 -3
  149. package/src/server/request-gates.mjs +441 -0
  150. package/src/server/restart-note.mjs +180 -0
  151. package/src/server/retire-sound-hook.mjs +3 -3
  152. package/src/server/ring-bounds.mjs +215 -0
  153. package/src/server/running-deck.mjs +1 -1
  154. package/src/server/running-version.mjs +24 -0
  155. package/src/server/self-update.mjs +39 -1371
  156. package/src/server/session-enrichment.mjs +622 -0
  157. package/src/server/session-read-gate.mjs +49 -0
  158. package/src/server/session-tracking.mjs +242 -0
  159. package/src/server/sse-clients.mjs +222 -0
  160. package/src/server/stale-discovery.mjs +30 -0
  161. package/src/server/static-serve.mjs +81 -0
  162. package/src/server/supervisor.mjs +73 -12
  163. package/src/server/system-metrics.mjs +169 -1935
  164. package/src/server/term.mjs +11 -249
  165. package/src/server/thermal-metrics.mjs +520 -0
  166. package/src/server/thermal-sampler.mjs +204 -0
  167. package/src/server/token-redact.mjs +123 -0
  168. package/src/server/transcript-gate.mjs +107 -0
  169. package/src/server/transcript-scan.mjs +563 -0
  170. package/src/server/usage-routes.mjs +93 -0
  171. package/src/server/uv-bootstrap.mjs +1 -1
  172. package/src/server/wordmark.mjs +127 -0
  173. package/dist/web/assets/BrowserWatchModal-CMn28FHb.js +0 -1
  174. package/dist/web/assets/UsageHistoryModal-C-sPhsKT.js +0 -1
  175. package/dist/web/assets/index-C1YFI9f4.js +0 -298
  176. package/dist/web/assets/index-Ck2RAla3.css +0 -1
@@ -1,240 +1,24 @@
1
- // Which of the running decks writes an event to the log they share?
1
+ // Appending to the events log that several decks may share: one whole line per
2
+ // write(2), in the order pushEvent produced them, behind a queue that is
3
+ // bounded and counts what it refused and what failed to land — and the drain,
4
+ // the flush and the Clear that take their turn on that same queue.
2
5
  //
3
- // The hook already answers that for the events it delivers: it groups the decks
4
- // it is about to post to by the log file each one names in its discovery record,
5
- // elects one per file, and marks the request to every other one `?persist=0`.
6
- // See electWriters in hook/hook.js.
7
- //
8
- // The Codex rollout watcher never goes through the hook — it builds its events
9
- // inside the server by tailing ~/.codex/sessions/**/rollout-*.jsonl — so nothing
10
- // suppressed the copies on that path: every deck tailing the same rollout
11
- // appended its own line to the one events.jsonl they all default to, so each
12
- // Codex tool call, prompt and session start landed there once per running deck.
13
- // That is the duplication the hook election was added to end, still open on the
14
- // path that is the only Codex capture there is on Windows, where Codex hooks
15
- // never fire at all.
16
- //
17
- // So the server runs the same election, over the same discovery records, with
18
- // the same tie-break. The rule is repeated here rather than imported from
19
- // hook/hook.js because that file is copied out of the package and run standalone
20
- // by the host CLI, with no path back to the module it came from — the same
21
- // reason it re-derives the Claude config dir inline. The two copies are pinned
22
- // equal by a test, as challengeProof's pair already is.
6
+ // Which deck writes a given line is decided before any of this, by the
7
+ // election in log-election.mjs, which used to sit at the top of this file.
23
8
  import { open, truncate, unlink } from "node:fs/promises";
24
- import { realpathSync } from "node:fs";
25
- import { basename, dirname, join, resolve, win32, posix } from "node:path";
26
- import { PRODUCT } from "./brand.mjs";
27
-
28
- /**
29
- * Does this platform's filesystem treat two spellings that differ only in case
30
- * as the same file? The platform is a parameter so both answers can be checked
31
- * from either kind of machine.
32
- *
33
- * Windows always does, and macOS does by default (APFS and HFS+ are formatted
34
- * case-insensitive unless the user deliberately chose otherwise). Linux does
35
- * not, and folding case there would be a bug of its own: /srv/a/events.jsonl and
36
- * /srv/A/events.jsonl are two real files, each of which needs a writer.
37
- */
38
- /**
39
- * The one spelling of an events log, so two decks pointed at one file land in
40
- * one group (#793).
41
- *
42
- * `resolve` alone was what shipped, and it settles relative-vs-absolute and
43
- * nothing else. The election below then only case-folds — so two spellings of
44
- * one file read as two files, which is the exact thing `bin/deck.js`'s comment
45
- * over this value says must not happen. On Windows it needs no odd user action:
46
- * `claudeConfigDir()` derives from `homedir()`, and a shell whose `USERPROFILE`
47
- * is 8.3-shortened yields a different default string than one with the long
48
- * form. `subst` and mapped drives and junctions do it too, and on macOS so does
49
- * `/tmp` against `/private/tmp`.
50
- *
51
- * What it cost was not merely a duplicate group. BOTH decks were then elected,
52
- * so every hook event was appended twice — the duplicate-tools-after-restart
53
- * symptom the election exists to end — and `logSharing()` compares the same
54
- * string, so both answered `mine: true` and `POST /api/clear` truncated a file
55
- * this deck does not own. That is the #698 history loss the ownership gate was
56
- * added to prevent.
57
- *
58
- * THE DIRECTORY IS CANONICALISED EVEN WHEN THE FILE IS NOT THERE, which is the
59
- * half a plain realpath misses. On a first run, or against a `--history` naming
60
- * a file the deck will create, `realpath` throws ENOENT — and falling back to
61
- * the resolved string would leave the two spellings different for exactly the
62
- * run that creates the file. The parent exists (or is about to be created under
63
- * one canonical name), so it is canonicalised and the basename rejoined.
64
- *
65
- * `canonicalWorkspace` in index.mjs is the same rule for the other path this
66
- * deck publishes, with three comments naming 8.3 expansion as its reason. This
67
- * is that rule reaching the value two lines away from it.
68
- */
69
- export function canonicalLogPath(raw) {
70
- if (typeof raw !== "string" || raw.trim() === "") return "";
71
- const abs = resolve(raw);
72
- try { return realpathSync.native(abs); } catch { /* not created yet */ }
73
- try { return join(realpathSync.native(dirname(abs)), basename(abs)); } catch { return abs; }
74
- }
75
-
76
- export const foldsCase = (platform = process.platform) =>
77
- platform === "win32" || platform === "darwin";
78
-
79
- /**
80
- * Of these decks, which ones write to disk? Returns the subset that should;
81
- * every other one is expected to draw the event and keep no record of it.
82
- *
83
- * Decks are grouped by the log file each one names in its discovery record and
84
- * one deck per group is elected. Grouping by the file rather than counting decks
85
- * is what keeps the overrides honest: a deck run with `--history` sits alone in
86
- * its own group and always writes, a deck run with `--no-persist` reports no
87
- * file and can never be elected to write for one that does, and a deck too old
88
- * to report either keeps the behaviour it had before this rule existed. Within a
89
- * group the lowest port wins — a fixed rule, so the same deck holds the file for
90
- * as long as it is up and the next one inherits it as soon as that deck is gone.
91
- *
92
- * Kept byte-for-byte equivalent to electWriters in hook/hook.js: the two decide
93
- * for the same decks over the same records, and a disagreement between them
94
- * means one log line written twice or none at all.
95
- */
96
- export function electWriters(decks, platform = process.platform) {
97
- const byLog = new Map();
98
- for (const d of decks) {
99
- const log = typeof d.persist === "string" ? d.persist : "";
100
- // Two namespaces, so a deck with no log to share — and a deck too old to
101
- // report one — is alone in its group and cannot collide with a real path.
102
- const key = log
103
- ? `log:${foldsCase(platform) ? log.toLowerCase() : log}`
104
- : `deck:${d.pid}:${d.port}`;
105
- const held = byLog.get(key);
106
- // Ports are unique among live decks; pid only breaks a tie a stale
107
- // discovery file could invent, so the answer stays deterministic.
108
- if (!held || d.port < held.port || (d.port === held.port && d.pid < held.pid)) {
109
- byLog.set(key, d);
110
- }
111
- }
112
- return new Set(byLog.values());
113
- }
114
-
115
- /**
116
- * Would a deck scoped to `workspace` capture a rollout running in `cwd`? An
117
- * empty workspace is unscoped and captures every session; a rollout that never
118
- * said where it runs is inside no workspace, so only an unscoped deck draws it.
119
- *
120
- * This answers two questions with one function — whether THIS deck tails a
121
- * rollout, and whether another deck tails it too — and that is only sound while
122
- * the rule below is the rule every deck actually runs. Model another deck's
123
- * capture with anything else and the election covers the wrong set: a deck that
124
- * writes without being elected, or an elected deck that never opened the file.
125
- *
126
- * It is also the rule hook/hook.js runs for the sessions it delivers, under the
127
- * name capturesSession — that script is copied out of the package and run
128
- * standalone, so the two are written twice and pinned equal by a test walking
129
- * one table of paths through both. They were not equal: case was folded here on
130
- * every platform, so on Linux a deck scoped to /srv/proj captured Codex sessions
131
- * from /srv/Proj and Claude sessions from neither. Those are two real
132
- * directories there, and the hook's own comment says what folding them together
133
- * costs — a deck handed the events of a tree it was not scoped to. So the fold
134
- * is per-platform on both sides now, and `--workspace` means one thing.
135
- *
136
- * (The narrow window that opens: two decks on Linux whose workspaces differ only
137
- * in case, one of them old enough to still fold, both containing one rollout's
138
- * cwd. Each models the other as tailing the file; one of them is wrong, and the
139
- * cost is a single log line written twice.)
140
- *
141
- * The platform is a parameter, following the hook's cwdInWorkspace and
142
- * spawnSpec in src/server/exec.mjs, so the Windows separator is testable from a
143
- * POSIX machine.
144
- */
145
- export function codexCwdInWorkspace(cwd, workspace, platform = process.platform) {
146
- if (!workspace || typeof workspace !== "string") return true;
147
- if (!cwd || typeof cwd !== "string") return false;
148
- const p = platform === "win32" ? win32 : posix;
149
- const fold = s => (foldsCase(platform) ? s.toLowerCase() : s);
150
- const a = fold(p.resolve(cwd));
151
- const b = fold(p.resolve(workspace));
152
- if (a === b) return true;
153
- // A root ("C:\", "/") already ends in the separator; appending a second one
154
- // would match nothing.
155
- return a.startsWith(b.endsWith(p.sep) ? b : b + p.sep);
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
-
184
- /**
185
- * Does this deck append a rollout's events to its log, or is another deck doing
186
- * it? `decks` is every deck registered right now, `pid` identifies this one
187
- * among them, and `cwd` is the workspace the rollout is running in.
188
- *
189
- * The group is every deck that tails this same rollout: each deck decides for
190
- * itself, so all of them whose workspace contains the cwd read the file and all
191
- * of them would write it. The hook builds the same group the same way for the
192
- * events it delivers — it used to narrow them to the longest workspace match
193
- * first, which is the asymmetry the predicate above describes the end of. A deck
194
- * started with `--no-codex` tails nothing and is left out; electing it would
195
- * mean the rollout's events reach no log at all. A deck too old to say either
196
- * way is assumed to be tailing, which is what it was doing before this field
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.
210
- */
211
- export function writesCodexLog({ decks, pid, cwd, platform = process.platform }) {
212
- const live = Array.isArray(decks) ? decks : [];
213
- const self = live.find(d => d && d.pid === pid) ?? null;
214
- // No record of our own on disk — the window before the first heartbeat writes
215
- // it, or a deck that cannot write one at all. Nobody can elect us and we
216
- // cannot see who else is here, so keep what this deck did before the election
217
- // existed and write. A line written twice is recoverable; a deck that quietly
218
- // stops recording anything is not.
219
- if (!self || typeof self.persist !== "string" || self.persist === "") return true;
220
-
221
- const group = [self];
222
- for (const d of live) {
223
- if (!d || d.pid === self.pid) continue;
224
- if (d.codex === false) continue;
225
- if (!sameCodexTree(self.codexHome, d.codexHome, platform)) continue;
226
- if (!codexCwdInWorkspace(cwd, d.workspace ?? "", platform)) continue;
227
- group.push(d);
228
- }
229
- return electWriters(group, platform).has(self);
230
- }
9
+ // How much the queue may hold, and what it refused: see append-budget.mjs.
10
+ import {
11
+ MAX_PENDING_APPEND_CHARS, appendQueueStats, noteRefused, queueIsFull, releaseCharge, takeCharge,
12
+ } from "./append-budget.mjs";
13
+ // What failed to land, and how much has: see append-failures.mjs.
14
+ import { appendFailureStats, appendsLanded, noteAppendFailed, noteAppendLanded } from "./append-failures.mjs";
231
15
 
232
16
  // ─── Appending one whole line ─────────────────────────────────────────────
233
17
  //
234
- // The election above decides WHICH deck writes a line. This decides HOW, and
235
- // the two are answers to the same question: the log is one file that several
236
- // processes append to, so a line that arrives in pieces is a line another
237
- // writer can land inside.
18
+ // The election in log-election.mjs decides WHICH deck writes a line. This
19
+ // decides HOW, and the two are answers to the same question: the log is one
20
+ // file that several processes append to, so a line that arrives in pieces is a
21
+ // line another writer can land inside.
238
22
  //
239
23
  // What was wrong. events.jsonl was appended with `fsPromises.appendFile`, which
240
24
  // is not one write(2). Node's writeFileHandle loops over the payload in chunks
@@ -300,256 +84,34 @@ export function writesCodexLog({ decks, pid, cwd, platform = process.platform })
300
84
  // already paid — open, write, close — minus the extra writes.
301
85
  const appendTails = new Map();
302
86
 
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 };
87
+ /** The step the next one queued for this log starts behind: the last one
88
+ * queued, or nothing when its queue is empty. */
89
+ function tailOf(filePath) {
90
+ return appendTails.get(filePath) ?? Promise.resolve();
413
91
  }
414
92
 
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
93
  /**
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.
94
+ * Make `tail` the step the next one queued for this log starts behind, and
95
+ * drop it from the map once it settles.
96
+ *
97
+ * Dropped so a process that writes to several logs over its life does not hold
98
+ * a promise per path it has finished with. Only the tail installed here is
99
+ * cleared: if another step chained on in the meantime the map already points
100
+ * at that one, and deleting it would let the next line race the one still in
101
+ * flight.
477
102
  */
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;
103
+ function installTail(filePath, tail) {
104
+ appendTails.set(filePath, tail);
105
+ tail.then(() => { if (appendTails.get(filePath) === tail) appendTails.delete(filePath); });
521
106
  }
522
107
 
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
- }
108
+ // What the queue is allowed to weigh (#1030) — the ceiling, the charge a line
109
+ // carries until its write settles, and the refusals past it: append-budget.mjs.
110
+ export { MAX_PENDING_APPEND_CHARS, appendQueueStats };
535
111
 
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
- }
112
+ // What the queue failed to write (#991), and how many lines have landed on
113
+ // each log (#1130): append-failures.mjs.
114
+ export { appendFailureStats, appendsLanded };
553
115
 
554
116
  /**
555
117
  * Append one already-serialized line to the shared log, whole.
@@ -566,8 +128,8 @@ function noteAppendFailed(filePath, line, err) {
566
128
  * about anyway. The chain deliberately continues past a failure — one ENOSPC
567
129
  * must not stop every later event from being recorded once space is back.
568
130
  *
569
- * BOUNDED, by MAX_PENDING_APPEND_CHARS above. Past it the line is refused at
570
- * the door, counted, and reported once per episode.
131
+ * BOUNDED, by MAX_PENDING_APPEND_CHARS in append-budget.mjs. Past it the line
132
+ * is refused at the door, counted, and reported once per episode.
571
133
  *
572
134
  * Refused at the door, and not evicted from the middle of the queue, which is
573
135
  * the other shape #1030 offers. A line that has been accepted has been charged,
@@ -588,34 +150,14 @@ function noteAppendFailed(filePath, line, err) {
588
150
  */
589
151
  export function appendLogLine(filePath, line) {
590
152
  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
- }
153
+ if (queueIsFull(charged)) {
154
+ noteRefused(filePath, charged);
612
155
  // Resolved rather than rejected, and resolved rather than the tail: the
613
156
  // contract every caller has is "never rejects, never makes you wait".
614
157
  return Promise.resolve();
615
158
  }
616
- pendingLines++;
617
- pendingChars += charged;
618
- const tail = (appendTails.get(filePath) ?? Promise.resolve())
159
+ takeCharge(charged);
160
+ const tail = tailOf(filePath)
619
161
  .then(() => writeWholeLine(filePath, line))
620
162
  // Counted, and reported once per episode — see noteAppendFailed. The order
621
163
  // of these four steps is the whole of the union between the append-queue
@@ -624,9 +166,10 @@ export function appendLogLine(filePath, line) {
624
166
  //
625
167
  // * the failure handler comes BEFORE the `catch`, or the rejection has
626
168
  // 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;
169
+ // * the `catch` stays, because the map cleanup in installTail chains a
170
+ // bare `.then` on this tail and "never rejects" is the contract every
171
+ // caller of this function has — neither handler above may be what
172
+ // breaks it;
630
173
  // * `finally` comes LAST, so the charge is given back whether the write
631
174
  // landed, failed, or a handler here threw on its way past. It would run
632
175
  // on a rejection anyway; what the ordering buys is that it cannot be
@@ -637,16 +180,25 @@ export function appendLogLine(filePath, line) {
637
180
  // which is the moment the closure holding it becomes collectable, and so
638
181
  // the moment the heap it was standing for is actually back.
639
182
  .finally(() => releaseCharge(charged));
640
- appendTails.set(filePath, tail);
641
- // Drop the chain once it drains, so a process that writes to several logs
642
- // over its life does not hold a promise per path it has finished with. Only
643
- // the tail we just installed is cleared: if another append chained on in the
644
- // meantime the map already points at that one, and deleting it would let the
645
- // next line race the one still in flight.
646
- tail.then(() => { if (appendTails.get(filePath) === tail) appendTails.delete(filePath); });
183
+ installTail(filePath, tail);
647
184
  return tail;
648
185
  }
649
186
 
187
+ /**
188
+ * `drained` — a promise that answers true once what it waits for has landed —
189
+ * or false at `ms`, whichever comes first: the bound drainAppends and
190
+ * flushAppends below both keep. The bell is unref'd, so a wait still running
191
+ * never holds the process open by itself, and it is cleared either way.
192
+ */
193
+ function withinDeadline(drained, ms) {
194
+ let bell;
195
+ const deadline = new Promise(resolve => {
196
+ bell = setTimeout(() => resolve(false), ms);
197
+ bell.unref?.();
198
+ });
199
+ return Promise.race([drained, deadline]).finally(() => clearTimeout(bell));
200
+ }
201
+
650
202
  /**
651
203
  * Wait for every queued append to land, bounded.
652
204
  *
@@ -676,17 +228,12 @@ export function appendLogLine(filePath, line) {
676
228
  export function drainAppends(ms = 3000) {
677
229
  const pending = [...appendTails.values()];
678
230
  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
231
  // `catch` on each: a failed append has already been swallowed by the chain,
685
232
  // and a rejection here would skip the rest of the drain.
686
- return Promise.race([
233
+ return withinDeadline(
687
234
  Promise.all(pending.map(p => Promise.resolve(p).catch(() => {}))).then(() => true),
688
- deadline,
689
- ]).finally(() => clearTimeout(bell));
235
+ ms,
236
+ );
690
237
  }
691
238
 
692
239
  /**
@@ -740,18 +287,10 @@ export function drainAppends(ms = 3000) {
740
287
  export function flushAppends(filePath, ms = 3000) {
741
288
  const tail = appendTails.get(filePath);
742
289
  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));
290
+ // The tail never rejects — appendLogLine's own catch sees to that — but a
291
+ // rejection here would reach a caller that was promised a boolean, so it is
292
+ // handled rather than assumed away.
293
+ return withinDeadline(Promise.resolve(tail).then(() => true, () => true), ms);
755
294
  }
756
295
 
757
296
  /**
@@ -822,7 +361,7 @@ export function flushAppends(filePath, ms = 3000) {
822
361
  * @returns {Promise<boolean>} whether it was over before the deadline
823
362
  */
824
363
  export function emptyLog(filePath, archives = [], ms = 3000, outcome = {}) {
825
- const turn = (appendTails.get(filePath) ?? Promise.resolve())
364
+ const turn = tailOf(filePath)
826
365
  .then(() => truncate(filePath, 0))
827
366
  .then(
828
367
  () => { outcome.error = null; },
@@ -837,34 +376,10 @@ export function emptyLog(filePath, archives = [], ms = 3000, outcome = {}) {
837
376
  outcome.archiveError = first;
838
377
  })
839
378
  .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); });
379
+ installTail(filePath, turn);
844
380
  return flushAppends(filePath, ms);
845
381
  }
846
382
 
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
-
868
383
  /**
869
384
  * One line, one write(2). The loop exists only for the short-write case
870
385
  * described above; on every filesystem that does not do that it runs once.