ccdeck 3.29.11 → 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.
- package/README.md +1 -1
- 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-DP3YMtEi.js +1 -0
- package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
- package/dist/web/assets/index-3vS6A0vt.css +1 -0
- package/dist/web/assets/index-BVTWHDta.js +298 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +22 -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 +2 -2
- 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 +1 -1
- package/src/server/deck-prefs.mjs +51 -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 +172 -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 +108 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +77 -0
- package/src/server/prefs-routes.mjs +76 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +51 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -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 +93 -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,298 @@
|
|
|
1
|
+
// This deck's entry in the discovery dir: the record every hook reads to find a
|
|
2
|
+
// deck to post to, written so no reader lands inside it, and kept on disk for as
|
|
3
|
+
// long as the deck runs.
|
|
4
|
+
//
|
|
5
|
+
// These lived in src/server/installer.mjs, after the hook installer, and share
|
|
6
|
+
// nothing with it but the directory and the atomic write. The hooks are
|
|
7
|
+
// installed once per boot; the record is written at boot, re-asserted every five
|
|
8
|
+
// seconds and removed at shutdown, by bin/deck.js. So they moved to a module of
|
|
9
|
+
// their own, and the directory's definition came with them. installer.mjs
|
|
10
|
+
// imports the directory and re-exports every name, so bin/deck.js and the CLI
|
|
11
|
+
// still import them from there. The code is unchanged.
|
|
12
|
+
import { readFile, mkdir, unlink, chmod, utimes } from "node:fs/promises";
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
16
|
+
// Under the name installer.mjs gives it, which is the one the bodies below use.
|
|
17
|
+
import { CODEX_HOME as CODEX_DIR } from "./codex-dir.mjs";
|
|
18
|
+
import { canonicalLogPath } from "./log-election.mjs";
|
|
19
|
+
import { stripBom, writeFileAtomic } from "./atomic-write.mjs";
|
|
20
|
+
|
|
21
|
+
// Resolved through claude-dir.mjs, the module that owns the rule, exactly as
|
|
22
|
+
// installer.mjs resolves it: the record has to land where the hooks it is for
|
|
23
|
+
// look, and they follow CLAUDE_CONFIG_DIR.
|
|
24
|
+
const CLAUDE_DIR = claudeConfigDir();
|
|
25
|
+
|
|
26
|
+
// Single shared discovery dir — both providers' hook scripts post here so one
|
|
27
|
+
// running agent-dag server can match either ecosystem's events. It follows the
|
|
28
|
+
// Claude config dir, so hook/hook.js has to resolve that dir the same way: it
|
|
29
|
+
// reads what this writes, and a disagreement means the hooks find no server.
|
|
30
|
+
const AGENT_DAG_DIR = join(CLAUDE_DIR, "agent-dag");
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The events log as the record spells it: an absolute path, or null when this
|
|
34
|
+
* deck writes none. Shared by the writer and by ensureDiscovery's comparison,
|
|
35
|
+
* so a file this process wrote can never read back as somebody else's.
|
|
36
|
+
*/
|
|
37
|
+
function persistField(persist) {
|
|
38
|
+
return typeof persist === "string" && persist !== "" ? persist : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The Codex tree as the record spells it: the canonical path of the CODEX_HOME
|
|
43
|
+
* this process reads, or null for a --no-codex deck, which reads none. Shared by
|
|
44
|
+
* the writer and by ensureDiscovery's comparison for the reason persistField
|
|
45
|
+
* is, and exported for a start's own shape in bin/deck.js — so a start asking
|
|
46
|
+
* "does the running deck read my tree" spells the tree exactly the way the
|
|
47
|
+
* record it compares against does. Worked out on every call rather than once at
|
|
48
|
+
* load: it is one realpath on a five-second heartbeat, and a tree that did not
|
|
49
|
+
* exist when the deck started — the first `codex login` creates it — is then
|
|
50
|
+
* named the way it is named once it does.
|
|
51
|
+
*/
|
|
52
|
+
export function codexHomeField(codex) {
|
|
53
|
+
return codex !== false ? canonicalLogPath(CODEX_DIR) : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Every deck's token lives in this directory, and writeFileAtomic's temp file is
|
|
57
|
+
// created beside its target with whatever the umask allows — 0644 on most
|
|
58
|
+
// machines — so for the moment before the rename the token would sit in a
|
|
59
|
+
// world-readable file. 0700 on the directory closes that window from the outside:
|
|
60
|
+
// another user cannot traverse into it whatever the mode of a file inside says.
|
|
61
|
+
// The dir usually predates this code, so the mode is re-asserted rather than only
|
|
62
|
+
// set at creation, where it would be masked by the umask anyway. Windows ignores
|
|
63
|
+
// both — NTFS ACLs inherit from the per-user profile directory.
|
|
64
|
+
async function ensureDiscoveryDir() {
|
|
65
|
+
if (!existsSync(AGENT_DAG_DIR)) await mkdir(AGENT_DAG_DIR, { recursive: true, mode: 0o700 });
|
|
66
|
+
await chmod(AGENT_DAG_DIR, 0o700).catch(() => {});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Register this deck, in one step no reader can land inside.
|
|
71
|
+
*
|
|
72
|
+
* The record used to go down with a plain writeFile, which truncates the target
|
|
73
|
+
* and then fills it, so the file existed and was empty for a moment on every
|
|
74
|
+
* rewrite. Everything that reads this directory parses each record whole —
|
|
75
|
+
* electWriters in hook/hook.js, readLiveDecks in live-decks.mjs and
|
|
76
|
+
* sweepStaleDiscovery in index.mjs — and a record that fails to parse is a deck
|
|
77
|
+
* missing from that cycle: the event it should have logged is either logged by
|
|
78
|
+
* nobody or logged twice by the decks that remain, which is the exact failure
|
|
79
|
+
* the single-writer election exists to prevent, reached through the file the
|
|
80
|
+
* election reads. A rename is atomic on Linux, macOS and Windows alike, so a
|
|
81
|
+
* reader now sees the previous record or the new one and never half of either.
|
|
82
|
+
*
|
|
83
|
+
* The token is the deck's proof of identity, so the file holding it is the
|
|
84
|
+
* deck's key material: readable and writable by its owner, nobody else. The mode
|
|
85
|
+
* is pinned after the write because writeFileAtomic can only carry over a mode
|
|
86
|
+
* the target already had — a first registration, or one left by an earlier run
|
|
87
|
+
* under a recycled pid, would otherwise keep whatever the umask handed it.
|
|
88
|
+
*/
|
|
89
|
+
export async function writeDiscovery({ port, workspace, token, persist = null, codex = true, claude = true, version = "", parent = null }) {
|
|
90
|
+
await ensureDiscoveryDir();
|
|
91
|
+
const file = discoveryPath();
|
|
92
|
+
const data = {
|
|
93
|
+
pid: process.pid,
|
|
94
|
+
port,
|
|
95
|
+
workspace: workspace ?? "",
|
|
96
|
+
// Without this the hooks refuse to post: a file naming a port it cannot
|
|
97
|
+
// authenticate is exactly the stale-file case they now decline to trust.
|
|
98
|
+
token: token ?? "",
|
|
99
|
+
// Absolute path of the events log this deck appends to, or null under
|
|
100
|
+
// --no-persist. The hook reads it to elect a single writer per file:
|
|
101
|
+
// several decks receive the same event by design, and without this they
|
|
102
|
+
// each appended their own copy to the one log they share. See
|
|
103
|
+
// electWriters in hook/hook.js.
|
|
104
|
+
persist: persistField(persist),
|
|
105
|
+
// Is this deck tailing Codex's rollout files? Those events never pass
|
|
106
|
+
// through a hook, so the decks elect a writer for them among themselves —
|
|
107
|
+
// and a deck running --no-codex must be left out of that election rather
|
|
108
|
+
// than win it and record a rollout it is not even reading. See
|
|
109
|
+
// writesCodexLog in src/server/log-election.mjs.
|
|
110
|
+
codex: codex !== false,
|
|
111
|
+
// And WHOSE rollouts. `codex: true` says this deck tails them and never said
|
|
112
|
+
// from where, while CODEX_HOME moves the whole tree — #375 was five modules
|
|
113
|
+
// disagreeing about where it points. Two decks sharing one events.jsonl can
|
|
114
|
+
// therefore be reading two different trees, and the election grouped them
|
|
115
|
+
// anyway, because an unscoped deck's workspace contains every cwd and this
|
|
116
|
+
// was the one field that could have told them apart. The lower-port deck won
|
|
117
|
+
// a rollout it had never opened, the deck reading it stood down, and the
|
|
118
|
+
// shared log recorded nothing of that session (#982).
|
|
119
|
+
//
|
|
120
|
+
// Canonical rather than as spelled, for the reason canonicalLogPath gives
|
|
121
|
+
// the log path above (#793): a symlinked ~/.codex, an 8.3-shortened
|
|
122
|
+
// USERPROFILE or /tmp against /private/tmp would otherwise put two decks
|
|
123
|
+
// reading one tree into two groups and elect them both — the duplicate the
|
|
124
|
+
// election exists to end. Only this published copy is canonicalised.
|
|
125
|
+
// codex-dir.mjs goes on reading and writing through the spelling the user
|
|
126
|
+
// chose, which it has to for a symlinked home (see codexHome() there).
|
|
127
|
+
codexHome: codexHomeField(codex),
|
|
128
|
+
// Does this deck run Browser Watch? The watch elects a single writer among
|
|
129
|
+
// the decks on a machine, and it elected on port alone — so an older ccdeck
|
|
130
|
+
// that predates the feature won the election by having the lower port and
|
|
131
|
+
// then wrote nothing, while the deck that HAS the watch stood down. Measured
|
|
132
|
+
// on this machine: a v1.46 deck from an npx cache held 4317, answered the
|
|
133
|
+
// watch route with the SPA's index.html, and Browser Watch silently
|
|
134
|
+
// recorded nothing for as long as both were up. No error, no log line — the
|
|
135
|
+
// panel showed findings on screen and the disk stayed empty.
|
|
136
|
+
//
|
|
137
|
+
// Same shape as `codex` above, and for the same reason: a deck that is not
|
|
138
|
+
// doing the work must be left out of the election rather than win it. An
|
|
139
|
+
// older deck has no such field, so it is excluded by construction.
|
|
140
|
+
watch: true,
|
|
141
|
+
// Is the Claude side of this deck switched on? Same shape as `codex` above
|
|
142
|
+
// and there for a second reader: a bare `ccdeck` that finds this deck
|
|
143
|
+
// running attaches to it instead of starting a rival, and it may only do
|
|
144
|
+
// that when the deck on the port is the deck it would itself have built. A
|
|
145
|
+
// `--no-claude` deck has no hooks, no accounts panel and no switcher, and
|
|
146
|
+
// until this field existed it was indistinguishable from one that has all
|
|
147
|
+
// three. An older deck has no such field, so it never passes for one and is
|
|
148
|
+
// replaced instead — see secondStart.
|
|
149
|
+
claude: claude !== false,
|
|
150
|
+
// What this deck IS, so a launcher that attaches can say whether the deck
|
|
151
|
+
// it found is the version the user just asked for. Never a decision: a
|
|
152
|
+
// rival deck on a random port is worse than an older deck said out loud.
|
|
153
|
+
version: typeof version === "string" ? version : "",
|
|
154
|
+
// The supervisor above this worker, or null when nothing is supervising.
|
|
155
|
+
//
|
|
156
|
+
// Only `ccdeck --stop` reads it, and only on the path where the polite
|
|
157
|
+
// request failed. A worker killed on its own leaves its supervisor alive,
|
|
158
|
+
// and a supervisor that puts crashed workers back would answer that kill by
|
|
159
|
+
// starting the deck again — so the ladder has to end the parent first and
|
|
160
|
+
// the child second, which it cannot do without being told who the parent is.
|
|
161
|
+
parent: Number.isInteger(parent) ? parent : null,
|
|
162
|
+
startedAt: new Date().toISOString(),
|
|
163
|
+
};
|
|
164
|
+
await writeFileAtomic(file, JSON.stringify(data, null, 2) + "\n");
|
|
165
|
+
await chmod(file, 0o600).catch(() => {});
|
|
166
|
+
return file;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function removeDiscovery(file) {
|
|
170
|
+
try { await unlink(file); } catch {}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Where this process registers itself. One file per deck, named by pid. */
|
|
174
|
+
export function discoveryPath() {
|
|
175
|
+
return join(AGENT_DAG_DIR, `${process.pid}.json`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Make sure this deck's discovery file is on disk and says what it should.
|
|
180
|
+
*
|
|
181
|
+
* Registration was a single write at boot, so anything that took the file away
|
|
182
|
+
* afterwards — a sweep on another machine's clock, a half-finished restart, a
|
|
183
|
+
* user tidying the directory — left a deck that was listening, serving and
|
|
184
|
+
* completely invisible: hook.js enumerates this directory and nothing else, so
|
|
185
|
+
* the deck received zero events while looking perfectly healthy. Re-asserting
|
|
186
|
+
* is cheap (one small read), so the deck checks rather than assumes.
|
|
187
|
+
*
|
|
188
|
+
* A file this process wrote is left alone, mode and contents included — only its
|
|
189
|
+
* mtime moves, stamped on every check; see the note where it is. Anything else —
|
|
190
|
+
* no file, unreadable, another pid, a stale port, token, events log, Codex
|
|
191
|
+
* setting or Codex tree, Claude setting, or a version left by the deck this
|
|
192
|
+
* process replaced — is replaced. Every field another deck decides by is compared, the
|
|
193
|
+
* log path included: leave one out and a record missing it would pass as ours
|
|
194
|
+
* forever, which for the log path means no deck can tell which of them share a
|
|
195
|
+
* file and they all write their own copy of every event again. The Codex tree
|
|
196
|
+
* is compared strictly for the same reason: a record from before the field
|
|
197
|
+
* existed has none, and `undefined` must read as "rewrite", never as a
|
|
198
|
+
* --no-codex deck's null.
|
|
199
|
+
*/
|
|
200
|
+
export async function ensureDiscovery({ port, workspace, token, persist = null, codex = true, claude = true, version = "", parent = null }) {
|
|
201
|
+
const file = discoveryPath();
|
|
202
|
+
try {
|
|
203
|
+
const d = JSON.parse(stripBom(await readFile(file, "utf8")));
|
|
204
|
+
if (d
|
|
205
|
+
&& d.pid === process.pid
|
|
206
|
+
&& d.port === port
|
|
207
|
+
&& (d.workspace ?? "") === (workspace ?? "")
|
|
208
|
+
&& (d.token ?? "") === (token ?? "")
|
|
209
|
+
&& (d.persist ?? null) === persistField(persist)
|
|
210
|
+
&& d.codex === (codex !== false)
|
|
211
|
+
&& d.codexHome === codexHomeField(codex)
|
|
212
|
+
&& d.claude === (claude !== false)
|
|
213
|
+
&& (d.version ?? "") === (typeof version === "string" ? version : "")
|
|
214
|
+
&& (d.parent ?? null) === (Number.isInteger(parent) ? parent : null)) {
|
|
215
|
+
// STAMPED, though nothing in it changed. The mtime is how a reader tells a
|
|
216
|
+
// record some deck is still keeping from one whose deck is gone while its
|
|
217
|
+
// pid lives on under another process: hook.js unlinks a record whose port
|
|
218
|
+
// lets both challenge deadlines pass only once this stamp is a minute old
|
|
219
|
+
// (#1069), which a deck running this check every five seconds never lets
|
|
220
|
+
// happen. Best-effort — a stamp that cannot be written leaves the record
|
|
221
|
+
// exactly as every deck before this one left it.
|
|
222
|
+
const now = new Date();
|
|
223
|
+
await utimes(file, now, now).catch(() => {});
|
|
224
|
+
return { file, rewritten: false };
|
|
225
|
+
}
|
|
226
|
+
} catch { /* missing, unreadable or corrupt — rewritten below */ }
|
|
227
|
+
await writeDiscovery({ port, workspace, token, persist, codex, claude, version, parent });
|
|
228
|
+
return { file, rewritten: true };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Keep this deck registered for as long as it runs, and tell the caller when
|
|
233
|
+
* that stops being true.
|
|
234
|
+
*
|
|
235
|
+
* `onState` hears the first outcome, every change of health, and every
|
|
236
|
+
* re-registration after the first — never a steady state. A deck that cannot
|
|
237
|
+
* write the file has to say so: silently listening while no hook can find it
|
|
238
|
+
* is the failure this exists to end, not a state worth hiding.
|
|
239
|
+
*
|
|
240
|
+
* The interval is unref'd, so it never keeps a finished process alive, and
|
|
241
|
+
* `stop()` must be called before the file is removed on shutdown — otherwise
|
|
242
|
+
* the next tick would put it straight back.
|
|
243
|
+
*
|
|
244
|
+
* `stop()` ANSWERS WITH THE CHECK ALREADY IN FLIGHT, and shutdown has to await
|
|
245
|
+
* it. Clearing the interval stops the next tick; it does nothing about the one
|
|
246
|
+
* that started a moment ago and is currently inside writeFileAtomic. That tick
|
|
247
|
+
* finishes after the unlink and re-creates the file — exactly the "leave the
|
|
248
|
+
* file behind for the hooks to find once nothing is listening" that stopping
|
|
249
|
+
* first is supposed to prevent, reached by the one route stopping first does
|
|
250
|
+
* not cover. The window is a rename and an fsync on POSIX; on Windows it is
|
|
251
|
+
* that plus renameWithRetry's ladder, up to 200ms of sleeping while a scanner
|
|
252
|
+
* holds the target — the platform the retry was written for is the platform
|
|
253
|
+
* where the race is twenty times wider.
|
|
254
|
+
*
|
|
255
|
+
* `run()` never rejects (every failure is a state), so awaiting this cannot
|
|
256
|
+
* throw and cannot outlast one bounded check.
|
|
257
|
+
*/
|
|
258
|
+
export function keepDiscovery({ port, workspace, token, persist = null, codex = true, claude = true, version = "", parent = null, intervalMs = 5000, onState = null } = {}) {
|
|
259
|
+
// null until the first outcome, which therefore always differs and is always
|
|
260
|
+
// reported — the caller learns where it stands before anything else happens.
|
|
261
|
+
let healthy = null;
|
|
262
|
+
|
|
263
|
+
const run = async () => {
|
|
264
|
+
let state;
|
|
265
|
+
try {
|
|
266
|
+
const { rewritten } = await ensureDiscovery({ port, workspace, token, persist, codex, claude, version, parent });
|
|
267
|
+
state = { ok: true, rewritten, file: discoveryPath(), error: null };
|
|
268
|
+
} catch (err) {
|
|
269
|
+
state = { ok: false, rewritten: false, file: discoveryPath(), error: err };
|
|
270
|
+
}
|
|
271
|
+
const worthSaying = healthy !== state.ok || state.rewritten;
|
|
272
|
+
healthy = state.ok;
|
|
273
|
+
if (worthSaying && onState) { try { onState(state); } catch { /* not our problem */ } }
|
|
274
|
+
return state;
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// One check at a time. Registration is a write and a write is now a rename,
|
|
278
|
+
// which costs an fsync — long enough that a tick can land inside the boot-time
|
|
279
|
+
// check bin/deck.js runs by hand. That second check reads a file the first has
|
|
280
|
+
// not renamed into place yet, concludes the deck is unregistered and writes it
|
|
281
|
+
// again, and the two writes race over one record: the deck reports itself
|
|
282
|
+
// unregistered on a machine where nothing whatsoever is wrong. A caller that
|
|
283
|
+
// asks mid-check gets the answer the check already in flight is fetching.
|
|
284
|
+
let inFlight = null;
|
|
285
|
+
const check = () => (inFlight ??= run().finally(() => { inFlight = null; }));
|
|
286
|
+
|
|
287
|
+
const timer = setInterval(() => { check(); }, intervalMs);
|
|
288
|
+
timer.unref?.();
|
|
289
|
+
|
|
290
|
+
const stop = () => {
|
|
291
|
+
clearInterval(timer);
|
|
292
|
+
return inFlight ?? Promise.resolve(null);
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
return { file: discoveryPath(), check, stop };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export { AGENT_DAG_DIR };
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// The events.jsonl this deck keeps: where it is, whether this deck is the one
|
|
2
|
+
// that writes a given session to it, who else shares it, when it rolls over,
|
|
3
|
+
// and whether it is being written at all.
|
|
4
|
+
//
|
|
5
|
+
// These lived in src/server/index.mjs — the path and the election near the top,
|
|
6
|
+
// the rotation after them, and the writability probe beside the Restart gate
|
|
7
|
+
// that reads it. Every one of them is a question about the same file and none
|
|
8
|
+
// of them touches the ring, the SSE fan-out or the scanners, so they moved
|
|
9
|
+
// together, with `persistPath` itself. That variable is assigned in one place,
|
|
10
|
+
// openEventLog, and read everywhere else through eventLogPath(); the bodies
|
|
11
|
+
// that moved read it directly, as they always did.
|
|
12
|
+
import { mkdir, open, stat, unlink } from "node:fs/promises";
|
|
13
|
+
import { resolve, dirname as pdirname } from "node:path";
|
|
14
|
+
import { PRODUCT } from "./brand.mjs";
|
|
15
|
+
import { appendFailureStats, appendsLanded, flushAppends } from "./log-writer.mjs";
|
|
16
|
+
import { electWriters, foldsCase } from "./log-election.mjs";
|
|
17
|
+
// Every deck registered right now that proved it is the deck its record
|
|
18
|
+
// describes — the group logSharing counts. See live-decks.mjs.
|
|
19
|
+
import { readLiveDecks } from "./live-decks.mjs";
|
|
20
|
+
|
|
21
|
+
let persistPath = null; // absolute path to events.jsonl, or null
|
|
22
|
+
|
|
23
|
+
/** The log this deck was told to keep, resolved, or null when it keeps none.
|
|
24
|
+
* Read through here by everything but the code that owns the file;
|
|
25
|
+
* openEventLog is the one writer. */
|
|
26
|
+
function eventLogPath() {
|
|
27
|
+
return persistPath;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── Which deck records which session ─────────────────────────────────────
|
|
31
|
+
// The hook posts an event to every deck whose workspace matches, and by
|
|
32
|
+
// default all of them append to one events.jsonl — so each event landed in
|
|
33
|
+
// that file once per running deck, and every later replay of it ingested each
|
|
34
|
+
// tool call that many times. The hook now elects one writer per log file and
|
|
35
|
+
// marks the request to the rest `?persist=0`. That flag covers the hook event
|
|
36
|
+
// itself; this map carries it to the ModelObserved/UsageObserved/
|
|
37
|
+
// ContextObserved events a deck derives from the same session's transcript,
|
|
38
|
+
// which every deck reads for itself and would otherwise duplicate exactly the
|
|
39
|
+
// same way. A session is recorded unless we have been told someone else owns
|
|
40
|
+
// it, so a lone deck — and a deck the hook is too old to flag — still writes
|
|
41
|
+
// everything.
|
|
42
|
+
//
|
|
43
|
+
// THIS COVERS THE CLAUDE SIDE ONLY, and that is worth stating because reading it
|
|
44
|
+
// as the general answer is what produced #447. The only thing that ever fills
|
|
45
|
+
// the set is noteLogWriter, called from handleEventIngest — a hook POST. Codex
|
|
46
|
+
// events are read off the rollout files inside this process and never reach an
|
|
47
|
+
// HTTP handler, and Codex hooks are not installed any more, so a Codex session
|
|
48
|
+
// id cannot get in here and writesLogFor answers "mine" for every one of them on
|
|
49
|
+
// every deck. The Codex half is decided by writesCodexLog in the watcher's scan
|
|
50
|
+
// loop instead, and every event derived from a rollout — including the memory
|
|
51
|
+
// scan's ContextObserved — has to carry that verdict explicitly.
|
|
52
|
+
const foreignSessions = new Set(); // session ids another deck is logging
|
|
53
|
+
const MAX_FOREIGN_SESSIONS = 512; // bound: insertion order = oldest first
|
|
54
|
+
|
|
55
|
+
function noteLogWriter(payload, mine) {
|
|
56
|
+
const sid = payload && typeof payload === "object" ? payload.session_id : null;
|
|
57
|
+
if (typeof sid !== "string" || sid === "") return;
|
|
58
|
+
// Delete either way: on re-add it moves the id back to the young end, so the
|
|
59
|
+
// eviction below drops sessions that stopped being posted, not busy ones.
|
|
60
|
+
foreignSessions.delete(sid);
|
|
61
|
+
if (mine) return;
|
|
62
|
+
foreignSessions.add(sid);
|
|
63
|
+
if (foreignSessions.size > MAX_FOREIGN_SESSIONS) {
|
|
64
|
+
foreignSessions.delete(foreignSessions.values().next().value);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Is this deck the one that writes this payload's session to the log? Every
|
|
70
|
+
* event goes through here on its way to disk, including the ones this deck
|
|
71
|
+
* derives from a transcript on its own — that is the point of remembering the
|
|
72
|
+
* session rather than only honouring the flag on the event that carried it.
|
|
73
|
+
*/
|
|
74
|
+
export function writesLogFor(payload) {
|
|
75
|
+
const sid = payload && typeof payload === "object" ? payload.session_id : null;
|
|
76
|
+
return typeof sid !== "string" || sid === "" || !foreignSessions.has(sid);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Who else is holding the log this deck would empty, and whose it is to empty.
|
|
81
|
+
*
|
|
82
|
+
* `events.jsonl` is one file several decks share — that is the whole reason the
|
|
83
|
+
* election above exists — and `POST /api/clear` truncated it from whichever deck
|
|
84
|
+
* happened to be asked. So Clear on a deck scoped to one tree deleted the
|
|
85
|
+
* machine-wide deck's weeks of history, and told nobody: the other deck goes on
|
|
86
|
+
* serving what is still in its ring, so the damage only shows up the next time
|
|
87
|
+
* it boots and replays a file that is now empty (#698). Measured on macOS 15 /
|
|
88
|
+
* Node 22.14: 1407 bytes and five lines before, 134 bytes after — and the 134
|
|
89
|
+
* were the clearing deck's own `__clear` marker, appended to a log it writes
|
|
90
|
+
* nothing else to.
|
|
91
|
+
*
|
|
92
|
+
* The rule this establishes is the one the rest of the file already runs on: a
|
|
93
|
+
* deck may empty the log it WRITES. Ownership is electWriters — the same
|
|
94
|
+
* election, over the same discovery records, that decides which deck appends a
|
|
95
|
+
* line — so the process that truncates the file is the process that fills it,
|
|
96
|
+
* which is also the only way the two operations are ordered on any platform. A
|
|
97
|
+
* deck that is not the elected writer clears its own canvas and leaves the file
|
|
98
|
+
* to the deck that owns it; the confirmation says so before the user presses
|
|
99
|
+
* anything, and says how many decks share the file when the answer is "yours to
|
|
100
|
+
* empty". Nothing here decides the copy — `GET /api/clear` hands these facts to
|
|
101
|
+
* the dialog, which is the half that keeps the user from destroying history they
|
|
102
|
+
* were never told about.
|
|
103
|
+
*
|
|
104
|
+
* Deliberately NOT a scope test. Whether this deck was started with
|
|
105
|
+
* `--workspace` has nothing to do with who owns a file: two machine-wide decks
|
|
106
|
+
* share one log exactly as a scoped one shares it with a machine-wide one, and
|
|
107
|
+
* the harm is the same in both directions.
|
|
108
|
+
*
|
|
109
|
+
* Fail-safe matches writesCodexLog's, for the same reason: with no discovery
|
|
110
|
+
* record of our own — the window before the first heartbeat writes it, or a deck
|
|
111
|
+
* that cannot write one at all — nobody can elect us and we keep what a lone
|
|
112
|
+
* deck does. The COUNT is still every deck sharing the file, so even inside that
|
|
113
|
+
* window the confirmation warns rather than guessing quietly.
|
|
114
|
+
*/
|
|
115
|
+
export async function logSharing() {
|
|
116
|
+
if (!persistPath) return { path: null, decks: 1, mine: true, owner: null };
|
|
117
|
+
const fold = s => (foldsCase() ? s.toLowerCase() : s);
|
|
118
|
+
const here = fold(persistPath);
|
|
119
|
+
const sameLog = d => typeof d.persist === "string" && d.persist !== "" && fold(d.persist) === here;
|
|
120
|
+
|
|
121
|
+
let live = [];
|
|
122
|
+
try { live = await readLiveDecks(); } catch { /* unreadable dir — treated as "alone" below */ }
|
|
123
|
+
const group = live.filter(sameLog);
|
|
124
|
+
const self = group.find(d => d.pid === process.pid) ?? null;
|
|
125
|
+
if (!self) return { path: persistPath, decks: Math.max(group.length + 1, 1), mine: true, owner: null };
|
|
126
|
+
|
|
127
|
+
const writers = electWriters(group);
|
|
128
|
+
const mine = writers.has(self);
|
|
129
|
+
// One log path, so the election normally returns exactly one deck. It can
|
|
130
|
+
// return more only when two decks spell the same file differently on a
|
|
131
|
+
// case-sensitive platform, which `sameLog` folded together and electWriters
|
|
132
|
+
// did not — the lowest port of them is the one to name, and `mine` above is
|
|
133
|
+
// the election's own answer either way.
|
|
134
|
+
const owner = mine
|
|
135
|
+
? self
|
|
136
|
+
: [...writers].sort((a, b) => a.port - b.port || a.pid - b.pid)[0] ?? null;
|
|
137
|
+
return { path: persistPath, decks: group.length, mine, owner };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ─── Persistence rotation ─────────────────────────────────────────────────
|
|
141
|
+
// 24/7 dev servers used to grow events.jsonl unbounded — saw it hit GBs
|
|
142
|
+
// across weeks. We rotate when the file passes ROTATE_AT_BYTES, archiving
|
|
143
|
+
// the previous file to .1 and starting fresh. Last-event-id replay still
|
|
144
|
+
// covers the in-memory ring buffer, which is bounded by MAX_BUFFER events AND
|
|
145
|
+
// by MAX_BUFFER_CHARS — so how far back a replay reaches depends on how large
|
|
146
|
+
// the traffic has been, not on the count alone.
|
|
147
|
+
const ROTATE_AT_BYTES = 50 * 1024 * 1024;
|
|
148
|
+
let lastRotateCheckAt = 0;
|
|
149
|
+
let rotateInProgress = false;
|
|
150
|
+
/** Bytes handed to appendLogLine since the last time we looked at the file.
|
|
151
|
+
*
|
|
152
|
+
* THE 30-SECOND CLOCK MADE THE THRESHOLD ADVISORY. The stat was throttled to
|
|
153
|
+
* once per 30s and this function has one caller — the push path — so the
|
|
154
|
+
* overshoot was exactly `30s x the ingest byte rate`, which is unbounded in
|
|
155
|
+
* throughput rather than merely loose. Measured, on a log doing 10.5 MB/s:
|
|
156
|
+
*
|
|
157
|
+
* t=6s log=51393249 <- 50 MB crossed
|
|
158
|
+
* t=30s log=306262048
|
|
159
|
+
* t=31s log=0 log1=316750478 <- rotation fires, 302 MB, 25s late
|
|
160
|
+
*
|
|
161
|
+
* and at higher rates 1,199 MB and 2,579 MB with ZERO rotations — 51x the
|
|
162
|
+
* cap, 604 MB on disk across the two generations for a documented 50 MB.
|
|
163
|
+
*
|
|
164
|
+
* Counting what we hand the appender costs one addition per event and removes
|
|
165
|
+
* the dependency on throughput: the check now happens when enough has been
|
|
166
|
+
* written to be worth a stat, whatever the clock says. The clock stays as a
|
|
167
|
+
* FLOOR for the idle case, where nothing is being written and a periodic look
|
|
168
|
+
* is the only way to notice a file that grew by some other route.
|
|
169
|
+
*
|
|
170
|
+
* Deliberately a lower bound on the real file: it counts bytes queued, not
|
|
171
|
+
* bytes landed, and it is reset on every look rather than on a successful
|
|
172
|
+
* rotation — so a stat that finds the file still under the threshold has
|
|
173
|
+
* already paid for itself and does not need to re-count what it just saw. */
|
|
174
|
+
let bytesSinceRotateCheck = 0;
|
|
175
|
+
/** Enough written to be worth a stat, whatever the clock says. A fifth of the
|
|
176
|
+
* threshold, so the worst overshoot is bounded at ~20% rather than by rate. */
|
|
177
|
+
const ROTATE_CHECK_EVERY_BYTES = Math.floor(ROTATE_AT_BYTES / 5);
|
|
178
|
+
/** And a floor for the idle case, where nothing is being written and a
|
|
179
|
+
* periodic look is the only way to notice a file that grew by some other
|
|
180
|
+
* route — another deck appending to a log they share, say. */
|
|
181
|
+
const ROTATE_CHECK_EVERY_MS = 30_000;
|
|
182
|
+
/**
|
|
183
|
+
* Whether it is worth asking the filesystem how big the log is.
|
|
184
|
+
*
|
|
185
|
+
* Pure, and exported, for the reason mayReadAccounts and maySelfPoll are: this
|
|
186
|
+
* is the rule, and a rule whose only observable failure is a file quietly
|
|
187
|
+
* reaching gigabytes belongs somewhere a test can point at it. Rotation had no
|
|
188
|
+
* test of any kind.
|
|
189
|
+
*/
|
|
190
|
+
export function rotateCheckDue({ now, lastCheckAt, bytesSince }) {
|
|
191
|
+
return now - lastCheckAt >= ROTATE_CHECK_EVERY_MS
|
|
192
|
+
|| bytesSince >= ROTATE_CHECK_EVERY_BYTES;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function maybeRotatePersistFile(wroteBytes = 0) {
|
|
196
|
+
if (!persistPath) return;
|
|
197
|
+
bytesSinceRotateCheck += wroteBytes;
|
|
198
|
+
const now = Date.now();
|
|
199
|
+
if (!rotateCheckDue({ now, lastCheckAt: lastRotateCheckAt, bytesSince: bytesSinceRotateCheck })) return;
|
|
200
|
+
lastRotateCheckAt = now;
|
|
201
|
+
bytesSinceRotateCheck = 0;
|
|
202
|
+
if (rotateInProgress) return;
|
|
203
|
+
rotateInProgress = true;
|
|
204
|
+
try {
|
|
205
|
+
const s = await stat(persistPath).catch(() => null);
|
|
206
|
+
if (!s || s.size < ROTATE_AT_BYTES) return;
|
|
207
|
+
// ONLY THE DECK THAT WRITES THIS LOG MAY MOVE IT, the same gate `/api/clear`
|
|
208
|
+
// already keeps and for the same reason. Two decks appending to one log is
|
|
209
|
+
// explicitly permitted as the fail-safe — log-election.mjs says "a line
|
|
210
|
+
// written twice is recoverable" — so both cross 50 MB and both rotate. If
|
|
211
|
+
// B's stat lands before A's rename, B's `unlink` deletes the archive A has
|
|
212
|
+
// just made and B's `rename` moves the new, near-empty live file into its
|
|
213
|
+
// place: one generation of history destroyed by a deck that was only
|
|
214
|
+
// housekeeping. The append path was made multi-writer safe; this one was
|
|
215
|
+
// not. Ownership is electWriters, so nothing new gets to disagree with it.
|
|
216
|
+
const sharing = await logSharing();
|
|
217
|
+
if (!sharing.mine) return;
|
|
218
|
+
// Roll events.jsonl → events.jsonl.1 (replacing any previous .1).
|
|
219
|
+
const oldPath = persistPath + ".1";
|
|
220
|
+
// LET THE QUEUE DRAIN FIRST. Appends are ordered behind a promise chain
|
|
221
|
+
// inside log-writer.mjs and a descriptor opened before the rename completes
|
|
222
|
+
// into the RENAMED inode, so the line being appended at the moment of a
|
|
223
|
+
// rotation lands in the archive rather than the live log — measured, a
|
|
224
|
+
// 24 MB line in events.jsonl.1 and a 65-byte live file. See flushAppends.
|
|
225
|
+
await flushAppends(persistPath);
|
|
226
|
+
try { await unlink(oldPath); } catch {}
|
|
227
|
+
const { rename } = await import("node:fs/promises");
|
|
228
|
+
await rename(persistPath, oldPath);
|
|
229
|
+
console.log(`${PRODUCT}: rotated ${persistPath} (${(s.size / 1024 / 1024).toFixed(0)}MB → ${oldPath})`);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
console.error(`${PRODUCT}: persist rotation failed:`, err && err.message ? err.message : err);
|
|
232
|
+
} finally {
|
|
233
|
+
rotateInProgress = false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// A log that cannot be written is no log wearing a different hat. The Restart
|
|
238
|
+
// gate — `_canRestart` in index.mjs — was a test of CONFIGURATION alone,
|
|
239
|
+
// `_onRestart != null && persist != null`, so a deck whose `--history` named a
|
|
240
|
+
// read-only volume, a full one, or a removable drive that was later unmounted
|
|
241
|
+
// answered `canRestart: true` while not one event reached disk. Measured: 10 of
|
|
242
|
+
// 10 posts acknowledged, no file on disk, `canRestart: true`. The press then
|
|
243
|
+
// lands on replayLog's `if (!existsSync(filePath)) return 0` and takes the
|
|
244
|
+
// whole session history with it — which is precisely the loss the flag exists
|
|
245
|
+
// to prevent.
|
|
246
|
+
let _persistWritable = false;
|
|
247
|
+
// How many lines had landed on `persistPath` when the probe answered. Read by
|
|
248
|
+
// logWritableNow and nothing else; see appendsLanded.
|
|
249
|
+
let _landedAtProbe = 0;
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Is the log this deck was told to keep being written, on the newest evidence?
|
|
253
|
+
*
|
|
254
|
+
* `_persistWritable` answered this alone, and it is the boot probe's answer:
|
|
255
|
+
* nothing after the boot could change it. The gate it fed had a closing edge —
|
|
256
|
+
* a failing append opens an episode, and an episode shut the gate — and no
|
|
257
|
+
* opening one. MEASURED on a sandboxed deck (#1130), a log whose parent was a
|
|
258
|
+
* file at boot and a directory a moment later, which is what a mount that
|
|
259
|
+
* comes up late or a permission that is fixed leaves behind: a line on disk,
|
|
260
|
+
* the failure count at 0, and `canRestart` false for the life of the process.
|
|
261
|
+
*
|
|
262
|
+
* The appender is the evidence, and it already produces it: every line either
|
|
263
|
+
* lands or fails, and log-writer counts both. So the answer is whichever of the
|
|
264
|
+
* two sources spoke LAST. An open failure episode means the newest append
|
|
265
|
+
* failed, whatever came before it. Otherwise a line landed since the probe
|
|
266
|
+
* means the newest evidence is a write that worked, whatever the probe said;
|
|
267
|
+
* and with neither, the probe is still the newest thing anyone knows.
|
|
268
|
+
*
|
|
269
|
+
* No syscall is added anywhere for it. The probe stays the one at boot, the
|
|
270
|
+
* episode is the one #1062 keeps, and what an event pays is one counter
|
|
271
|
+
* increment in the landing handler (append-failures.mjs), beside the episode
|
|
272
|
+
* lookup that handler already makes.
|
|
273
|
+
*/
|
|
274
|
+
function logWritableNow() {
|
|
275
|
+
if (!persistPath) return false;
|
|
276
|
+
if (appendFailureStats(persistPath).failing > 0) return false;
|
|
277
|
+
return _persistWritable || appendsLanded(persistPath) > _landedAtProbe;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Is the log this deck was told to keep actually writable?
|
|
282
|
+
*
|
|
283
|
+
* One `open(path, "a")` and one close — the same pair every append pays, asked
|
|
284
|
+
* once at boot so the answer is known before the first event rather than after
|
|
285
|
+
* the first silent loss. It CREATES the file, which is what an append would do
|
|
286
|
+
* anyway and what makes the probe honest about the directory as well as the
|
|
287
|
+
* file: EROFS, EACCES and ENOENT all surface here.
|
|
288
|
+
*/
|
|
289
|
+
async function probeLogWritable(path) {
|
|
290
|
+
let handle = null;
|
|
291
|
+
try {
|
|
292
|
+
handle = await open(path, "a");
|
|
293
|
+
return true;
|
|
294
|
+
} catch (err) {
|
|
295
|
+
console.error(`${PRODUCT}: the event log ${path} is not writable (${err && err.message ? err.message : err}) — Restart is disabled until an event reaches it, because it would replay a log this deck is not filling`);
|
|
296
|
+
return false;
|
|
297
|
+
} finally {
|
|
298
|
+
await handle?.close().catch(() => {});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Point the deck at the log it was told to keep, and probe it once.
|
|
304
|
+
*
|
|
305
|
+
* Lifted out of startServer, which calls it once per boot, before the replay.
|
|
306
|
+
* A boot with no log leaves `persistPath` as it was and only resets the probe,
|
|
307
|
+
* which is what startServer always did: the path is assigned when there is one
|
|
308
|
+
* and never cleared.
|
|
309
|
+
*/
|
|
310
|
+
async function openEventLog(persist) {
|
|
311
|
+
_persistWritable = false;
|
|
312
|
+
if (!persist) return;
|
|
313
|
+
persistPath = resolve(persist);
|
|
314
|
+
try { await mkdir(pdirname(persistPath), { recursive: true }); } catch {}
|
|
315
|
+
// Asked before the first event, so a log that cannot be written is a fact
|
|
316
|
+
// the deck states rather than one the user discovers by pressing Restart.
|
|
317
|
+
// The mkdir above is inside a bare try/catch, so its failure is already
|
|
318
|
+
// swallowed once by the time we get here — see _persistWritable.
|
|
319
|
+
_persistWritable = await probeLogWritable(persistPath);
|
|
320
|
+
// Taken the moment the probe answers, so that a line landing from here on
|
|
321
|
+
// is evidence newer than it. See logWritableNow.
|
|
322
|
+
_landedAtProbe = appendsLanded(persistPath);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// What index.mjs calls besides the three exported above. Listed rather than
|
|
326
|
+
// marked at each declaration, so the declarations read as they did where they
|
|
327
|
+
// came from.
|
|
328
|
+
export { eventLogPath, logWritableNow, maybeRotatePersistFile, noteLogWriter, openEventLog };
|