ccdeck 1.43.0 → 1.45.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 +210 -12
- package/bin/agent-dag.js +505 -0
- package/bin/deck.js +1051 -0
- package/dist/web/assets/index-Bdl1LX0-.css +1 -0
- package/dist/web/assets/index-jXBjwwZC.js +89 -0
- package/dist/web/index.html +49 -0
- package/hook/hook.js +496 -0
- package/package.json +42 -9
- package/src/server/args.mjs +156 -0
- package/src/server/brand.mjs +16 -0
- package/src/server/ccusage.mjs +1052 -0
- package/src/server/claude-accounts.mjs +653 -0
- package/src/server/claude-dir.mjs +213 -0
- package/src/server/codex-auth.mjs +388 -0
- package/src/server/codex-dir.mjs +171 -0
- package/src/server/codex-quota.mjs +445 -0
- package/src/server/codex-usage.mjs +411 -0
- package/src/server/cswap-admin.mjs +1034 -0
- package/src/server/cswap-auto.mjs +595 -0
- package/src/server/cswap-install.mjs +583 -0
- package/src/server/exec.mjs +938 -0
- package/src/server/index.mjs +5109 -0
- package/src/server/installer.mjs +763 -0
- package/src/server/invoked-as.mjs +144 -0
- package/src/server/log-writer.mjs +283 -0
- package/src/server/npx.mjs +259 -0
- package/src/server/quota.mjs +684 -0
- package/src/server/reset-label.mjs +78 -0
- package/src/server/retire-sound-hook.mjs +315 -0
- package/src/server/self-update.mjs +1331 -0
- package/src/server/supervisor.mjs +302 -0
- package/src/server/system-metrics.mjs +619 -0
- package/src/server/term.mjs +570 -0
- package/src/server/uv-bootstrap.mjs +301 -0
- package/bin/ccdeck.js +0 -194
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Which of the three published commands the user typed — and nothing else.
|
|
2
|
+
//
|
|
3
|
+
// The deck is on npm three times over — `ccdeck`, `agents-deck` and `agent-dag`
|
|
4
|
+
// — and since #340 all three are literally the same tarball, published with
|
|
5
|
+
// `name` set to each in turn. Every surface a human reads says ccdeck now, and
|
|
6
|
+
// most of the downloads are still on the other two, so nothing closes that split
|
|
7
|
+
// on its own. This file is the evidence behind saying so — once in the terminal,
|
|
8
|
+
// once in the browser.
|
|
9
|
+
//
|
|
10
|
+
// Two rules make that safe, and they are the whole of the file.
|
|
11
|
+
//
|
|
12
|
+
// It is never a refusal. A build that declined to boot under an old name would
|
|
13
|
+
// take the self-update path down with it: a user on `agents-deck` clicks
|
|
14
|
+
// "Update & restart", the copy that arrives refuses to run, and the deck is
|
|
15
|
+
// dead on the one machine where the deck was the thing that would have
|
|
16
|
+
// explained why.
|
|
17
|
+
//
|
|
18
|
+
// And it is never asked of the PACKAGE. That was unarguable while ccdeck was a
|
|
19
|
+
// stub — it depended on `agents-deck` and spawned its bin, so "is this build
|
|
20
|
+
// agents-deck?" was true inside every ccdeck run, and a notice keyed on it would
|
|
21
|
+
// have scolded exactly the people who did what we asked. #340 removed the stub
|
|
22
|
+
// and the rule outlived it, because the three names are now ONE tarball: asking
|
|
23
|
+
// the package what it is gets you whichever name the publish step happened to
|
|
24
|
+
// set last, which is not evidence about the user at all. The question is which
|
|
25
|
+
// name the USER TYPED. That is a different question, with a different answer,
|
|
26
|
+
// and on one platform with no answer at all:
|
|
27
|
+
//
|
|
28
|
+
// npx, everywhere — npm writes the literal spec into
|
|
29
|
+
// `_npx/<hash>/package.json` as `_npx.packages`, which npxRestartSpec
|
|
30
|
+
// already reads to decide what a restart re-runs. Reused rather than
|
|
31
|
+
// re-derived; the only difference here is that this caller has no fallback
|
|
32
|
+
// to offer, since a guess is the one answer it must not give.
|
|
33
|
+
//
|
|
34
|
+
// a global install on macOS or Linux — npm links <prefix>/bin/<name> at the
|
|
35
|
+
// package's bin/agent-dag.js, and node does not resolve that symlink for
|
|
36
|
+
// argv[1]. All three names reach one file and only argv[1] tells them
|
|
37
|
+
// apart, so its last segment is the command that was typed. Verified with
|
|
38
|
+
// npm's own layout: a symlink named `ccdeck` gives argv[1] `…/bin/ccdeck`.
|
|
39
|
+
//
|
|
40
|
+
// a global install on Windows — nowhere. npm writes <name>.cmd, <name>.ps1
|
|
41
|
+
// and an extensionless sh shim, each of which runs
|
|
42
|
+
// `node "…\node_modules\<pkg>\bin\agent-dag.js" %*` (npm's cmd-shim).
|
|
43
|
+
// The typed name is the shim's FILENAME and never becomes an argument, and
|
|
44
|
+
// there is no other carrier — npm_config_user_agent and friends are not set
|
|
45
|
+
// for a direct bin invocation. So Windows answers null.
|
|
46
|
+
//
|
|
47
|
+
// a git checkout — nothing was typed. `node bin/agent-dag.js` is not any of
|
|
48
|
+
// the three names.
|
|
49
|
+
//
|
|
50
|
+
// Which leaves the callers one rule: positive identification, or silence. A
|
|
51
|
+
// notice shown to somebody who already types `ccdeck` is worse than a notice
|
|
52
|
+
// missed, because it is the failure that teaches everyone to ignore the next
|
|
53
|
+
// one.
|
|
54
|
+
import { bareSpecName, isNpxInstall, npxRestartSpec } from "./self-update.mjs";
|
|
55
|
+
|
|
56
|
+
/** The command every surface promotes. Deliberately not brand.mjs's PRODUCT:
|
|
57
|
+
* that is the name a human reads, this is an npm bin name the user types, and
|
|
58
|
+
* the two are the same string only because the rename made them so —
|
|
59
|
+
* invoked-as.test.ts pins them against each other rather than assuming it. */
|
|
60
|
+
export const PREFERRED = "ccdeck";
|
|
61
|
+
|
|
62
|
+
/** Every command an install of this package provides, which is every name that
|
|
63
|
+
* can be typed. package.json's `bin` block is the other half of this list. */
|
|
64
|
+
export const COMMANDS = [PREFERRED, "agents-deck", "agent-dag"];
|
|
65
|
+
|
|
66
|
+
/** `value` when it is one of those three, and null for everything else — a shim
|
|
67
|
+
* filename with an extension on it, a path, a spec that resolved to some other
|
|
68
|
+
* package, an environment variable somebody set by hand. None of those are
|
|
69
|
+
* evidence of anything, and this function is the only place that decides so. */
|
|
70
|
+
export function knownCommand(value) {
|
|
71
|
+
return typeof value === "string" && COMMANDS.includes(value) ? value : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The last segment of a path, split on both separators rather than `path.sep`
|
|
75
|
+
* — the same reason isNpxInstall gives: a Windows path can reach a POSIX
|
|
76
|
+
* process through a test or a config file, and a separator-agnostic split is
|
|
77
|
+
* what keeps this one function on all three platforms. */
|
|
78
|
+
function lastSegment(path) {
|
|
79
|
+
if (typeof path !== "string") return null;
|
|
80
|
+
const parts = path.split(/[\\/]/);
|
|
81
|
+
return parts[parts.length - 1];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The name the user typed, when we can prove it. Null means unknown, and
|
|
86
|
+
* unknown must never produce a notice.
|
|
87
|
+
*/
|
|
88
|
+
export function invokedAs({ pkgRoot, argv1, platform = process.platform } = {}) {
|
|
89
|
+
// npx first, and alone: under npx argv[1] is a path inside the cache
|
|
90
|
+
// directory, so the metadata is not the better answer, it is the only one.
|
|
91
|
+
// The explicit null fallback is the point of the call — npxRestartSpec's
|
|
92
|
+
// default is the package name, which is right for "what do I re-run" and
|
|
93
|
+
// would be a guess wearing an answer's clothes here.
|
|
94
|
+
if (isNpxInstall(pkgRoot)) return knownCommand(bareSpecName(npxRestartSpec(pkgRoot, null)));
|
|
95
|
+
// See the header: on Windows the shim eats the name. Decided on the platform
|
|
96
|
+
// rather than left to the extension check below, so that a Windows path that
|
|
97
|
+
// somehow did end in a bare name still cannot become a guess.
|
|
98
|
+
if (platform === "win32") return null;
|
|
99
|
+
return knownCommand(lastSegment(argv1));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The same question, answered for the process that is asking it.
|
|
104
|
+
*
|
|
105
|
+
* bin/deck.js — and the server running inside it — is spawned BY the
|
|
106
|
+
* supervisor, so its own argv[1] is `…/bin/deck.js` every single time: the
|
|
107
|
+
* typed name reached bin/agent-dag.js and stopped there. The supervisor answers
|
|
108
|
+
* once and passes the answer down in AGENTS_DECK_INVOKED_AS, which is read back
|
|
109
|
+
* through knownCommand rather than trusted, since an environment variable is
|
|
110
|
+
* user input like any other. The fall-through still answers the npx half for a
|
|
111
|
+
* worker started with no supervisor above it.
|
|
112
|
+
*/
|
|
113
|
+
export function invokedName({
|
|
114
|
+
env = process.env, pkgRoot, argv1 = process.argv[1], platform = process.platform,
|
|
115
|
+
} = {}) {
|
|
116
|
+
return knownCommand(env?.AGENTS_DECK_INVOKED_AS) ?? invokedAs({ pkgRoot, argv1, platform });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* What the terminal says about it, or null when there is nothing to say.
|
|
121
|
+
*
|
|
122
|
+
* Two lines, because the second one is where the value is. Someone who ran
|
|
123
|
+
* `npm i -g agents-deck` has nothing to install and nothing to download — the
|
|
124
|
+
* same install already put a `ccdeck` on their PATH, and all they have to do is
|
|
125
|
+
* type it. Handing them an install command instead would be work we invented.
|
|
126
|
+
* Under npx there is no such install, so the fix there is the command itself.
|
|
127
|
+
*
|
|
128
|
+
* `dash` comes from the caller's glyph tier: an em dash is as absent from a
|
|
129
|
+
* legacy Windows console as a check mark is, and this file must not be the one
|
|
130
|
+
* place that forgets it.
|
|
131
|
+
*/
|
|
132
|
+
export function renameNotice({ invoked, pkgRoot, dash = "—", preferred = PREFERRED } = {}) {
|
|
133
|
+
const name = knownCommand(invoked);
|
|
134
|
+
if (!name || name === preferred) return null;
|
|
135
|
+
return {
|
|
136
|
+
// Leads with the reassurance on purpose. Nothing is breaking here, no
|
|
137
|
+
// command is being taken away, and a line that opened with the rename would
|
|
138
|
+
// read as one that was.
|
|
139
|
+
said: `${name} still works ${dash} the deck is called ${preferred} now`,
|
|
140
|
+
fix: isNpxInstall(pkgRoot)
|
|
141
|
+
? `next time: npx ${preferred}`
|
|
142
|
+
: `${preferred} already works here ${dash} same install, no download`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
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
|
+
import { open } from "node:fs/promises";
|
|
24
|
+
import { win32, posix } from "node:path";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Does this platform's filesystem treat two spellings that differ only in case
|
|
28
|
+
* as the same file? The platform is a parameter so both answers can be checked
|
|
29
|
+
* from either kind of machine.
|
|
30
|
+
*
|
|
31
|
+
* Windows always does, and macOS does by default (APFS and HFS+ are formatted
|
|
32
|
+
* case-insensitive unless the user deliberately chose otherwise). Linux does
|
|
33
|
+
* not, and folding case there would be a bug of its own: /srv/a/events.jsonl and
|
|
34
|
+
* /srv/A/events.jsonl are two real files, each of which needs a writer.
|
|
35
|
+
*/
|
|
36
|
+
export const foldsCase = (platform = process.platform) =>
|
|
37
|
+
platform === "win32" || platform === "darwin";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Of these decks, which ones write to disk? Returns the subset that should;
|
|
41
|
+
* every other one is expected to draw the event and keep no record of it.
|
|
42
|
+
*
|
|
43
|
+
* Decks are grouped by the log file each one names in its discovery record and
|
|
44
|
+
* one deck per group is elected. Grouping by the file rather than counting decks
|
|
45
|
+
* is what keeps the overrides honest: a deck run with `--history` sits alone in
|
|
46
|
+
* its own group and always writes, a deck run with `--no-persist` reports no
|
|
47
|
+
* file and can never be elected to write for one that does, and a deck too old
|
|
48
|
+
* to report either keeps the behaviour it had before this rule existed. Within a
|
|
49
|
+
* group the lowest port wins — a fixed rule, so the same deck holds the file for
|
|
50
|
+
* as long as it is up and the next one inherits it as soon as that deck is gone.
|
|
51
|
+
*
|
|
52
|
+
* Kept byte-for-byte equivalent to electWriters in hook/hook.js: the two decide
|
|
53
|
+
* for the same decks over the same records, and a disagreement between them
|
|
54
|
+
* means one log line written twice or none at all.
|
|
55
|
+
*/
|
|
56
|
+
export function electWriters(decks, platform = process.platform) {
|
|
57
|
+
const byLog = new Map();
|
|
58
|
+
for (const d of decks) {
|
|
59
|
+
const log = typeof d.persist === "string" ? d.persist : "";
|
|
60
|
+
// Two namespaces, so a deck with no log to share — and a deck too old to
|
|
61
|
+
// report one — is alone in its group and cannot collide with a real path.
|
|
62
|
+
const key = log
|
|
63
|
+
? `log:${foldsCase(platform) ? log.toLowerCase() : log}`
|
|
64
|
+
: `deck:${d.pid}:${d.port}`;
|
|
65
|
+
const held = byLog.get(key);
|
|
66
|
+
// Ports are unique among live decks; pid only breaks a tie a stale
|
|
67
|
+
// discovery file could invent, so the answer stays deterministic.
|
|
68
|
+
if (!held || d.port < held.port || (d.port === held.port && d.pid < held.pid)) {
|
|
69
|
+
byLog.set(key, d);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return new Set(byLog.values());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Would a deck scoped to `workspace` capture a rollout running in `cwd`? An
|
|
77
|
+
* empty workspace is unscoped and captures every session; a rollout that never
|
|
78
|
+
* said where it runs is inside no workspace, so only an unscoped deck draws it.
|
|
79
|
+
*
|
|
80
|
+
* This answers two questions with one function — whether THIS deck tails a
|
|
81
|
+
* rollout, and whether another deck tails it too — and that is only sound while
|
|
82
|
+
* the rule below is the rule every deck actually runs. Model another deck's
|
|
83
|
+
* capture with anything else and the election covers the wrong set: a deck that
|
|
84
|
+
* writes without being elected, or an elected deck that never opened the file.
|
|
85
|
+
*
|
|
86
|
+
* It is also the rule hook/hook.js runs for the sessions it delivers, under the
|
|
87
|
+
* name capturesSession — that script is copied out of the package and run
|
|
88
|
+
* standalone, so the two are written twice and pinned equal by a test walking
|
|
89
|
+
* one table of paths through both. They were not equal: case was folded here on
|
|
90
|
+
* every platform, so on Linux a deck scoped to /srv/proj captured Codex sessions
|
|
91
|
+
* from /srv/Proj and Claude sessions from neither. Those are two real
|
|
92
|
+
* directories there, and the hook's own comment says what folding them together
|
|
93
|
+
* costs — a deck handed the events of a tree it was not scoped to. So the fold
|
|
94
|
+
* is per-platform on both sides now, and `--workspace` means one thing.
|
|
95
|
+
*
|
|
96
|
+
* (The narrow window that opens: two decks on Linux whose workspaces differ only
|
|
97
|
+
* in case, one of them old enough to still fold, both containing one rollout's
|
|
98
|
+
* cwd. Each models the other as tailing the file; one of them is wrong, and the
|
|
99
|
+
* cost is a single log line written twice.)
|
|
100
|
+
*
|
|
101
|
+
* The platform is a parameter, following the hook's cwdInWorkspace and
|
|
102
|
+
* spawnSpec in src/server/exec.mjs, so the Windows separator is testable from a
|
|
103
|
+
* POSIX machine.
|
|
104
|
+
*/
|
|
105
|
+
export function codexCwdInWorkspace(cwd, workspace, platform = process.platform) {
|
|
106
|
+
if (!workspace || typeof workspace !== "string") return true;
|
|
107
|
+
if (!cwd || typeof cwd !== "string") return false;
|
|
108
|
+
const p = platform === "win32" ? win32 : posix;
|
|
109
|
+
const fold = s => (foldsCase(platform) ? s.toLowerCase() : s);
|
|
110
|
+
const a = fold(p.resolve(cwd));
|
|
111
|
+
const b = fold(p.resolve(workspace));
|
|
112
|
+
if (a === b) return true;
|
|
113
|
+
// A root ("C:\", "/") already ends in the separator; appending a second one
|
|
114
|
+
// would match nothing.
|
|
115
|
+
return a.startsWith(b.endsWith(p.sep) ? b : b + p.sep);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Does this deck append a rollout's events to its log, or is another deck doing
|
|
120
|
+
* it? `decks` is every deck registered right now, `pid` identifies this one
|
|
121
|
+
* among them, and `cwd` is the workspace the rollout is running in.
|
|
122
|
+
*
|
|
123
|
+
* The group is every deck that tails this same rollout: each deck decides for
|
|
124
|
+
* itself, so all of them whose workspace contains the cwd read the file and all
|
|
125
|
+
* of them would write it. The hook builds the same group the same way for the
|
|
126
|
+
* events it delivers — it used to narrow them to the longest workspace match
|
|
127
|
+
* first, which is the asymmetry the predicate above describes the end of. A deck
|
|
128
|
+
* started with `--no-codex` tails nothing and is left out; electing it would
|
|
129
|
+
* mean the rollout's events reach no log at all. A deck too old to say either
|
|
130
|
+
* way is assumed to be tailing, which is what it was doing before this field
|
|
131
|
+
* existed.
|
|
132
|
+
*/
|
|
133
|
+
export function writesCodexLog({ decks, pid, cwd, platform = process.platform }) {
|
|
134
|
+
const live = Array.isArray(decks) ? decks : [];
|
|
135
|
+
const self = live.find(d => d && d.pid === pid) ?? null;
|
|
136
|
+
// No record of our own on disk — the window before the first heartbeat writes
|
|
137
|
+
// it, or a deck that cannot write one at all. Nobody can elect us and we
|
|
138
|
+
// cannot see who else is here, so keep what this deck did before the election
|
|
139
|
+
// existed and write. A line written twice is recoverable; a deck that quietly
|
|
140
|
+
// stops recording anything is not.
|
|
141
|
+
if (!self || typeof self.persist !== "string" || self.persist === "") return true;
|
|
142
|
+
|
|
143
|
+
const group = [self];
|
|
144
|
+
for (const d of live) {
|
|
145
|
+
if (!d || d.pid === self.pid) continue;
|
|
146
|
+
if (d.codex === false) continue;
|
|
147
|
+
if (!codexCwdInWorkspace(cwd, d.workspace ?? "", platform)) continue;
|
|
148
|
+
group.push(d);
|
|
149
|
+
}
|
|
150
|
+
return electWriters(group, platform).has(self);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── Appending one whole line ─────────────────────────────────────────────
|
|
154
|
+
//
|
|
155
|
+
// The election above decides WHICH deck writes a line. This decides HOW, and
|
|
156
|
+
// the two are answers to the same question: the log is one file that several
|
|
157
|
+
// processes append to, so a line that arrives in pieces is a line another
|
|
158
|
+
// writer can land inside.
|
|
159
|
+
//
|
|
160
|
+
// What was wrong. events.jsonl was appended with `fsPromises.appendFile`, which
|
|
161
|
+
// is not one write(2). Node's writeFileHandle loops over the payload in chunks
|
|
162
|
+
// of kWriteFileMaxChunkSize — 512 KiB — awaiting each one, so any line longer
|
|
163
|
+
// than 524288 bytes became two or more separate appends with the event loop
|
|
164
|
+
// free in between. Measured here, on Node 26 / macOS 15 / APFS, by watching the
|
|
165
|
+
// file grow while one 5 MiB appendFile was in flight: it arrived in exactly ten
|
|
166
|
+
// steps of 524288 bytes. A single handle.write of the same payload arrived in
|
|
167
|
+
// one step of 5242880.
|
|
168
|
+
//
|
|
169
|
+
// That is reachable in ordinary use. `POST /api/event` accepts a body up to
|
|
170
|
+
// 5,000,000 characters, and a PostToolUse carrying a large Read or Bash
|
|
171
|
+
// response is routinely a good fraction of that. The event that gets spliced
|
|
172
|
+
// into the middle is nearly always this deck's own: pushEvent kicks off
|
|
173
|
+
// maybeResolveUsage / maybeResolveModel / maybeResolveContext on the same call,
|
|
174
|
+
// and each of those pushes its own event a moment later. Reproduced at 8/8 runs
|
|
175
|
+
// with one 1 MiB line and eight small ones issued in one tick, and 4/4 runs
|
|
176
|
+
// across two processes appending 3 MiB lines to one file.
|
|
177
|
+
//
|
|
178
|
+
// The damage is silent and doubled: the oversized event is torn into two
|
|
179
|
+
// unparseable halves AND the small event that landed between them is swallowed
|
|
180
|
+
// inside one of those halves, so a boot replay loses both.
|
|
181
|
+
//
|
|
182
|
+
// What this does instead: exactly one write(2) per line, on a descriptor opened
|
|
183
|
+
// O_APPEND. That is the primitive the atomicity rests on, so it is worth
|
|
184
|
+
// stating exactly what each platform promises.
|
|
185
|
+
//
|
|
186
|
+
// Linux — a write to a regular file holds the inode's i_rwsem for the whole
|
|
187
|
+
// call, so one write(2) is atomic against every other writer regardless of
|
|
188
|
+
// size, up to MAX_RW_COUNT (~2 GiB) after which the call returns short.
|
|
189
|
+
// O_APPEND additionally makes the seek-to-end and the write one step, which
|
|
190
|
+
// is what stops two processes overwriting each other's tail.
|
|
191
|
+
//
|
|
192
|
+
// macOS — measured, not assumed, because the guarantee is not written down as
|
|
193
|
+
// plainly: single writes of 1 MiB and 5 MiB in-process, and 3 MiB from two
|
|
194
|
+
// processes at once, produced no torn line in any run, while appendFile of
|
|
195
|
+
// the same payloads tore in every run.
|
|
196
|
+
//
|
|
197
|
+
// Windows — libuv issues the append as a WriteFile at the documented
|
|
198
|
+
// end-of-file offset on a handle opened FILE_APPEND_DATA, which NTFS serialises
|
|
199
|
+
// per call. This is the platform where the Codex rollout watcher is the only
|
|
200
|
+
// capture path there is, so it is also the platform with the most decks
|
|
201
|
+
// sharing one log.
|
|
202
|
+
//
|
|
203
|
+
// Where the guarantee does NOT hold: any filesystem that can return a short
|
|
204
|
+
// write — NFS and some network/FUSE mounts under memory pressure, and any
|
|
205
|
+
// platform once the payload passes its per-call ceiling. The loop below then
|
|
206
|
+
// issues a second write for the remainder, and another writer can land between
|
|
207
|
+
// them. Nothing available in userland fixes that, so the answer is the reader:
|
|
208
|
+
// replayLog skips a line it cannot parse, counts it, and says so, rather than
|
|
209
|
+
// stopping at it. A torn line costs one event, not the rest of the file.
|
|
210
|
+
//
|
|
211
|
+
// Why not cap or drop oversized events instead. The 5 MB ceiling is already the
|
|
212
|
+
// cap, and it is enforced where it belongs — at ingest, with a 413 the poster
|
|
213
|
+
// can see. Refusing to persist an event the deck is happily drawing would make
|
|
214
|
+
// the canvas and its own replay disagree, which is the bug this fixes wearing a
|
|
215
|
+
// different hat.
|
|
216
|
+
//
|
|
217
|
+
// Why the file is opened per line rather than kept open. maybeRotatePersistFile
|
|
218
|
+
// renames events.jsonl to events.jsonl.1 at 50 MB and `/api/clear` truncates it
|
|
219
|
+
// to zero; a descriptor held across either would go on filling the file nobody
|
|
220
|
+
// reads any more. Opening per line is also exactly the syscall count appendFile
|
|
221
|
+
// already paid — open, write, close — minus the extra writes.
|
|
222
|
+
const appendTails = new Map();
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Append one already-serialized line to the shared log, whole.
|
|
226
|
+
*
|
|
227
|
+
* Appends are serialized per file behind a promise chain. That is not what
|
|
228
|
+
* makes them atomic — the single write(2) below is — but it keeps the order the
|
|
229
|
+
* lines land in equal to the order pushEvent produced them, which is the order
|
|
230
|
+
* a replay reads them back in, and it keeps this process to one open descriptor
|
|
231
|
+
* on the log no matter how many events arrive in one tick.
|
|
232
|
+
*
|
|
233
|
+
* Never rejects. This is called fire-and-forget from the hottest path in the
|
|
234
|
+
* process, and a rejection nobody awaits is a dead deck; a line that could not
|
|
235
|
+
* be written is a line lost, which the caller could not have done anything
|
|
236
|
+
* about anyway. The chain deliberately continues past a failure — one ENOSPC
|
|
237
|
+
* must not stop every later event from being recorded once space is back.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} filePath absolute path to the log
|
|
240
|
+
* @param {string} line the line to append, INCLUDING its trailing newline
|
|
241
|
+
*/
|
|
242
|
+
export function appendLogLine(filePath, line) {
|
|
243
|
+
const tail = (appendTails.get(filePath) ?? Promise.resolve())
|
|
244
|
+
.then(() => writeWholeLine(filePath, line))
|
|
245
|
+
.catch(() => {});
|
|
246
|
+
appendTails.set(filePath, tail);
|
|
247
|
+
// Drop the chain once it drains, so a process that writes to several logs
|
|
248
|
+
// over its life does not hold a promise per path it has finished with. Only
|
|
249
|
+
// the tail we just installed is cleared: if another append chained on in the
|
|
250
|
+
// meantime the map already points at that one, and deleting it would let the
|
|
251
|
+
// next line race the one still in flight.
|
|
252
|
+
tail.then(() => { if (appendTails.get(filePath) === tail) appendTails.delete(filePath); });
|
|
253
|
+
return tail;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* One line, one write(2). The loop exists only for the short-write case
|
|
258
|
+
* described above; on every filesystem that does not do that it runs once.
|
|
259
|
+
*
|
|
260
|
+
* `position` is left null on purpose. That is what makes libuv issue write(2)
|
|
261
|
+
* rather than pwrite(2), and pwrite is the version that does not honour
|
|
262
|
+
* O_APPEND on every platform — it would write at an offset computed before the
|
|
263
|
+
* other writer moved the end of the file.
|
|
264
|
+
*/
|
|
265
|
+
async function writeWholeLine(filePath, line) {
|
|
266
|
+
// Encoded up front so the short-write loop can count bytes rather than UTF-16
|
|
267
|
+
// units. A multi-byte character split across two chunks would otherwise be
|
|
268
|
+
// resumed mid-sequence and the line would be mojibake even without a race.
|
|
269
|
+
const buf = Buffer.from(line, "utf8");
|
|
270
|
+
const handle = await open(filePath, "a");
|
|
271
|
+
try {
|
|
272
|
+
let written = 0;
|
|
273
|
+
while (written < buf.byteLength) {
|
|
274
|
+
const { bytesWritten } = await handle.write(buf, written, buf.byteLength - written, null);
|
|
275
|
+
// A write that reports no progress would spin this loop forever inside a
|
|
276
|
+
// promise nobody is watching. Give up on the line instead.
|
|
277
|
+
if (bytesWritten <= 0) throw new Error(`append made no progress at ${written}/${buf.byteLength} bytes`);
|
|
278
|
+
written += bytesWritten;
|
|
279
|
+
}
|
|
280
|
+
} finally {
|
|
281
|
+
await handle.close();
|
|
282
|
+
}
|
|
283
|
+
}
|