ccdeck 3.29.11 → 3.31.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 +4 -3
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
- package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
- package/dist/web/assets/index-CZJf1Cpp.js +299 -0
- package/dist/web/assets/index-D4q1ElXU.css +1 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +40 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +7 -3
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +2 -2
- package/src/server/deck-prefs.mjs +58 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +174 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +110 -0
- package/src/server/prefs-give-back.mjs +206 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +86 -0
- package/src/server/prefs-routes.mjs +99 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +70 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/provider-status.mjs +307 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +108 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
- package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
- package/dist/web/assets/index-jmrcEsCj.js +0 -298
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The load average, and the one rule for when it is a reading at all.
|
|
2
|
+
//
|
|
3
|
+
// Two things in system-metrics.mjs ask for it: the `load:1m` series the sampler
|
|
4
|
+
// records on every CPU tick, and the `loadavg` that /api/system answers. Each
|
|
5
|
+
// used to carry its own copy of the platform test and its own copy of the
|
|
6
|
+
// comment defending it, the second pointing at the first. #1028 was a bug in
|
|
7
|
+
// BOTH copies at once — the same extra clause on each — so this file is the rule
|
|
8
|
+
// spelled once, with both callers asking it.
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The one-, five- and fifteen-minute load averages, to two places, or null on a
|
|
13
|
+
* platform that publishes none.
|
|
14
|
+
*
|
|
15
|
+
* `os.loadavg()` reads a kernel value — no syscall worth the name — which is why
|
|
16
|
+
* the sampler can afford it on every CPU tick. Windows returns [0,0,0], which is
|
|
17
|
+
* not a reading and is not reported as one.
|
|
18
|
+
*
|
|
19
|
+
* THE PLATFORM TEST IS THE WHOLE TEST. It used to be joined by
|
|
20
|
+
* `load.some(n => n > 0)`, which reads as belt and braces and is not: Node
|
|
21
|
+
* documents `os.loadavg()` as always [0,0,0] on Windows, so the platform test
|
|
22
|
+
* alone already excludes every non-reading — and the extra clause could only
|
|
23
|
+
* ever reject a reading that was REAL. `/proc/loadavg` on a genuinely quiet
|
|
24
|
+
* Linux box says `0.00 0.00 0.00`, so "Queued work" recorded no points at all
|
|
25
|
+
* for every quiet minute, and after the machine woke the chart read as though
|
|
26
|
+
* the deck had been switched off through them. And MachinePanel gates the whole
|
|
27
|
+
* section on `{loadavg && …}`, so an idle Linux box had the section disappear
|
|
28
|
+
* from under it (#1028).
|
|
29
|
+
*/
|
|
30
|
+
export function loadReading(platform = process.platform, load = os.loadavg()) {
|
|
31
|
+
return platform !== "win32" ? load.map(n => Math.round(n * 100) / 100) : null;
|
|
32
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// Which of the running decks writes an event to the log they share?
|
|
2
|
+
//
|
|
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.
|
|
23
|
+
//
|
|
24
|
+
// These lived at the top of log-writer.mjs, above the appender they decide
|
|
25
|
+
// for. They are pure rules over discovery records and paths and the appender
|
|
26
|
+
// never called one, so they moved on their own: event-log.mjs asks the
|
|
27
|
+
// election who owns the log, the rollout watcher and the boot replay ask it
|
|
28
|
+
// whose rollout a cwd is, and discovery.mjs and the launcher spell the log's
|
|
29
|
+
// path through it. The bodies are unchanged.
|
|
30
|
+
import { realpathSync } from "node:fs";
|
|
31
|
+
import { basename, dirname, join, resolve, win32, posix } from "node:path";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The one spelling of an events log, so two decks pointed at one file land in
|
|
35
|
+
* one group (#793).
|
|
36
|
+
*
|
|
37
|
+
* `resolve` alone was what shipped, and it settles relative-vs-absolute and
|
|
38
|
+
* nothing else. The election below then only case-folds — so two spellings of
|
|
39
|
+
* one file read as two files, which is the exact thing `bin/deck.js`'s comment
|
|
40
|
+
* over this value says must not happen. On Windows it needs no odd user action:
|
|
41
|
+
* `claudeConfigDir()` derives from `homedir()`, and a shell whose `USERPROFILE`
|
|
42
|
+
* is 8.3-shortened yields a different default string than one with the long
|
|
43
|
+
* form. `subst` and mapped drives and junctions do it too, and on macOS so does
|
|
44
|
+
* `/tmp` against `/private/tmp`.
|
|
45
|
+
*
|
|
46
|
+
* What it cost was not merely a duplicate group. BOTH decks were then elected,
|
|
47
|
+
* so every hook event was appended twice — the duplicate-tools-after-restart
|
|
48
|
+
* symptom the election exists to end — and `logSharing()` compares the same
|
|
49
|
+
* string, so both answered `mine: true` and `POST /api/clear` truncated a file
|
|
50
|
+
* this deck does not own. That is the #698 history loss the ownership gate was
|
|
51
|
+
* added to prevent.
|
|
52
|
+
*
|
|
53
|
+
* THE DIRECTORY IS CANONICALISED EVEN WHEN THE FILE IS NOT THERE, which is the
|
|
54
|
+
* half a plain realpath misses. On a first run, or against a `--history` naming
|
|
55
|
+
* a file the deck will create, `realpath` throws ENOENT — and falling back to
|
|
56
|
+
* the resolved string would leave the two spellings different for exactly the
|
|
57
|
+
* run that creates the file. The parent exists (or is about to be created under
|
|
58
|
+
* one canonical name), so it is canonicalised and the basename rejoined.
|
|
59
|
+
*
|
|
60
|
+
* `canonicalWorkspace` in canonical-path.mjs is the same rule for the other
|
|
61
|
+
* path this deck publishes, with three comments naming 8.3 expansion as its
|
|
62
|
+
* reason. This is that rule reaching the value two lines away from it.
|
|
63
|
+
*/
|
|
64
|
+
export function canonicalLogPath(raw) {
|
|
65
|
+
if (typeof raw !== "string" || raw.trim() === "") return "";
|
|
66
|
+
const abs = resolve(raw);
|
|
67
|
+
try { return realpathSync.native(abs); } catch { /* not created yet */ }
|
|
68
|
+
try { return join(realpathSync.native(dirname(abs)), basename(abs)); } catch { return abs; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Does this platform's filesystem treat two spellings that differ only in case
|
|
73
|
+
* as the same file? The platform is a parameter so both answers can be checked
|
|
74
|
+
* from either kind of machine.
|
|
75
|
+
*
|
|
76
|
+
* Windows always does, and macOS does by default (APFS and HFS+ are formatted
|
|
77
|
+
* case-insensitive unless the user deliberately chose otherwise). Linux does
|
|
78
|
+
* not, and folding case there would be a bug of its own: /srv/a/events.jsonl and
|
|
79
|
+
* /srv/A/events.jsonl are two real files, each of which needs a writer.
|
|
80
|
+
*/
|
|
81
|
+
export const foldsCase = (platform = process.platform) =>
|
|
82
|
+
platform === "win32" || platform === "darwin";
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Of these decks, which ones write to disk? Returns the subset that should;
|
|
86
|
+
* every other one is expected to draw the event and keep no record of it.
|
|
87
|
+
*
|
|
88
|
+
* Decks are grouped by the log file each one names in its discovery record and
|
|
89
|
+
* one deck per group is elected. Grouping by the file rather than counting decks
|
|
90
|
+
* is what keeps the overrides honest: a deck run with `--history` sits alone in
|
|
91
|
+
* its own group and always writes, a deck run with `--no-persist` reports no
|
|
92
|
+
* file and can never be elected to write for one that does, and a deck too old
|
|
93
|
+
* to report either keeps the behaviour it had before this rule existed. Within a
|
|
94
|
+
* group the lowest port wins — a fixed rule, so the same deck holds the file for
|
|
95
|
+
* as long as it is up and the next one inherits it as soon as that deck is gone.
|
|
96
|
+
*
|
|
97
|
+
* Kept byte-for-byte equivalent to electWriters in hook/hook.js: the two decide
|
|
98
|
+
* for the same decks over the same records, and a disagreement between them
|
|
99
|
+
* means one log line written twice or none at all.
|
|
100
|
+
*/
|
|
101
|
+
export function electWriters(decks, platform = process.platform) {
|
|
102
|
+
const byLog = new Map();
|
|
103
|
+
for (const d of decks) {
|
|
104
|
+
const log = typeof d.persist === "string" ? d.persist : "";
|
|
105
|
+
// Two namespaces, so a deck with no log to share — and a deck too old to
|
|
106
|
+
// report one — is alone in its group and cannot collide with a real path.
|
|
107
|
+
const key = log
|
|
108
|
+
? `log:${foldsCase(platform) ? log.toLowerCase() : log}`
|
|
109
|
+
: `deck:${d.pid}:${d.port}`;
|
|
110
|
+
const held = byLog.get(key);
|
|
111
|
+
// Ports are unique among live decks; pid only breaks a tie a stale
|
|
112
|
+
// discovery file could invent, so the answer stays deterministic.
|
|
113
|
+
if (!held || d.port < held.port || (d.port === held.port && d.pid < held.pid)) {
|
|
114
|
+
byLog.set(key, d);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return new Set(byLog.values());
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Would a deck scoped to `workspace` capture a rollout running in `cwd`? An
|
|
122
|
+
* empty workspace is unscoped and captures every session; a rollout that never
|
|
123
|
+
* said where it runs is inside no workspace, so only an unscoped deck draws it.
|
|
124
|
+
*
|
|
125
|
+
* This answers two questions with one function — whether THIS deck tails a
|
|
126
|
+
* rollout, and whether another deck tails it too — and that is only sound while
|
|
127
|
+
* the rule below is the rule every deck actually runs. Model another deck's
|
|
128
|
+
* capture with anything else and the election covers the wrong set: a deck that
|
|
129
|
+
* writes without being elected, or an elected deck that never opened the file.
|
|
130
|
+
*
|
|
131
|
+
* It is also the rule hook/hook.js runs for the sessions it delivers, under the
|
|
132
|
+
* name capturesSession — that script is copied out of the package and run
|
|
133
|
+
* standalone, so the two are written twice and pinned equal by a test walking
|
|
134
|
+
* one table of paths through both. They were not equal: case was folded here on
|
|
135
|
+
* every platform, so on Linux a deck scoped to /srv/proj captured Codex sessions
|
|
136
|
+
* from /srv/Proj and Claude sessions from neither. Those are two real
|
|
137
|
+
* directories there, and the hook's own comment says what folding them together
|
|
138
|
+
* costs — a deck handed the events of a tree it was not scoped to. So the fold
|
|
139
|
+
* is per-platform on both sides now, and `--workspace` means one thing.
|
|
140
|
+
*
|
|
141
|
+
* (The narrow window that opens: two decks on Linux whose workspaces differ only
|
|
142
|
+
* in case, one of them old enough to still fold, both containing one rollout's
|
|
143
|
+
* cwd. Each models the other as tailing the file; one of them is wrong, and the
|
|
144
|
+
* cost is a single log line written twice.)
|
|
145
|
+
*
|
|
146
|
+
* The platform is a parameter, following the hook's cwdInWorkspace and
|
|
147
|
+
* spawnSpec in src/server/exec.mjs, so the Windows separator is testable from a
|
|
148
|
+
* POSIX machine.
|
|
149
|
+
*/
|
|
150
|
+
export function codexCwdInWorkspace(cwd, workspace, platform = process.platform) {
|
|
151
|
+
if (!workspace || typeof workspace !== "string") return true;
|
|
152
|
+
if (!cwd || typeof cwd !== "string") return false;
|
|
153
|
+
const p = platform === "win32" ? win32 : posix;
|
|
154
|
+
const fold = s => (foldsCase(platform) ? s.toLowerCase() : s);
|
|
155
|
+
const a = fold(p.resolve(cwd));
|
|
156
|
+
const b = fold(p.resolve(workspace));
|
|
157
|
+
if (a === b) return true;
|
|
158
|
+
// A root ("C:\", "/") already ends in the separator; appending a second one
|
|
159
|
+
// would match nothing.
|
|
160
|
+
return a.startsWith(b.endsWith(p.sep) ? b : b + p.sep);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Do two discovery records name the same Codex tree? Each argument is a
|
|
165
|
+
* record's `codexHome`: the canonical path of the CODEX_HOME that deck tails.
|
|
166
|
+
*
|
|
167
|
+
* Anything that is not a non-empty string answers yes. That is a record written
|
|
168
|
+
* before the field existed — a machine halfway through an upgrade is the
|
|
169
|
+
* ordinary way to meet one — and guessing "a different tree" there would elect
|
|
170
|
+
* a second writer for one log on every machine with an older deck still up.
|
|
171
|
+
* Guessing "the same tree" is what every deck did before the field existed,
|
|
172
|
+
* which is the fail-safe writesCodexLog below already takes for a record too old
|
|
173
|
+
* to carry `codex` at all.
|
|
174
|
+
*
|
|
175
|
+
* Case is folded where the filesystem folds it, exactly as electWriters folds
|
|
176
|
+
* the log path: on Windows and macOS two spellings that differ only in case are
|
|
177
|
+
* one directory with one reader, and on Linux they are two directories, each
|
|
178
|
+
* read by its own deck. The other ways to spell one directory — a symlinked
|
|
179
|
+
* ~/.codex, an 8.3-shortened USERPROFILE, /tmp against /private/tmp — are
|
|
180
|
+
* settled before the value is published (writeDiscovery in discovery.mjs runs
|
|
181
|
+
* it through canonicalLogPath), so two records compared here already agree on
|
|
182
|
+
* everything but case.
|
|
183
|
+
*/
|
|
184
|
+
export function sameCodexTree(a, b, platform = process.platform) {
|
|
185
|
+
if (typeof a !== "string" || a === "" || typeof b !== "string" || b === "") return true;
|
|
186
|
+
return foldsCase(platform) ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Does this deck append a rollout's events to its log, or is another deck doing
|
|
191
|
+
* it? `decks` is every deck registered right now, `pid` identifies this one
|
|
192
|
+
* among them, and `cwd` is the workspace the rollout is running in.
|
|
193
|
+
*
|
|
194
|
+
* The group is every deck that tails this same rollout: each deck decides for
|
|
195
|
+
* itself, so all of them whose workspace contains the cwd read the file and all
|
|
196
|
+
* of them would write it. The hook builds the same group the same way for the
|
|
197
|
+
* events it delivers — it used to narrow them to the longest workspace match
|
|
198
|
+
* first, which is the asymmetry the predicate above describes the end of. A deck
|
|
199
|
+
* started with `--no-codex` tails nothing and is left out; electing it would
|
|
200
|
+
* mean the rollout's events reach no log at all. A deck too old to say either
|
|
201
|
+
* way is assumed to be tailing, which is what it was doing before this field
|
|
202
|
+
* existed.
|
|
203
|
+
*
|
|
204
|
+
* A deck reading a different Codex tree is left out for the same reason (#982).
|
|
205
|
+
* CODEX_HOME moves the whole tree, so two decks on one machine can share one
|
|
206
|
+
* events.jsonl while reading two different sets of rollouts — and for an
|
|
207
|
+
* unscoped deck the workspace test above answers yes to every cwd, so it tells
|
|
208
|
+
* them apart not at all. The record's `codex: true` said a deck tails rollouts
|
|
209
|
+
* and never said whose. So the lower-port deck won the line for a rollout its
|
|
210
|
+
* own tree does not hold and appended nothing, the deck that was reading it
|
|
211
|
+
* stood down, and the shared log recorded none of the session while the canvas
|
|
212
|
+
* drew all of it: #695's symptom, from a deck that is alive, answers its
|
|
213
|
+
* challenge, and is simply looking somewhere else. The same deck launched with
|
|
214
|
+
* a stale CODEX_HOME is the duller version of it.
|
|
215
|
+
*/
|
|
216
|
+
export function writesCodexLog({ decks, pid, cwd, platform = process.platform }) {
|
|
217
|
+
const live = Array.isArray(decks) ? decks : [];
|
|
218
|
+
const self = live.find(d => d && d.pid === pid) ?? null;
|
|
219
|
+
// No record of our own on disk — the window before the first heartbeat writes
|
|
220
|
+
// it, or a deck that cannot write one at all. Nobody can elect us and we
|
|
221
|
+
// cannot see who else is here, so keep what this deck did before the election
|
|
222
|
+
// existed and write. A line written twice is recoverable; a deck that quietly
|
|
223
|
+
// stops recording anything is not.
|
|
224
|
+
if (!self || typeof self.persist !== "string" || self.persist === "") return true;
|
|
225
|
+
|
|
226
|
+
const group = [self];
|
|
227
|
+
for (const d of live) {
|
|
228
|
+
if (!d || d.pid === self.pid) continue;
|
|
229
|
+
if (d.codex === false) continue;
|
|
230
|
+
if (!sameCodexTree(self.codexHome, d.codexHome, platform)) continue;
|
|
231
|
+
if (!codexCwdInWorkspace(cwd, d.workspace ?? "", platform)) continue;
|
|
232
|
+
group.push(d);
|
|
233
|
+
}
|
|
234
|
+
return electWriters(group, platform).has(self);
|
|
235
|
+
}
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
// The boot replay: what a starting deck reads back out of its log, and which of
|
|
2
|
+
// those events belong on this deck's canvas.
|
|
3
|
+
//
|
|
4
|
+
// These lived in src/server/index.mjs, after pushEvent. They push through
|
|
5
|
+
// event-sink.mjs as every other emitter outside index.mjs does, size their
|
|
6
|
+
// read against the ring's own two bounds (ring-bounds.mjs), and judge scope by
|
|
7
|
+
// the same predicate the Codex watcher runs (log-election.mjs) — nothing here
|
|
8
|
+
// reads the ring. startServer calls replayLog once, with the log openEventLog
|
|
9
|
+
// resolved. The bodies are unchanged.
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { PRODUCT } from "./brand.mjs";
|
|
12
|
+
import { codexCwdInWorkspace } from "./log-election.mjs";
|
|
13
|
+
import { linesFromEnd, linesFromStart } from "./log-tail.mjs";
|
|
14
|
+
import { ENVELOPE_CHARS, MAX_BUFFER, MAX_BUFFER_CHARS, payloadChars } from "./ring-bounds.mjs";
|
|
15
|
+
// event-pipeline.mjs's pushEvent, reached without importing it — see
|
|
16
|
+
// event-sink.mjs.
|
|
17
|
+
import { pushEvent } from "./event-sink.mjs";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Which of the log's events belong on THIS deck's canvas — the boot replay's
|
|
21
|
+
* half of `--workspace`, and the half that did not exist (#696).
|
|
22
|
+
*
|
|
23
|
+
* `--workspace` filtered the two LIVE capture paths and nothing else. The log is
|
|
24
|
+
* the machine-wide `<claude config dir>/agent-dag/events.jsonl` that every deck
|
|
25
|
+
* on the box shares by default, and `replayLog` pushed all of it, so a deck
|
|
26
|
+
* started with `--workspace ~/proj` came up with every session on the machine
|
|
27
|
+
* already drawn — including the ones it had just printed it would not capture,
|
|
28
|
+
* contradicting its own `workspace` row, README.md and the empty-state sentence
|
|
29
|
+
* in src/web/scope.ts that exists precisely so the canvas stops asserting things
|
|
30
|
+
* that are not true (#404).
|
|
31
|
+
*
|
|
32
|
+
* THE RULE IS NOT A NEW ONE. Per event it is `codexCwdInWorkspace`, the same
|
|
33
|
+
* predicate the Codex watcher runs and the twin of `capturesSession` in
|
|
34
|
+
* hook/hook.js — the two are pinned equal by a test walking one table of paths
|
|
35
|
+
* through both, so `--workspace` means one thing on every path a payload can
|
|
36
|
+
* reach the ring by, including this one. Nothing about case folding, separators
|
|
37
|
+
* or the sibling-prefix trap (`/srv/projX` is not inside `/srv/proj`) is decided
|
|
38
|
+
* here; it is decided there, once, per platform.
|
|
39
|
+
*
|
|
40
|
+
* WHAT THE MAP IS FOR. Not every line carries a cwd. The synthetic enrichment
|
|
41
|
+
* events the transcript scanners emit — `ModelObserved`, `UsageObserved`,
|
|
42
|
+
* `SessionNamed`, `ContextObserved` — carry `session_id` and nothing else, and
|
|
43
|
+
* they are persisted like any other event. Judging those by the live rule alone
|
|
44
|
+
* (no cwd, so inside no workspace) would keep an in-scope session on the canvas
|
|
45
|
+
* while stripping its model, its token columns and its name until the next live
|
|
46
|
+
* event arrived. So the answer for a session is learned from the events that DO
|
|
47
|
+
* say where they run and carried forward to the ones that do not.
|
|
48
|
+
*
|
|
49
|
+
* That is a reconstruction of the live behaviour rather than a second notion of
|
|
50
|
+
* scope: live, a deck only ever emits `ModelObserved` for a session whose hook
|
|
51
|
+
* event it already accepted, so "follows its session" is what those events
|
|
52
|
+
* already do — the map only re-derives it from a file. It costs one entry per
|
|
53
|
+
* distinct session id in the log (a few thousand at the very most, against a log
|
|
54
|
+
* measured in tens of megabytes) and one lookup per line.
|
|
55
|
+
*
|
|
56
|
+
* TWO CASES DECIDED EXPLICITLY:
|
|
57
|
+
*
|
|
58
|
+
* * `__clear` — the control marker `/api/clear` pushes, with `cwd: ""`. It is
|
|
59
|
+
* not a session event; it is the instruction that makes the reducer forget
|
|
60
|
+
* everything before it. It has not been written to the log since #698, but
|
|
61
|
+
* logs the decks before that wrote still carry it, and dropping it on a
|
|
62
|
+
* scoped deck would replay the state a user had explicitly cleared, so it
|
|
63
|
+
* is always admitted.
|
|
64
|
+
* * a payload with no cwd and no session the map has seen — refused on a
|
|
65
|
+
* scoped deck, which is exactly what `capturesSession` decides live for a
|
|
66
|
+
* session that never said where it runs.
|
|
67
|
+
*
|
|
68
|
+
* An unscoped deck (`workspace === ""`, the default) admits everything, and
|
|
69
|
+
* takes the cheapest possible path to saying so.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} workspace this deck's canonical workspace; "" for machine-wide
|
|
72
|
+
* @returns {(payload: unknown) => boolean} called once per replayed envelope, in
|
|
73
|
+
* log order — it remembers, so the order matters and it is not reusable across
|
|
74
|
+
* two replays.
|
|
75
|
+
*/
|
|
76
|
+
export function replayScope(workspace, platform = process.platform, providers = null) {
|
|
77
|
+
// WHICH CLIS THIS DECK IS WATCHING, and the half of `--no-codex` that did not
|
|
78
|
+
// exist (#1004). Live capture is gated on the provider — `if (codex)
|
|
79
|
+
// startCodexWatcher(...)`, and the Claude hook is only installed when
|
|
80
|
+
// `deckProviders().claude` — and the boot replay was not, so a deck started
|
|
81
|
+
// with `--no-codex` printed
|
|
82
|
+
//
|
|
83
|
+
// Codex sessions skipped — no ~/.codex/, or --no-codex
|
|
84
|
+
//
|
|
85
|
+
// and then filled its canvas with Codex sessions out of the machine-wide
|
|
86
|
+
// events.jsonl, frozen mid-turn, which no live path could ever close because
|
|
87
|
+
// the watcher that would have is the one that was skipped. Measured on a log
|
|
88
|
+
// of two Claude lines and two Codex lines replayed with `codex: false`:
|
|
89
|
+
//
|
|
90
|
+
// in the ring: [ 'claude:claude-1:SessionStart', 'claude:claude-1:PreToolUse',
|
|
91
|
+
// 'codex:codex-1:SessionStart', 'codex:codex-1:PreToolUse' ]
|
|
92
|
+
//
|
|
93
|
+
// The mirror case is worse: a `--no-claude` deck replays Claude sessions while
|
|
94
|
+
// `providers.claude` is false, so App.tsx hides the accounts panel and the
|
|
95
|
+
// sound controls — sessions on screen the deck has deliberately taken the
|
|
96
|
+
// controls for away.
|
|
97
|
+
//
|
|
98
|
+
// It costs one comparison per line. `provider: "codex"` is stamped on every
|
|
99
|
+
// payload the Codex watcher emits, its cwd-less enrichment included, and the
|
|
100
|
+
// Claude side is the complement — which is what types.ts already says
|
|
101
|
+
// `provider` means ("defaults to claude for replay events written before
|
|
102
|
+
// multi-provider support"). So this needs no per-session memory of its own and
|
|
103
|
+
// leaves `orderDependent` alone.
|
|
104
|
+
const claudeOn = providers?.claude !== false;
|
|
105
|
+
const codexOn = providers?.codex !== false;
|
|
106
|
+
const scoped = !!workspace && typeof workspace === "string";
|
|
107
|
+
if (!scoped && claudeOn && codexOn) {
|
|
108
|
+
const all = () => true;
|
|
109
|
+
// Every caller gets the same answer for the same payload, forever. That is
|
|
110
|
+
// what lets replayLog read the log from its end — see `orderDependent` on
|
|
111
|
+
// the scoped predicate below for the half that cannot.
|
|
112
|
+
all.orderDependent = false;
|
|
113
|
+
return all;
|
|
114
|
+
}
|
|
115
|
+
const bySession = new Map();
|
|
116
|
+
/**
|
|
117
|
+
* THE ANSWER DEPENDS ON WHAT CAME BEFORE, and #742 is why that is now stated
|
|
118
|
+
* out loud rather than left as an implementation detail.
|
|
119
|
+
*
|
|
120
|
+
* The synthetic enrichment events — ModelObserved, UsageObserved,
|
|
121
|
+
* SessionNamed, ContextObserved — carry a session_id and no cwd, so the only
|
|
122
|
+
* thing that can decide them is a cwd-bearing event for the same session,
|
|
123
|
+
* and that event is EARLIER in the log. Fed the log backwards, this predicate
|
|
124
|
+
* meets the enrichment first, has nothing in `bySession`, and drops it: the
|
|
125
|
+
* session lands on the canvas with no model and no tokens.
|
|
126
|
+
*
|
|
127
|
+
* So the flag is not advice. replayLog reads it, and reads the file forwards
|
|
128
|
+
* whenever it is set. Making a scoped replay cheap needs an index of where a
|
|
129
|
+
* workspace's lines are, which is a different change from this one.
|
|
130
|
+
*/
|
|
131
|
+
admits.orderDependent = scoped;
|
|
132
|
+
return admits;
|
|
133
|
+
|
|
134
|
+
function admits(payload) {
|
|
135
|
+
if (!payload || typeof payload !== "object") return false;
|
|
136
|
+
if (payload.hook_event_name === "__clear") return true;
|
|
137
|
+
// The provider gate, ahead of the workspace one and exempting `__clear` for
|
|
138
|
+
// the same reason it does: the marker carries no provider, and a deck that
|
|
139
|
+
// dropped it would replay a canvas the user had explicitly cleared.
|
|
140
|
+
if (!(payload.provider === "codex" ? codexOn : claudeOn)) return false;
|
|
141
|
+
if (!scoped) return true;
|
|
142
|
+
const sid = typeof payload.session_id === "string" ? payload.session_id : null;
|
|
143
|
+
const cwd = typeof payload.cwd === "string" && payload.cwd !== "" ? payload.cwd : null;
|
|
144
|
+
if (cwd) {
|
|
145
|
+
const inside = codexCwdInWorkspace(cwd, workspace, platform);
|
|
146
|
+
if (sid) bySession.set(sid, inside);
|
|
147
|
+
return inside;
|
|
148
|
+
}
|
|
149
|
+
if (sid && bySession.has(sid)) return bySession.get(sid);
|
|
150
|
+
return false;
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Can this log fill the ring on its own?
|
|
156
|
+
*
|
|
157
|
+
* Read from the END and bounded by the ring, so the answer costs at most one
|
|
158
|
+
* ring's worth of parsing however large the file is — and on a full log it
|
|
159
|
+
* stops within the first few hundred lines. Deliberately UNSCOPED: the number
|
|
160
|
+
* it returns is an upper bound on what any workspace predicate will admit, so
|
|
161
|
+
* `false` is a certainty that the archive is needed while `true` is only the
|
|
162
|
+
* absence of evidence that it is. See the order-dependent branch of replayLog
|
|
163
|
+
* for why that asymmetry is the right way round.
|
|
164
|
+
*/
|
|
165
|
+
async function fillsRing(filePath, maxEvents, maxChars) {
|
|
166
|
+
let n = 0;
|
|
167
|
+
let chars = 0;
|
|
168
|
+
for await (const line of linesFromEnd(filePath)) {
|
|
169
|
+
if (!line) continue;
|
|
170
|
+
let evt;
|
|
171
|
+
try { evt = JSON.parse(line); } catch { continue; }
|
|
172
|
+
if (!evt || typeof evt !== "object" || !evt.payload) continue;
|
|
173
|
+
n++;
|
|
174
|
+
chars += ENVELOPE_CHARS + payloadChars(evt.payload);
|
|
175
|
+
if (n >= maxEvents || chars >= maxChars) return true;
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Read the log back into the ring buffer at boot.
|
|
182
|
+
*
|
|
183
|
+
* A line that will not parse is skipped rather than thrown on, and that is
|
|
184
|
+
* deliberate — it is what lets a log damaged by the pre-#446 writer still
|
|
185
|
+
* replay. Every line before the damage and every line after it is whole, so
|
|
186
|
+
* stopping at the first bad one would throw away the rest of the session for a
|
|
187
|
+
* fault that costs one event. Newline framing is what makes the recovery
|
|
188
|
+
* possible: a torn write leaves the reader resynchronised at the very next
|
|
189
|
+
* `\n`, with no length prefix to have been lost along with the bytes.
|
|
190
|
+
*
|
|
191
|
+
* What changed is that the skip is no longer silent. Before, a deck whose log
|
|
192
|
+
* had been shredded replayed "mostly" and said nothing anywhere: the largest
|
|
193
|
+
* tool responses and whatever ordinary event was spliced into them were gone,
|
|
194
|
+
* with no counter and no line on the terminal. The count below is the only
|
|
195
|
+
* signal that a log carries damage from a writer that has since been fixed, and
|
|
196
|
+
* the byte total is what tells the user whether it was one truncated tail from
|
|
197
|
+
* a kill -9 or a megabyte of shredded tool output.
|
|
198
|
+
*
|
|
199
|
+
* Deliberately one line, and deliberately without the path in it: this prints
|
|
200
|
+
* onto a terminal the deck is about to paint over (see oneLine in term.mjs for
|
|
201
|
+
* what a multi-line message does there), and the path was already printed at
|
|
202
|
+
* boot by the caller.
|
|
203
|
+
*
|
|
204
|
+
* `workspace` is what makes the replay agree with the two live capture paths —
|
|
205
|
+
* see replayScope. An out-of-scope line is NOT counted into `skipped` and is not
|
|
206
|
+
* warned about: `skipped` means "this log has bytes in it no reader can parse",
|
|
207
|
+
* which is damage and is worth a line on the terminal, while a scoped deck
|
|
208
|
+
* declining a session it was told not to capture is the flag doing its job. They
|
|
209
|
+
* are two different things and only the first is ever printed.
|
|
210
|
+
*
|
|
211
|
+
* WHAT THE FILTER COSTS AT BOOT, honestly: nothing is saved on the read of any
|
|
212
|
+
* line this reaches, because the cwd being judged is inside the JSON. What it
|
|
213
|
+
* saves is everything after the parse: no redaction pass, no envelope, no ring
|
|
214
|
+
* insert, no character accounting and no eviction pressure for a line this deck
|
|
215
|
+
* should never have held. The ring is bounded by MAX_BUFFER events AND
|
|
216
|
+
* MAX_BUFFER_CHARS, so on a busy machine the out-of-scope traffic was not
|
|
217
|
+
* merely extra — it was evicting the in-scope sessions the user started the
|
|
218
|
+
* deck to watch.
|
|
219
|
+
*
|
|
220
|
+
* READ BACKWARDS, AND ONLY AS FAR AS THE RING (#742). This used to stream the
|
|
221
|
+
* whole file from the front, and the whole file is where the boot's time went:
|
|
222
|
+
* 12,079 lines and 31 MB on the machine it was measured on, 690ms of
|
|
223
|
+
* JSON.parse, growing with every session until rotation cuts it at 50 MB — to
|
|
224
|
+
* fill a ring that holds two thousand events. Five sixths of that parse was
|
|
225
|
+
* feeding the eviction loop, on the critical path of a boot, every time.
|
|
226
|
+
*
|
|
227
|
+
* So the lines arrive newest-first and the loop stops the moment the ring is
|
|
228
|
+
* full, which makes the cost a property of MAX_BUFFER rather than of how long
|
|
229
|
+
* the user has been running the deck. Nothing is lost by it: what a forward
|
|
230
|
+
* replay left in the ring was always the NEWEST admitted events that fit, and
|
|
231
|
+
* that is exactly the set this collects. A young log — too few events to fill
|
|
232
|
+
* the ring — is read to its start, and costs what it always did.
|
|
233
|
+
*
|
|
234
|
+
* The order of the pushes is still oldest-first. `seq` is assigned by pushEvent
|
|
235
|
+
* in the order it is called, and a ring numbered backwards would hand every
|
|
236
|
+
* resuming client a Last-Event-ID that means the opposite of what it says.
|
|
237
|
+
*
|
|
238
|
+
* A SCOPED DECK STILL READS FORWARDS, and that is not an oversight. Its
|
|
239
|
+
* predicate decides the cwd-less enrichment events — ModelObserved,
|
|
240
|
+
* UsageObserved, SessionNamed, ContextObserved — from the cwd-bearing event
|
|
241
|
+
* earlier in the log, so backwards it meets the answer after the question and
|
|
242
|
+
* drops them: the session arrives on the canvas with no model and no tokens.
|
|
243
|
+
* `replayScope` says which kind of predicate it handed over rather than this
|
|
244
|
+
* inferring it from the workspace string, so the two cannot drift apart. Making
|
|
245
|
+
* that case cheap needs an index of where a workspace's lines are, which is a
|
|
246
|
+
* different change from this one.
|
|
247
|
+
*/
|
|
248
|
+
/* Exported for the suite, with both ceilings as parameters. The byte budget is
|
|
249
|
+
* 128 MiB, so a test that wanted to reach it honestly would have to write 128
|
|
250
|
+
* MiB — which is why the count bound was the only one anything pinned, and why
|
|
251
|
+
* the byte bound was the one that broke. Production passes neither argument. */
|
|
252
|
+
export async function replayLog(filePath, workspace = "", {
|
|
253
|
+
maxEvents = MAX_BUFFER, maxChars = MAX_BUFFER_CHARS, providers = null,
|
|
254
|
+
} = {}) {
|
|
255
|
+
// THE ARCHIVE IS PART OF THE HISTORY, and for two years nothing read it.
|
|
256
|
+
// `maybeRotatePersistFile` renames events.jsonl to events.jsonl.1 at 50 MB;
|
|
257
|
+
// a grep for `.1` across src/ and bin/ finds the write, the copy deck-home's
|
|
258
|
+
// migration makes, and two comments. replayLog is called once, with
|
|
259
|
+
// `persistPath` alone. So the boot immediately after a rotation replays a
|
|
260
|
+
// file holding a handful of lines while 50 MB of history sits beside it
|
|
261
|
+
// unread — and the next rotation's `unlink` deletes it outright. Measured:
|
|
262
|
+
// one rotation, then `replayLog(events.jsonl)` returning 1.
|
|
263
|
+
//
|
|
264
|
+
// The reader below already stops the moment EITHER of the ring's bounds is
|
|
265
|
+
// reached, so on a log that can fill the ring on its own this costs nothing
|
|
266
|
+
// at all: the archive is never opened. It is read only when the live log
|
|
267
|
+
// cannot fill the ring, which is exactly the window a rotation opens.
|
|
268
|
+
//
|
|
269
|
+
// A Clear opens the same window and means the opposite by it. It empties the
|
|
270
|
+
// live log, which can then fill nothing, so reading on into the archive put
|
|
271
|
+
// back precisely what the user had cleared (#1130) — which is why a Clear
|
|
272
|
+
// removes the archive as well as emptying the live log, rather than this
|
|
273
|
+
// learning to tell the two kinds of empty apart. See handleClear.
|
|
274
|
+
const archivePath = filePath + ".1";
|
|
275
|
+
const liveThere = existsSync(filePath);
|
|
276
|
+
const archiveThere = existsSync(archivePath);
|
|
277
|
+
if (!liveThere && !archiveThere) return 0;
|
|
278
|
+
let skipped = 0;
|
|
279
|
+
let skippedBytes = 0;
|
|
280
|
+
// `providers` is the second scope this deck has, and it goes the same way the
|
|
281
|
+
// first one does — into the predicate, so the replay rule stays pinned equal
|
|
282
|
+
// to the live rule rather than being a second notion of it. See replayScope.
|
|
283
|
+
const admits = replayScope(workspace, process.platform, providers);
|
|
284
|
+
const replay = (evt) =>
|
|
285
|
+
pushEvent(evt.payload, evt.source ?? "replay", { receivedAt: evt.receivedAt, replay: true });
|
|
286
|
+
const parse = (line) => {
|
|
287
|
+
try {
|
|
288
|
+
return JSON.parse(line);
|
|
289
|
+
} catch {
|
|
290
|
+
skipped++;
|
|
291
|
+
skippedBytes += Buffer.byteLength(line, "utf8");
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
const usable = (evt) => evt && typeof evt === "object" && evt.payload;
|
|
296
|
+
|
|
297
|
+
let count = 0;
|
|
298
|
+
if (admits.orderDependent) {
|
|
299
|
+
// Oldest first, so the archive comes BEFORE the live log — `admits` is
|
|
300
|
+
// stateful and answers from what it has already seen, which is the whole
|
|
301
|
+
// reason this branch reads forwards at all.
|
|
302
|
+
//
|
|
303
|
+
// Whether the archive is worth reading is decided by a probe rather than by
|
|
304
|
+
// this branch's own reader, because this branch has no stopping rule: it
|
|
305
|
+
// pushes everything and lets the ring evict. The probe reads the LIVE log
|
|
306
|
+
// backwards, unscoped, and is bounded by the ring, so it costs at most one
|
|
307
|
+
// ring's worth of parsing from the end of the file and nothing more. An
|
|
308
|
+
// unscoped count is an upper bound on what scoping will admit, so "this
|
|
309
|
+
// cannot fill the ring" is certain when the probe says so — and when it
|
|
310
|
+
// says the opposite a scoped deck may still under-fill, which is precisely
|
|
311
|
+
// what it does today. Nothing regresses; the case a rotation creates, where
|
|
312
|
+
// the live log holds a handful of lines, is the one that is fixed.
|
|
313
|
+
const files = [];
|
|
314
|
+
if (archiveThere && !(liveThere && await fillsRing(filePath, maxEvents, maxChars))) files.push(archivePath);
|
|
315
|
+
if (liveThere) files.push(filePath);
|
|
316
|
+
for (const file of files) {
|
|
317
|
+
for await (const line of linesFromStart(file)) {
|
|
318
|
+
if (!line) continue;
|
|
319
|
+
const evt = parse(line);
|
|
320
|
+
if (!usable(evt) || !admits(evt.payload)) continue;
|
|
321
|
+
replay(evt);
|
|
322
|
+
count++;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
} else {
|
|
326
|
+
// Newest first, so this is filled back to front and then walked in reverse
|
|
327
|
+
// to push. Bounded by BOTH of the ring's limits, which is what makes the
|
|
328
|
+
// memory here a property of the ring rather than of the file.
|
|
329
|
+
//
|
|
330
|
+
// The count alone was not enough, and the comment that used to say it was
|
|
331
|
+
// predates #625. Eviction is the only thing that applies MAX_BUFFER_CHARS,
|
|
332
|
+
// and eviction happens inside pushEvent — which does not run until this
|
|
333
|
+
// array is already full. Measured on a 187 MB log of 40 events of 4.9M
|
|
334
|
+
// characters each, every one of them under the ingest cap: RSS went from
|
|
335
|
+
// 215 MB to a peak of 505 MB, and the ring that survived held 27 events and
|
|
336
|
+
// 126 MiB. About 290 MB staged for a ring capped at 128.
|
|
337
|
+
//
|
|
338
|
+
// Rotation at 50 MB normally keeps logs well under this, but rotation is
|
|
339
|
+
// best-effort and its failure is only logged, and the rotation's own note
|
|
340
|
+
// in event-log.mjs records logs reaching gigabytes.
|
|
341
|
+
const newestFirst = [];
|
|
342
|
+
let stagedChars = 0;
|
|
343
|
+
// Newest generation first. The second pass runs only if the first stopped
|
|
344
|
+
// because it ran out of FILE rather than because it reached a bound, which
|
|
345
|
+
// is the "cannot fill the ring" test stated exactly and for free — and it
|
|
346
|
+
// is also why a full live log never opens the archive at all.
|
|
347
|
+
for (const file of liveThere ? [filePath, archivePath] : [archivePath]) {
|
|
348
|
+
if (!existsSync(file)) continue;
|
|
349
|
+
if (newestFirst.length >= maxEvents || stagedChars >= maxChars) break;
|
|
350
|
+
for await (const line of linesFromEnd(file)) {
|
|
351
|
+
if (!line) continue;
|
|
352
|
+
const evt = parse(line);
|
|
353
|
+
if (!usable(evt) || !admits(evt.payload)) continue;
|
|
354
|
+
newestFirst.push(evt);
|
|
355
|
+
stagedChars += ENVELOPE_CHARS + payloadChars(evt.payload);
|
|
356
|
+
// Everything older than this would be evicted by the events already held,
|
|
357
|
+
// so reading further is work whose only result is throwing it away.
|
|
358
|
+
// Either limit reaching its ceiling means exactly that.
|
|
359
|
+
if (newestFirst.length >= maxEvents || stagedChars >= maxChars) break;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
for (let i = newestFirst.length - 1; i >= 0; i--) replay(newestFirst[i]);
|
|
363
|
+
count = newestFirst.length;
|
|
364
|
+
}
|
|
365
|
+
if (skipped > 0) {
|
|
366
|
+
// "in the part of the log it read", because that is now a part rather than
|
|
367
|
+
// the whole: a damaged line older than the ring is never reached, and
|
|
368
|
+
// claiming to have counted every unreadable line in the file would be a
|
|
369
|
+
// number this no longer has.
|
|
370
|
+
const kb = (skippedBytes / 1024).toFixed(0);
|
|
371
|
+
console.warn(`${PRODUCT}: skipped ${skipped} unreadable line(s) (${kb}KB) while replaying the event log`);
|
|
372
|
+
}
|
|
373
|
+
return count;
|
|
374
|
+
}
|