agent-dag 3.22.1 → 3.22.3
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 +6 -477
- package/package.json +14 -48
- package/shim.js +107 -0
- package/LICENSE +0 -661
- package/LICENSING.md +0 -82
- package/THIRD_PARTY_NOTICES.md +0 -395
- package/bin/agent-dag.js +0 -626
- package/bin/deck.js +0 -1805
- package/dist/web/assets/index-CJYsv0lr.css +0 -1
- package/dist/web/assets/index-Ifm23DDC.js +0 -270
- package/dist/web/index.html +0 -49
- package/hook/hook.js +0 -542
- package/release-notes.json +0 -398
- package/src/server/activity.mjs +0 -52
- package/src/server/agent-activity.mjs +0 -522
- package/src/server/args.mjs +0 -183
- package/src/server/auto-update.mjs +0 -79
- package/src/server/block-notify.mjs +0 -173
- package/src/server/boot-deadline.mjs +0 -127
- package/src/server/brand.mjs +0 -16
- package/src/server/browser-history.mjs +0 -497
- package/src/server/browser-presence.mjs +0 -211
- package/src/server/browser-profiles.mjs +0 -279
- package/src/server/browser-react.mjs +0 -284
- package/src/server/browser-watch-store.mjs +0 -350
- package/src/server/browser-watch.mjs +0 -905
- package/src/server/ccusage.mjs +0 -1168
- package/src/server/claude-accounts.mjs +0 -951
- package/src/server/claude-dir.mjs +0 -213
- package/src/server/codex-auth.mjs +0 -388
- package/src/server/codex-dir.mjs +0 -171
- package/src/server/codex-quota.mjs +0 -449
- package/src/server/codex-usage.mjs +0 -512
- package/src/server/cswap-admin.mjs +0 -1562
- package/src/server/cswap-auto.mjs +0 -658
- package/src/server/cswap-install.mjs +0 -641
- package/src/server/deck-home.mjs +0 -243
- package/src/server/deck-prefs.mjs +0 -301
- package/src/server/deck-probe.mjs +0 -111
- package/src/server/detach.mjs +0 -244
- package/src/server/exec.mjs +0 -996
- package/src/server/global-install.mjs +0 -67
- package/src/server/hwmonitor.mjs +0 -56
- package/src/server/index.mjs +0 -6043
- package/src/server/installer.mjs +0 -912
- package/src/server/invoked-as.mjs +0 -144
- package/src/server/lan-about.mjs +0 -119
- package/src/server/lan-engine.mjs +0 -952
- package/src/server/lan-reach.mjs +0 -256
- package/src/server/lan-socket.mjs +0 -682
- package/src/server/lan-sync.mjs +0 -941
- package/src/server/lhm-parse.mjs +0 -91
- package/src/server/log-tail.mjs +0 -139
- package/src/server/log-writer.mjs +0 -322
- package/src/server/login-service.mjs +0 -473
- package/src/server/macmon.mjs +0 -310
- package/src/server/npx.mjs +0 -264
- package/src/server/open-url.mjs +0 -242
- package/src/server/presence.mjs +0 -40
- package/src/server/quota.mjs +0 -792
- package/src/server/relay-guard.mjs +0 -507
- package/src/server/reset-label.mjs +0 -78
- package/src/server/retire-sound-hook.mjs +0 -349
- package/src/server/running-deck.mjs +0 -234
- package/src/server/self-update.mjs +0 -1380
- package/src/server/stop-deck.mjs +0 -171
- package/src/server/supervisor.mjs +0 -392
- package/src/server/system-metrics.mjs +0 -1825
- package/src/server/term.mjs +0 -686
- package/src/server/uv-bootstrap.mjs +0 -337
package/src/server/lhm-parse.mjs
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
// Reading LibreHardwareMonitor's sensor tree, out of the shape its own source
|
|
2
|
-
// produces rather than out of an example.
|
|
3
|
-
//
|
|
4
|
-
// `GenerateJsonForNode` in HttpServer.cs builds every node as
|
|
5
|
-
//
|
|
6
|
-
// { id, Text, Min, Value, Max }
|
|
7
|
-
//
|
|
8
|
-
// and a SENSOR node adds
|
|
9
|
-
//
|
|
10
|
-
// SensorId : "/intelcpu/0/temperature/0" the stable identifier
|
|
11
|
-
// Type : "Temperature" the SensorType enum, as text
|
|
12
|
-
// Value : "52.0 °C" formatted for a human
|
|
13
|
-
// RawValue : 52.0 the number, unformatted
|
|
14
|
-
//
|
|
15
|
-
// Two of those decide this file.
|
|
16
|
-
//
|
|
17
|
-
// RAWVALUE, NEVER VALUE. `Value` is formatted with the machine's culture, so on
|
|
18
|
-
// a German or Russian Windows it reads "52,0 °C" — a comma — and every naive
|
|
19
|
-
// parse of it either throws away the decimal or produces 520. `RawValue` is the
|
|
20
|
-
// number itself. This is the same trap the deck already hit once with `ps`
|
|
21
|
-
// output and fixed with LC_NUMERIC.
|
|
22
|
-
//
|
|
23
|
-
// AND RAWVALUE CAN BE THE STRING "NaN". The server serialises with
|
|
24
|
-
// JsonNumberHandling.AllowNamedFloatingPointLiterals, which writes a NaN as a
|
|
25
|
-
// quoted "NaN" rather than failing — so a sensor that has not read yet arrives
|
|
26
|
-
// as text where a number is expected. `Number("NaN")` is NaN and is rejected
|
|
27
|
-
// below, which is the right answer, but it is rejected on purpose rather than
|
|
28
|
-
// by luck.
|
|
29
|
-
//
|
|
30
|
-
// TYPE, NEVER THE NAME. `Type` comes from an enum and is the same word on every
|
|
31
|
-
// machine in every language; the `Text` beside it is a display name that
|
|
32
|
-
// differs between vendors and driver versions.
|
|
33
|
-
|
|
34
|
-
/** A plausible temperature. The same floor the rest of the thermal code uses:
|
|
35
|
-
* 0 is a sensor that has not read, and nothing above this is a temperature. */
|
|
36
|
-
const plausible = (c) => Number.isFinite(c) && c > 0 && c < 150;
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Every temperature sensor in the tree, flattened.
|
|
40
|
-
*
|
|
41
|
-
* Exported for its own test, and because "what did that machine actually
|
|
42
|
-
* publish" is the question anybody debugging this will have first.
|
|
43
|
-
*/
|
|
44
|
-
export function flattenSensors(root) {
|
|
45
|
-
const out = [];
|
|
46
|
-
const walk = (n, depth) => {
|
|
47
|
-
// The tree is four or five deep in practice — root, computer, hardware,
|
|
48
|
-
// type, sensor. The bound is against a cycle, which JSON cannot contain but
|
|
49
|
-
// a hand-written fixture can.
|
|
50
|
-
if (!n || typeof n !== "object" || depth > 12) return;
|
|
51
|
-
if (n.Type === "Temperature") {
|
|
52
|
-
out.push({
|
|
53
|
-
id: String(n.SensorId ?? ""),
|
|
54
|
-
name: String(n.Text ?? ""),
|
|
55
|
-
celsius: Number(n.RawValue),
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
if (Array.isArray(n.Children)) for (const c of n.Children) walk(c, depth + 1);
|
|
59
|
-
};
|
|
60
|
-
walk(root, 0);
|
|
61
|
-
return out;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Which sensor is the CPU, and which is the GPU.
|
|
66
|
-
*
|
|
67
|
-
* Chosen by SensorId rather than by name. The identifier is built from the
|
|
68
|
-
* hardware type — `/intelcpu/0/…`, `/amdcpu/0/…`, `/gpu-nvidia/0/…` — and is
|
|
69
|
-
* the same on every machine, while `Text` is a display name that reads "CPU
|
|
70
|
-
* Package" on one driver and "Core (Tctl/Tdie)" on another.
|
|
71
|
-
*
|
|
72
|
-
* The HOTTEST of a hardware's sensors is taken, not the first. A CPU publishes
|
|
73
|
-
* a package reading and one per core; the package is usually the highest and is
|
|
74
|
-
* what a person means by "the CPU temperature", and where a vendor publishes no
|
|
75
|
-
* package the hottest core is the honest stand-in. Taking the first would
|
|
76
|
-
* report core #1 while core #6 is thermal-throttling.
|
|
77
|
-
*/
|
|
78
|
-
export function readTemps(root) {
|
|
79
|
-
const sensors = flattenSensors(root).filter(s => plausible(s.celsius));
|
|
80
|
-
const hottest = (re) => {
|
|
81
|
-
const mine = sensors.filter(s => re.test(s.id));
|
|
82
|
-
if (!mine.length) return null;
|
|
83
|
-
return Math.round(Math.max(...mine.map(s => s.celsius)));
|
|
84
|
-
};
|
|
85
|
-
const out = {};
|
|
86
|
-
const cpu = hottest(/^\/(intel|amd)cpu\//i);
|
|
87
|
-
const gpu = hottest(/^\/gpu-/i);
|
|
88
|
-
if (cpu != null) out.cpu = cpu;
|
|
89
|
-
if (gpu != null) out.gpu = gpu;
|
|
90
|
-
return out;
|
|
91
|
-
}
|
package/src/server/log-tail.mjs
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
// Reading a log backwards, because only its end is ever kept.
|
|
2
|
-
//
|
|
3
|
-
// #742. The event log is replayed before the port opens, and the replay parsed
|
|
4
|
-
// every line in it from the beginning — 12,079 lines and 31 MB on the machine
|
|
5
|
-
// this was measured on, 690ms of JSON.parse, growing with every session until
|
|
6
|
-
// rotation cuts it at 50 MB. The ring it fills holds MAX_BUFFER = 2000 events.
|
|
7
|
-
// So roughly five sixths of that work was parsing events that were evicted by
|
|
8
|
-
// the ones parsed after them, on the critical path of a boot, every time.
|
|
9
|
-
//
|
|
10
|
-
// Reading from the end fixes the asymmetry rather than the constant: the loop
|
|
11
|
-
// stops as soon as the ring is full, so the cost becomes a property of the ring
|
|
12
|
-
// instead of a property of how long the user has been running the deck. When
|
|
13
|
-
// the ring cannot be filled — a young log, or a workspace-scoped deck whose
|
|
14
|
-
// events are a thin slice of a shared one — it reads all the way back to the
|
|
15
|
-
// start and costs exactly what the forward read did. There is no case where
|
|
16
|
-
// this is slower and no case where it sees less.
|
|
17
|
-
//
|
|
18
|
-
// Bytes, not characters. Lines are split on 0x0A and decoded one at a time,
|
|
19
|
-
// which is safe in UTF-8 because no byte of a multi-byte sequence can be 0x0A —
|
|
20
|
-
// a chunk boundary landing inside a three-byte character joins back together
|
|
21
|
-
// before anything is decoded. Decoding the chunk first and splitting the string
|
|
22
|
-
// is what would corrupt it, and it is the obvious way to write this.
|
|
23
|
-
import { open } from "node:fs/promises";
|
|
24
|
-
import { createReadStream } from "node:fs";
|
|
25
|
-
import { createInterface } from "node:readline";
|
|
26
|
-
|
|
27
|
-
/** One line out of a buffer, minus the carriage return a CRLF file leaves on
|
|
28
|
-
* the end of it. Sliced before decoding — see the note about 0x0A above. */
|
|
29
|
-
function line(buf, from, to) {
|
|
30
|
-
const end = to > from && buf[to - 1] === 0x0D ? to - 1 : to;
|
|
31
|
-
return buf.subarray(from, end).toString("utf8");
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** How much is read at a time. One megabyte holds about 400 events at the size
|
|
35
|
-
* this log's lines actually run to, so a full ring is usually five reads. */
|
|
36
|
-
export const CHUNK_BYTES = 1 << 20;
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* The file's lines, newest first, as an async iterable.
|
|
40
|
-
*
|
|
41
|
-
* Stops reading the moment the consumer stops asking — that is the whole point,
|
|
42
|
-
* and it is why this is a generator rather than a function returning an array.
|
|
43
|
-
* A `break` in the caller closes the handle through the generator's `finally`.
|
|
44
|
-
*
|
|
45
|
-
* Lines are yielded WITHOUT their newline, and the sequence is exactly the
|
|
46
|
-
* reverse of what `readline` yields reading the same file forwards — empty
|
|
47
|
-
* lines included, and a trailing newline at EOF is a line terminator rather
|
|
48
|
-
* than an empty line after it. A CRLF file reads the same as it does forwards,
|
|
49
|
-
* because the carriage return is stripped here too. That equivalence is the
|
|
50
|
-
* contract, and it is what the round-trip case in the test file checks against
|
|
51
|
-
* readline itself rather than against a hand-written expectation.
|
|
52
|
-
*
|
|
53
|
-
* The ONE difference: `readline` also breaks on a lone carriage return, for the
|
|
54
|
-
* sake of files written by software that predates OS X. Walking those backwards
|
|
55
|
-
* would mean scanning every byte of every chunk instead of asking Buffer for
|
|
56
|
-
* the next 0x0A, and nothing that writes a line into this deck's log has
|
|
57
|
-
* produced one since 2001. A file full of lone carriage returns reads here as a
|
|
58
|
-
* single very long line, which JSON.parse then declines — one skipped line,
|
|
59
|
-
* counted and reported, rather than a wrong answer.
|
|
60
|
-
*/
|
|
61
|
-
export async function* linesFromEnd(filePath, { chunkBytes = CHUNK_BYTES, openFile = open } = {}) {
|
|
62
|
-
const fh = await openFile(filePath, "r");
|
|
63
|
-
try {
|
|
64
|
-
const { size } = await fh.stat();
|
|
65
|
-
let pos = size;
|
|
66
|
-
// The bytes at the front of what has been read that have no newline before
|
|
67
|
-
// them yet: the first line of the chunk, which may continue into the chunk
|
|
68
|
-
// that comes before it. Carried, never yielded, until a newline turns up or
|
|
69
|
-
// the start of the file does.
|
|
70
|
-
let carry = Buffer.alloc(0);
|
|
71
|
-
// The newline that ends the last line is a terminator, not the start of an
|
|
72
|
-
// empty line after it. Trimmed once, on the chunk that holds EOF.
|
|
73
|
-
let atEof = true;
|
|
74
|
-
|
|
75
|
-
while (pos > 0) {
|
|
76
|
-
const len = Math.min(chunkBytes, pos);
|
|
77
|
-
pos -= len;
|
|
78
|
-
const buf = Buffer.alloc(len);
|
|
79
|
-
// Node reads short at the end of a file and at a pipe; a regular file
|
|
80
|
-
// opened for reading at a known offset does not, but the loop is written
|
|
81
|
-
// to survive it rather than to assume it.
|
|
82
|
-
let got = 0;
|
|
83
|
-
while (got < len) {
|
|
84
|
-
const { bytesRead } = await fh.read(buf, got, len - got, pos + got);
|
|
85
|
-
if (bytesRead === 0) break;
|
|
86
|
-
got += bytesRead;
|
|
87
|
-
}
|
|
88
|
-
const hay = carry.length ? Buffer.concat([buf.subarray(0, got), carry]) : buf.subarray(0, got);
|
|
89
|
-
|
|
90
|
-
// Walk the newlines from the end. `end` is one past the last byte of the
|
|
91
|
-
// line being cut; every cut is a complete line, because everything to its
|
|
92
|
-
// right has already been yielded.
|
|
93
|
-
let end = hay.length;
|
|
94
|
-
if (atEof && end > 0 && hay[end - 1] === 0x0A) end--;
|
|
95
|
-
atEof = false;
|
|
96
|
-
while (end > 0) {
|
|
97
|
-
const nl = hay.lastIndexOf(0x0A, end - 1);
|
|
98
|
-
if (nl === -1) break;
|
|
99
|
-
yield line(hay, nl + 1, end);
|
|
100
|
-
end = nl;
|
|
101
|
-
}
|
|
102
|
-
carry = hay.subarray(0, end);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Whatever is left has the start of the file in front of it, so it is a
|
|
106
|
-
// whole line — and an EMPTY one is still a line, which is why this is not
|
|
107
|
-
// conditional on `carry.length`. A file that opens with a newline opens
|
|
108
|
-
// with an empty line, and reading it forwards says so.
|
|
109
|
-
if (size > 0) yield line(carry, 0, carry.length);
|
|
110
|
-
} finally {
|
|
111
|
-
await fh.close().catch(() => {});
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* The same file, the ordinary way round.
|
|
117
|
-
*
|
|
118
|
-
* Here rather than at the one call site so the two readers sit together and a
|
|
119
|
-
* reader of either finds the other: replayLog picks between them per boot, on
|
|
120
|
-
* whether its scope predicate can be fed a log backwards, and a pair of
|
|
121
|
-
* functions in one file is what makes that choice legible.
|
|
122
|
-
*
|
|
123
|
-
* Streams, so a scoped deck on a 50 MB log holds one line at a time exactly as
|
|
124
|
-
* it did before any of this.
|
|
125
|
-
*/
|
|
126
|
-
export async function* linesFromStart(filePath) {
|
|
127
|
-
const input = createReadStream(filePath, { encoding: "utf8" });
|
|
128
|
-
const rl = createInterface({ input });
|
|
129
|
-
try {
|
|
130
|
-
for await (const line of rl) yield line;
|
|
131
|
-
} finally {
|
|
132
|
-
// Both, and the stream second. `rl.close()` stops the interface and leaves
|
|
133
|
-
// the descriptor under it open, which is fine for the one caller here
|
|
134
|
-
// because it reads to the end — and is a leak the day somebody breaks out
|
|
135
|
-
// of this loop the way the backwards reader is designed to be broken out of.
|
|
136
|
-
rl.close();
|
|
137
|
-
input.destroy();
|
|
138
|
-
}
|
|
139
|
-
}
|
|
@@ -1,322 +0,0 @@
|
|
|
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 { realpathSync } from "node:fs";
|
|
25
|
-
import { basename, dirname, join, resolve, win32, posix } from "node:path";
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Does this platform's filesystem treat two spellings that differ only in case
|
|
29
|
-
* as the same file? The platform is a parameter so both answers can be checked
|
|
30
|
-
* from either kind of machine.
|
|
31
|
-
*
|
|
32
|
-
* Windows always does, and macOS does by default (APFS and HFS+ are formatted
|
|
33
|
-
* case-insensitive unless the user deliberately chose otherwise). Linux does
|
|
34
|
-
* not, and folding case there would be a bug of its own: /srv/a/events.jsonl and
|
|
35
|
-
* /srv/A/events.jsonl are two real files, each of which needs a writer.
|
|
36
|
-
*/
|
|
37
|
-
/**
|
|
38
|
-
* The one spelling of an events log, so two decks pointed at one file land in
|
|
39
|
-
* one group (#793).
|
|
40
|
-
*
|
|
41
|
-
* `resolve` alone was what shipped, and it settles relative-vs-absolute and
|
|
42
|
-
* nothing else. The election below then only case-folds — so two spellings of
|
|
43
|
-
* one file read as two files, which is the exact thing `bin/deck.js`'s comment
|
|
44
|
-
* over this value says must not happen. On Windows it needs no odd user action:
|
|
45
|
-
* `claudeConfigDir()` derives from `homedir()`, and a shell whose `USERPROFILE`
|
|
46
|
-
* is 8.3-shortened yields a different default string than one with the long
|
|
47
|
-
* form. `subst` and mapped drives and junctions do it too, and on macOS so does
|
|
48
|
-
* `/tmp` against `/private/tmp`.
|
|
49
|
-
*
|
|
50
|
-
* What it cost was not merely a duplicate group. BOTH decks were then elected,
|
|
51
|
-
* so every hook event was appended twice — the duplicate-tools-after-restart
|
|
52
|
-
* symptom the election exists to end — and `logSharing()` compares the same
|
|
53
|
-
* string, so both answered `mine: true` and `POST /api/clear` truncated a file
|
|
54
|
-
* this deck does not own. That is the #698 history loss the ownership gate was
|
|
55
|
-
* added to prevent.
|
|
56
|
-
*
|
|
57
|
-
* THE DIRECTORY IS CANONICALISED EVEN WHEN THE FILE IS NOT THERE, which is the
|
|
58
|
-
* half a plain realpath misses. On a first run, or against a `--history` naming
|
|
59
|
-
* a file the deck will create, `realpath` throws ENOENT — and falling back to
|
|
60
|
-
* the resolved string would leave the two spellings different for exactly the
|
|
61
|
-
* run that creates the file. The parent exists (or is about to be created under
|
|
62
|
-
* one canonical name), so it is canonicalised and the basename rejoined.
|
|
63
|
-
*
|
|
64
|
-
* `canonicalWorkspace` in index.mjs is the same rule for the other path this
|
|
65
|
-
* deck publishes, with three comments naming 8.3 expansion as its reason. This
|
|
66
|
-
* is that rule reaching the value two lines away from it.
|
|
67
|
-
*/
|
|
68
|
-
export function canonicalLogPath(raw) {
|
|
69
|
-
if (typeof raw !== "string" || raw.trim() === "") return "";
|
|
70
|
-
const abs = resolve(raw);
|
|
71
|
-
try { return realpathSync.native(abs); } catch { /* not created yet */ }
|
|
72
|
-
try { return join(realpathSync.native(dirname(abs)), basename(abs)); } catch { return abs; }
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export const foldsCase = (platform = process.platform) =>
|
|
76
|
-
platform === "win32" || platform === "darwin";
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Of these decks, which ones write to disk? Returns the subset that should;
|
|
80
|
-
* every other one is expected to draw the event and keep no record of it.
|
|
81
|
-
*
|
|
82
|
-
* Decks are grouped by the log file each one names in its discovery record and
|
|
83
|
-
* one deck per group is elected. Grouping by the file rather than counting decks
|
|
84
|
-
* is what keeps the overrides honest: a deck run with `--history` sits alone in
|
|
85
|
-
* its own group and always writes, a deck run with `--no-persist` reports no
|
|
86
|
-
* file and can never be elected to write for one that does, and a deck too old
|
|
87
|
-
* to report either keeps the behaviour it had before this rule existed. Within a
|
|
88
|
-
* group the lowest port wins — a fixed rule, so the same deck holds the file for
|
|
89
|
-
* as long as it is up and the next one inherits it as soon as that deck is gone.
|
|
90
|
-
*
|
|
91
|
-
* Kept byte-for-byte equivalent to electWriters in hook/hook.js: the two decide
|
|
92
|
-
* for the same decks over the same records, and a disagreement between them
|
|
93
|
-
* means one log line written twice or none at all.
|
|
94
|
-
*/
|
|
95
|
-
export function electWriters(decks, platform = process.platform) {
|
|
96
|
-
const byLog = new Map();
|
|
97
|
-
for (const d of decks) {
|
|
98
|
-
const log = typeof d.persist === "string" ? d.persist : "";
|
|
99
|
-
// Two namespaces, so a deck with no log to share — and a deck too old to
|
|
100
|
-
// report one — is alone in its group and cannot collide with a real path.
|
|
101
|
-
const key = log
|
|
102
|
-
? `log:${foldsCase(platform) ? log.toLowerCase() : log}`
|
|
103
|
-
: `deck:${d.pid}:${d.port}`;
|
|
104
|
-
const held = byLog.get(key);
|
|
105
|
-
// Ports are unique among live decks; pid only breaks a tie a stale
|
|
106
|
-
// discovery file could invent, so the answer stays deterministic.
|
|
107
|
-
if (!held || d.port < held.port || (d.port === held.port && d.pid < held.pid)) {
|
|
108
|
-
byLog.set(key, d);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
return new Set(byLog.values());
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Would a deck scoped to `workspace` capture a rollout running in `cwd`? An
|
|
116
|
-
* empty workspace is unscoped and captures every session; a rollout that never
|
|
117
|
-
* said where it runs is inside no workspace, so only an unscoped deck draws it.
|
|
118
|
-
*
|
|
119
|
-
* This answers two questions with one function — whether THIS deck tails a
|
|
120
|
-
* rollout, and whether another deck tails it too — and that is only sound while
|
|
121
|
-
* the rule below is the rule every deck actually runs. Model another deck's
|
|
122
|
-
* capture with anything else and the election covers the wrong set: a deck that
|
|
123
|
-
* writes without being elected, or an elected deck that never opened the file.
|
|
124
|
-
*
|
|
125
|
-
* It is also the rule hook/hook.js runs for the sessions it delivers, under the
|
|
126
|
-
* name capturesSession — that script is copied out of the package and run
|
|
127
|
-
* standalone, so the two are written twice and pinned equal by a test walking
|
|
128
|
-
* one table of paths through both. They were not equal: case was folded here on
|
|
129
|
-
* every platform, so on Linux a deck scoped to /srv/proj captured Codex sessions
|
|
130
|
-
* from /srv/Proj and Claude sessions from neither. Those are two real
|
|
131
|
-
* directories there, and the hook's own comment says what folding them together
|
|
132
|
-
* costs — a deck handed the events of a tree it was not scoped to. So the fold
|
|
133
|
-
* is per-platform on both sides now, and `--workspace` means one thing.
|
|
134
|
-
*
|
|
135
|
-
* (The narrow window that opens: two decks on Linux whose workspaces differ only
|
|
136
|
-
* in case, one of them old enough to still fold, both containing one rollout's
|
|
137
|
-
* cwd. Each models the other as tailing the file; one of them is wrong, and the
|
|
138
|
-
* cost is a single log line written twice.)
|
|
139
|
-
*
|
|
140
|
-
* The platform is a parameter, following the hook's cwdInWorkspace and
|
|
141
|
-
* spawnSpec in src/server/exec.mjs, so the Windows separator is testable from a
|
|
142
|
-
* POSIX machine.
|
|
143
|
-
*/
|
|
144
|
-
export function codexCwdInWorkspace(cwd, workspace, platform = process.platform) {
|
|
145
|
-
if (!workspace || typeof workspace !== "string") return true;
|
|
146
|
-
if (!cwd || typeof cwd !== "string") return false;
|
|
147
|
-
const p = platform === "win32" ? win32 : posix;
|
|
148
|
-
const fold = s => (foldsCase(platform) ? s.toLowerCase() : s);
|
|
149
|
-
const a = fold(p.resolve(cwd));
|
|
150
|
-
const b = fold(p.resolve(workspace));
|
|
151
|
-
if (a === b) return true;
|
|
152
|
-
// A root ("C:\", "/") already ends in the separator; appending a second one
|
|
153
|
-
// would match nothing.
|
|
154
|
-
return a.startsWith(b.endsWith(p.sep) ? b : b + p.sep);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Does this deck append a rollout's events to its log, or is another deck doing
|
|
159
|
-
* it? `decks` is every deck registered right now, `pid` identifies this one
|
|
160
|
-
* among them, and `cwd` is the workspace the rollout is running in.
|
|
161
|
-
*
|
|
162
|
-
* The group is every deck that tails this same rollout: each deck decides for
|
|
163
|
-
* itself, so all of them whose workspace contains the cwd read the file and all
|
|
164
|
-
* of them would write it. The hook builds the same group the same way for the
|
|
165
|
-
* events it delivers — it used to narrow them to the longest workspace match
|
|
166
|
-
* first, which is the asymmetry the predicate above describes the end of. A deck
|
|
167
|
-
* started with `--no-codex` tails nothing and is left out; electing it would
|
|
168
|
-
* mean the rollout's events reach no log at all. A deck too old to say either
|
|
169
|
-
* way is assumed to be tailing, which is what it was doing before this field
|
|
170
|
-
* existed.
|
|
171
|
-
*/
|
|
172
|
-
export function writesCodexLog({ decks, pid, cwd, platform = process.platform }) {
|
|
173
|
-
const live = Array.isArray(decks) ? decks : [];
|
|
174
|
-
const self = live.find(d => d && d.pid === pid) ?? null;
|
|
175
|
-
// No record of our own on disk — the window before the first heartbeat writes
|
|
176
|
-
// it, or a deck that cannot write one at all. Nobody can elect us and we
|
|
177
|
-
// cannot see who else is here, so keep what this deck did before the election
|
|
178
|
-
// existed and write. A line written twice is recoverable; a deck that quietly
|
|
179
|
-
// stops recording anything is not.
|
|
180
|
-
if (!self || typeof self.persist !== "string" || self.persist === "") return true;
|
|
181
|
-
|
|
182
|
-
const group = [self];
|
|
183
|
-
for (const d of live) {
|
|
184
|
-
if (!d || d.pid === self.pid) continue;
|
|
185
|
-
if (d.codex === false) continue;
|
|
186
|
-
if (!codexCwdInWorkspace(cwd, d.workspace ?? "", platform)) continue;
|
|
187
|
-
group.push(d);
|
|
188
|
-
}
|
|
189
|
-
return electWriters(group, platform).has(self);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// ─── Appending one whole line ─────────────────────────────────────────────
|
|
193
|
-
//
|
|
194
|
-
// The election above decides WHICH deck writes a line. This decides HOW, and
|
|
195
|
-
// the two are answers to the same question: the log is one file that several
|
|
196
|
-
// processes append to, so a line that arrives in pieces is a line another
|
|
197
|
-
// writer can land inside.
|
|
198
|
-
//
|
|
199
|
-
// What was wrong. events.jsonl was appended with `fsPromises.appendFile`, which
|
|
200
|
-
// is not one write(2). Node's writeFileHandle loops over the payload in chunks
|
|
201
|
-
// of kWriteFileMaxChunkSize — 512 KiB — awaiting each one, so any line longer
|
|
202
|
-
// than 524288 bytes became two or more separate appends with the event loop
|
|
203
|
-
// free in between. Measured here, on Node 26 / macOS 15 / APFS, by watching the
|
|
204
|
-
// file grow while one 5 MiB appendFile was in flight: it arrived in exactly ten
|
|
205
|
-
// steps of 524288 bytes. A single handle.write of the same payload arrived in
|
|
206
|
-
// one step of 5242880.
|
|
207
|
-
//
|
|
208
|
-
// That is reachable in ordinary use. `POST /api/event` accepts a body up to
|
|
209
|
-
// 5,000,000 characters, and a PostToolUse carrying a large Read or Bash
|
|
210
|
-
// response is routinely a good fraction of that. The event that gets spliced
|
|
211
|
-
// into the middle is nearly always this deck's own: pushEvent kicks off
|
|
212
|
-
// maybeResolveUsage / maybeResolveModel / maybeResolveContext on the same call,
|
|
213
|
-
// and each of those pushes its own event a moment later. Reproduced at 8/8 runs
|
|
214
|
-
// with one 1 MiB line and eight small ones issued in one tick, and 4/4 runs
|
|
215
|
-
// across two processes appending 3 MiB lines to one file.
|
|
216
|
-
//
|
|
217
|
-
// The damage is silent and doubled: the oversized event is torn into two
|
|
218
|
-
// unparseable halves AND the small event that landed between them is swallowed
|
|
219
|
-
// inside one of those halves, so a boot replay loses both.
|
|
220
|
-
//
|
|
221
|
-
// What this does instead: exactly one write(2) per line, on a descriptor opened
|
|
222
|
-
// O_APPEND. That is the primitive the atomicity rests on, so it is worth
|
|
223
|
-
// stating exactly what each platform promises.
|
|
224
|
-
//
|
|
225
|
-
// Linux — a write to a regular file holds the inode's i_rwsem for the whole
|
|
226
|
-
// call, so one write(2) is atomic against every other writer regardless of
|
|
227
|
-
// size, up to MAX_RW_COUNT (~2 GiB) after which the call returns short.
|
|
228
|
-
// O_APPEND additionally makes the seek-to-end and the write one step, which
|
|
229
|
-
// is what stops two processes overwriting each other's tail.
|
|
230
|
-
//
|
|
231
|
-
// macOS — measured, not assumed, because the guarantee is not written down as
|
|
232
|
-
// plainly: single writes of 1 MiB and 5 MiB in-process, and 3 MiB from two
|
|
233
|
-
// processes at once, produced no torn line in any run, while appendFile of
|
|
234
|
-
// the same payloads tore in every run.
|
|
235
|
-
//
|
|
236
|
-
// Windows — libuv issues the append as a WriteFile at the documented
|
|
237
|
-
// end-of-file offset on a handle opened FILE_APPEND_DATA, which NTFS serialises
|
|
238
|
-
// per call. This is the platform where the Codex rollout watcher is the only
|
|
239
|
-
// capture path there is, so it is also the platform with the most decks
|
|
240
|
-
// sharing one log.
|
|
241
|
-
//
|
|
242
|
-
// Where the guarantee does NOT hold: any filesystem that can return a short
|
|
243
|
-
// write — NFS and some network/FUSE mounts under memory pressure, and any
|
|
244
|
-
// platform once the payload passes its per-call ceiling. The loop below then
|
|
245
|
-
// issues a second write for the remainder, and another writer can land between
|
|
246
|
-
// them. Nothing available in userland fixes that, so the answer is the reader:
|
|
247
|
-
// replayLog skips a line it cannot parse, counts it, and says so, rather than
|
|
248
|
-
// stopping at it. A torn line costs one event, not the rest of the file.
|
|
249
|
-
//
|
|
250
|
-
// Why not cap or drop oversized events instead. The 5 MB ceiling is already the
|
|
251
|
-
// cap, and it is enforced where it belongs — at ingest, with a 413 the poster
|
|
252
|
-
// can see. Refusing to persist an event the deck is happily drawing would make
|
|
253
|
-
// the canvas and its own replay disagree, which is the bug this fixes wearing a
|
|
254
|
-
// different hat.
|
|
255
|
-
//
|
|
256
|
-
// Why the file is opened per line rather than kept open. maybeRotatePersistFile
|
|
257
|
-
// renames events.jsonl to events.jsonl.1 at 50 MB and `/api/clear` truncates it
|
|
258
|
-
// to zero; a descriptor held across either would go on filling the file nobody
|
|
259
|
-
// reads any more. Opening per line is also exactly the syscall count appendFile
|
|
260
|
-
// already paid — open, write, close — minus the extra writes.
|
|
261
|
-
const appendTails = new Map();
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Append one already-serialized line to the shared log, whole.
|
|
265
|
-
*
|
|
266
|
-
* Appends are serialized per file behind a promise chain. That is not what
|
|
267
|
-
* makes them atomic — the single write(2) below is — but it keeps the order the
|
|
268
|
-
* lines land in equal to the order pushEvent produced them, which is the order
|
|
269
|
-
* a replay reads them back in, and it keeps this process to one open descriptor
|
|
270
|
-
* on the log no matter how many events arrive in one tick.
|
|
271
|
-
*
|
|
272
|
-
* Never rejects. This is called fire-and-forget from the hottest path in the
|
|
273
|
-
* process, and a rejection nobody awaits is a dead deck; a line that could not
|
|
274
|
-
* be written is a line lost, which the caller could not have done anything
|
|
275
|
-
* about anyway. The chain deliberately continues past a failure — one ENOSPC
|
|
276
|
-
* must not stop every later event from being recorded once space is back.
|
|
277
|
-
*
|
|
278
|
-
* @param {string} filePath absolute path to the log
|
|
279
|
-
* @param {string} line the line to append, INCLUDING its trailing newline
|
|
280
|
-
*/
|
|
281
|
-
export function appendLogLine(filePath, line) {
|
|
282
|
-
const tail = (appendTails.get(filePath) ?? Promise.resolve())
|
|
283
|
-
.then(() => writeWholeLine(filePath, line))
|
|
284
|
-
.catch(() => {});
|
|
285
|
-
appendTails.set(filePath, tail);
|
|
286
|
-
// Drop the chain once it drains, so a process that writes to several logs
|
|
287
|
-
// over its life does not hold a promise per path it has finished with. Only
|
|
288
|
-
// the tail we just installed is cleared: if another append chained on in the
|
|
289
|
-
// meantime the map already points at that one, and deleting it would let the
|
|
290
|
-
// next line race the one still in flight.
|
|
291
|
-
tail.then(() => { if (appendTails.get(filePath) === tail) appendTails.delete(filePath); });
|
|
292
|
-
return tail;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* One line, one write(2). The loop exists only for the short-write case
|
|
297
|
-
* described above; on every filesystem that does not do that it runs once.
|
|
298
|
-
*
|
|
299
|
-
* `position` is left null on purpose. That is what makes libuv issue write(2)
|
|
300
|
-
* rather than pwrite(2), and pwrite is the version that does not honour
|
|
301
|
-
* O_APPEND on every platform — it would write at an offset computed before the
|
|
302
|
-
* other writer moved the end of the file.
|
|
303
|
-
*/
|
|
304
|
-
async function writeWholeLine(filePath, line) {
|
|
305
|
-
// Encoded up front so the short-write loop can count bytes rather than UTF-16
|
|
306
|
-
// units. A multi-byte character split across two chunks would otherwise be
|
|
307
|
-
// resumed mid-sequence and the line would be mojibake even without a race.
|
|
308
|
-
const buf = Buffer.from(line, "utf8");
|
|
309
|
-
const handle = await open(filePath, "a");
|
|
310
|
-
try {
|
|
311
|
-
let written = 0;
|
|
312
|
-
while (written < buf.byteLength) {
|
|
313
|
-
const { bytesWritten } = await handle.write(buf, written, buf.byteLength - written, null);
|
|
314
|
-
// A write that reports no progress would spin this loop forever inside a
|
|
315
|
-
// promise nobody is watching. Give up on the line instead.
|
|
316
|
-
if (bytesWritten <= 0) throw new Error(`append made no progress at ${written}/${buf.byteLength} bytes`);
|
|
317
|
-
written += bytesWritten;
|
|
318
|
-
}
|
|
319
|
-
} finally {
|
|
320
|
-
await handle.close();
|
|
321
|
-
}
|
|
322
|
-
}
|