agent-dag 1.48.0 → 3.1.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.
@@ -18,6 +18,7 @@
18
18
  // goes through one mutex.
19
19
  import { AsyncLocalStorage } from "node:async_hooks";
20
20
  import { existsSync } from "node:fs";
21
+ import { brotliCompressSync, brotliDecompressSync, constants as zlibConstants } from "node:zlib";
21
22
  import { readFile } from "node:fs/promises";
22
23
  import { homedir } from "node:os";
23
24
  import { join } from "node:path";
@@ -39,7 +40,46 @@ const CODE_VERDICT_MS = 60_000;
39
40
  export const SHARE_TTL_MS = 10 * 60_000;
40
41
  // How many accounts one bundle may carry. See shareAccounts.
41
42
  const MAX_SHARE_ACCOUNTS = 50;
42
- const SHARE_PREFIX = "ccdeck1:";
43
+
44
+ /**
45
+ * The prefix a share is written with, and the one it used to be written with.
46
+ *
47
+ * `ccdeck1:` is base64 of the envelope JSON. `ccdeck2:` is base64 of the same
48
+ * JSON compressed, and the difference is not cosmetic — it was reported from
49
+ * the panel as "the text is very large", and on a real store it is:
50
+ *
51
+ * 1 account 2200 characters -> 1024
52
+ * 3 accounts 6168 characters -> 1816
53
+ *
54
+ * Because what a bundle mostly contains is not credentials. An account's two
55
+ * OAuth tokens are 216 characters between them; the envelope around them is
56
+ * two thousand, and every one of its key names — `refreshTokenExpiresAt`,
57
+ * `organizationRateLimitTier`, `claudeCodeTrialDurationDays` — repeats
58
+ * verbatim for every account added. That is exactly what a compressor is for,
59
+ * which is why the saving grows with the number of accounts rather than
60
+ * shrinking.
61
+ *
62
+ * Brotli rather than gzip: 10% smaller here, in node:zlib since v11, no
63
+ * dependency either way.
64
+ *
65
+ * BOTH prefixes are read, so a blob copied before this change still imports.
66
+ * Only `ccdeck2:` is written, which does mean a deck older than this cannot
67
+ * read a new share — it will say the text does not look like a shared account.
68
+ * That cost is paid once and it is smallest now: the feature shipped in
69
+ * 1.48.0 and the format has had no time to spread.
70
+ */
71
+ const SHARE_PREFIX = "ccdeck2:";
72
+ const SHARE_PREFIX_V1 = "ccdeck1:";
73
+
74
+ /**
75
+ * The most an imported blob may decompress to.
76
+ *
77
+ * A few hundred bytes of brotli can name gigabytes of output, and this input
78
+ * arrives by paste from wherever the user found it. 50 accounts at ~1.6 KB of
79
+ * envelope each is under 100 KB, so 2 MB is twenty times the largest bundle
80
+ * this deck will ever produce and still nothing to allocate by accident.
81
+ */
82
+ const SHARE_MAX_BYTES = 2 << 20;
43
83
 
44
84
  // ── serialization ────────────────────────────────────────────────────────────
45
85
 
@@ -724,16 +764,37 @@ async function restoreActive(num) {
724
764
  */
725
765
  export function wrapShare(payload, now = Date.now(), ttlMs = SHARE_TTL_MS) {
726
766
  const body = JSON.stringify({ v: 1, exp: now + ttlMs, payload });
727
- return SHARE_PREFIX + Buffer.from(body, "utf8").toString("base64");
767
+ const packed = brotliCompressSync(Buffer.from(body, "utf8"), {
768
+ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11 },
769
+ });
770
+ return SHARE_PREFIX + packed.toString("base64");
728
771
  }
729
772
 
730
- /** The inverse. Returns `{ok:true, payload}` or `{ok:false, reason}`. */
773
+ /**
774
+ * The inverse, for either prefix. Returns `{ok:true, payload}` or
775
+ * `{ok:false, reason}`.
776
+ *
777
+ * `v` inside the envelope is unchanged at 1 and deliberately so: it versions
778
+ * the SHAPE of the envelope, and that shape did not change. The prefix versions
779
+ * the encoding. Folding the two would have made an old blob unreadable for no
780
+ * reason, since its contents are exactly what this still expects.
781
+ */
731
782
  export function unwrapShare(blob, now = Date.now()) {
732
783
  const text = String(blob ?? "").trim();
733
- if (!text.startsWith(SHARE_PREFIX)) return { ok: false, reason: "not_a_share" };
784
+ const v2 = text.startsWith(SHARE_PREFIX);
785
+ if (!v2 && !text.startsWith(SHARE_PREFIX_V1)) return { ok: false, reason: "not_a_share" };
734
786
  let env;
735
787
  try {
736
- env = JSON.parse(Buffer.from(text.slice(SHARE_PREFIX.length), "base64").toString("utf8"));
788
+ // Sliced by the prefix that actually matched. The two are the same length
789
+ // today and writing it this way is what keeps that from being load-bearing.
790
+ const bytes = Buffer.from(text.slice((v2 ? SHARE_PREFIX : SHARE_PREFIX_V1).length), "base64");
791
+ // maxOutputLength is the whole reason a bounded decompress is safe to point
792
+ // at pasted text; without it a short blob can name an allocation that ends
793
+ // the process.
794
+ const body = v2
795
+ ? brotliDecompressSync(bytes, { maxOutputLength: SHARE_MAX_BYTES })
796
+ : bytes;
797
+ env = JSON.parse(body.toString("utf8"));
737
798
  } catch {
738
799
  return { ok: false, reason: "corrupt" };
739
800
  }
@@ -266,15 +266,50 @@ export function cswapOwner(bin, platform = process.platform, env = process.env,
266
266
  }
267
267
 
268
268
  let _bin = null;
269
+
270
+ /**
271
+ * What the probe that resolved `_bin` printed, and when.
272
+ *
273
+ * #742: resolving the binary means running `cswap --version`, and reading the
274
+ * version means running `cswap --version`. Those were two separate spawns of a
275
+ * Python CLI a moment apart, and on this Mac each one costs between one and two
276
+ * and a half seconds — which made a probe of an ALREADY INSTALLED claude-swap
277
+ * the single largest thing in an ordinary boot.
278
+ *
279
+ * The second spawn is what this retires, and only the second: anything asking
280
+ * later gets a fresh answer, because a version read once at boot is not a
281
+ * version for the life of a deck that runs for days and may upgrade the tool
282
+ * underneath itself. Five seconds is long enough to cover cswapBin handing
283
+ * straight over to cswapVersion and far too short to be a cache.
284
+ */
285
+ let _probe = null;
286
+ const PROBE_FRESH_MS = 5_000;
287
+
288
+ /** "claude-swap 0.25.0" → "0.25.0", and "installed" for a copy that answered
289
+ * without a number in it. Shared so the memo and the spawn cannot disagree. */
290
+ function versionIn(r) {
291
+ const m = (r.stdout || r.stderr).trim().match(/(\d+\.\d+\.\d+\S*)/);
292
+ return m ? m[1] : "installed";
293
+ }
294
+
269
295
  export async function cswapBin() {
270
296
  // An explicit path wins over everything and is never cached away — someone
271
297
  // debugging a bad resolution needs it to take effect immediately.
272
298
  if (process.env.AGENTS_DECK_CSWAP) return process.env.AGENTS_DECK_CSWAP;
273
299
  if (_bin) return _bin;
274
- if ((await run("cswap", ["--version"], { timeout: 8_000 })).ok) return (_bin = "cswap");
300
+
301
+ const take = (spelling, r) => {
302
+ _probe = { version: versionIn(r), at: Date.now() };
303
+ return (_bin = spelling);
304
+ };
305
+
306
+ const bare = await run("cswap", ["--version"], { timeout: 8_000 });
307
+ if (bare.ok) return take("cswap", bare);
275
308
 
276
309
  for (const c of cswapCandidates()) {
277
- if (existsSync(c) && (await run(c, ["--version"], { timeout: 8_000 })).ok) return (_bin = c);
310
+ if (!existsSync(c)) continue;
311
+ const r = await run(c, ["--version"], { timeout: 8_000 });
312
+ if (r.ok) return take(c, r);
278
313
  }
279
314
  return "cswap"; // not found; leave the bare name so errors read sensibly
280
315
  }
@@ -299,15 +334,18 @@ export async function cswapBin() {
299
334
  * ensureCswap below, whose return value says nothing about which binary the
300
335
  * following twenty account operations will be sent to. See cswap-bin-memo.test.ts.
301
336
  */
302
- export function resetCswapBin() { _bin = null; }
337
+ export function resetCswapBin() { _bin = null; _probe = null; }
303
338
 
304
339
  /** Installed version string, or null when cswap cannot be found. */
305
340
  export async function cswapVersion() {
306
- const r = await run(await cswapBin(), ["--version"]);
341
+ const bin = await cswapBin();
342
+ // The call above may have just asked this very question — see _probe. Nothing
343
+ // is remembered past PROBE_FRESH_MS, so this is the second half of one
344
+ // lookup rather than a cache of the answer.
345
+ if (_probe && Date.now() - _probe.at < PROBE_FRESH_MS) return _probe.version;
346
+ const r = await run(bin, ["--version"]);
307
347
  if (!r.ok) return null;
308
- // "claude-swap 0.25.0" → "0.25.0"
309
- const m = (r.stdout || r.stderr).trim().match(/(\d+\.\d+\.\d+\S*)/);
310
- return m ? m[1] : "installed";
348
+ return versionIn(r);
311
349
  }
312
350
 
313
351
  /**
@@ -536,8 +574,19 @@ async function findUpgrader(owner) {
536
574
  *
537
575
  * Returns a small status the CLI prints verbatim:
538
576
  * { state: "present" | "installed" | "upgrading" | "skipped" | "unavailable", ... }
577
+ *
578
+ * `onInstalling` is called at most once, at the moment this stops asking
579
+ * questions and commits to an install — which is the moment the answer stops
580
+ * being seconds away and starts being minutes away. #742: the boot used to have
581
+ * no way to tell those two apart, so it waited out its whole deadline on a
582
+ * machine whose answer was already decided. Everything before that call is
583
+ * probes; everything after it is a uv download and an environment build.
584
+ *
585
+ * Deliberately a callback and not a state on the return value: what the caller
586
+ * needs is the news, not the outcome, and the outcome is the thing that takes
587
+ * three minutes to arrive.
539
588
  */
540
- export async function ensureCswap() {
589
+ export async function ensureCswap({ onInstalling = null } = {}) {
541
590
  if (process.env.AGENTS_DECK_NO_INSTALL === "1") {
542
591
  const version = await cswapVersion();
543
592
  return version ? { state: "present", version } : { state: "skipped" };
@@ -565,6 +614,10 @@ export async function ensureCswap() {
565
614
  return { state: "present", version: existing };
566
615
  }
567
616
 
617
+ // Said before the install starts rather than after it, because after it is
618
+ // three minutes later and the whole point is not to be waited for.
619
+ try { onInstalling?.(); } catch { /* a caller's notification is not our problem */ }
620
+
568
621
  const result = await installCswap();
569
622
  if (!result.ok) return { state: "unavailable", ...result };
570
623
  // Something was just installed, so any path resolved before it is a guess made
@@ -5,12 +5,11 @@
5
5
  // describes. Nothing else in this file talks to anything but 127.0.0.1 clients.
6
6
  import { createServer, request as httpRequest } from "node:http";
7
7
  import { readFile, stat, mkdir, open, truncate, readdir, unlink } from "node:fs/promises";
8
- import { createReadStream, existsSync, readFileSync, realpath as realpathCb, realpathSync } from "node:fs";
8
+ import { existsSync, readFileSync, realpath as realpathCb, realpathSync } from "node:fs";
9
9
  import { extname, join, resolve, sep, dirname as pdirname } from "node:path";
10
10
  import { homedir } from "node:os";
11
11
  import { fileURLToPath, pathToFileURL } from "node:url";
12
12
  import { dirname } from "node:path";
13
- import { createInterface } from "node:readline";
14
13
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
15
14
  import { promisify } from "node:util";
16
15
  import { claudeConfigDir } from "./claude-dir.mjs";
@@ -18,7 +17,8 @@ import { CODEX_HOME, CODEX_SESSIONS_DIR, STOP, walkRolloutDays } from "./codex-d
18
17
  import { PRODUCT } from "./brand.mjs";
19
18
  import { invokedName, renameNotice } from "./invoked-as.mjs";
20
19
  import { appendLogLine, codexCwdInWorkspace, electWriters, foldsCase, writesCodexLog } from "./log-writer.mjs";
21
- import { readProcesses, startSystemMetrics, systemSnapshot } from "./system-metrics.mjs";
20
+ import { historySnapshot, readProcesses, startSystemMetrics, systemSnapshot } from "./system-metrics.mjs";
21
+ import { linesFromEnd, linesFromStart } from "./log-tail.mjs";
22
22
 
23
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
24
24
  const PKG_ROOT = resolve(__dirname, "..", "..");
@@ -3360,9 +3360,34 @@ function pushEvent(raw, source, opts = {}) {
3360
3360
  * two replays.
3361
3361
  */
3362
3362
  export function replayScope(workspace, platform = process.platform) {
3363
- if (!workspace || typeof workspace !== "string") return () => true;
3363
+ if (!workspace || typeof workspace !== "string") {
3364
+ const all = () => true;
3365
+ // Every caller gets the same answer for the same payload, forever. That is
3366
+ // what lets replayLog read the log from its end — see `orderDependent` on
3367
+ // the scoped predicate below for the half that cannot.
3368
+ all.orderDependent = false;
3369
+ return all;
3370
+ }
3364
3371
  const bySession = new Map();
3365
- return function admits(payload) {
3372
+ /**
3373
+ * THE ANSWER DEPENDS ON WHAT CAME BEFORE, and #742 is why that is now stated
3374
+ * out loud rather than left as an implementation detail.
3375
+ *
3376
+ * The synthetic enrichment events — ModelObserved, UsageObserved,
3377
+ * SessionNamed, ContextObserved — carry a session_id and no cwd, so the only
3378
+ * thing that can decide them is a cwd-bearing event for the same session,
3379
+ * and that event is EARLIER in the log. Fed the log backwards, this predicate
3380
+ * meets the enrichment first, has nothing in `bySession`, and drops it: the
3381
+ * session lands on the canvas with no model and no tokens.
3382
+ *
3383
+ * So the flag is not advice. replayLog reads it, and reads the file forwards
3384
+ * whenever it is set. Making a scoped replay cheap needs an index of where a
3385
+ * workspace's lines are, which is a different change from this one.
3386
+ */
3387
+ admits.orderDependent = true;
3388
+ return admits;
3389
+
3390
+ function admits(payload) {
3366
3391
  if (!payload || typeof payload !== "object") return false;
3367
3392
  if (payload.hook_event_name === "__clear") return true;
3368
3393
  const sid = typeof payload.session_id === "string" ? payload.session_id : null;
@@ -3408,38 +3433,92 @@ export function replayScope(workspace, platform = process.platform) {
3408
3433
  * declining a session it was told not to capture is the flag doing its job. They
3409
3434
  * are two different things and only the first is ever printed.
3410
3435
  *
3411
- * WHAT THE FILTER COSTS AT BOOT, honestly: nothing is saved on the read. Every
3412
- * line is still streamed off disk and still JSON.parsed, because the cwd being
3413
- * judged is inside the JSON a 30 MB log is 30 MB of reading and parsing on a
3414
- * scoped deck exactly as on an unscoped one. What it saves is everything after
3415
- * the parse: no redaction pass, no envelope, no ring insert, no character
3416
- * accounting and no eviction pressure for a line this deck should never have
3417
- * held. The ring is bounded by MAX_BUFFER events AND MAX_BUFFER_CHARS, so on a
3418
- * busy machine the out-of-scope traffic was not merely extra — it was evicting
3419
- * the in-scope sessions the user started the deck to watch.
3436
+ * WHAT THE FILTER COSTS AT BOOT, honestly: nothing is saved on the read of any
3437
+ * line this reaches, because the cwd being judged is inside the JSON. What it
3438
+ * saves is everything after the parse: no redaction pass, no envelope, no ring
3439
+ * insert, no character accounting and no eviction pressure for a line this deck
3440
+ * should never have held. The ring is bounded by MAX_BUFFER events AND
3441
+ * MAX_BUFFER_CHARS, so on a busy machine the out-of-scope traffic was not
3442
+ * merely extra it was evicting the in-scope sessions the user started the
3443
+ * deck to watch.
3444
+ *
3445
+ * READ BACKWARDS, AND ONLY AS FAR AS THE RING (#742). This used to stream the
3446
+ * whole file from the front, and the whole file is where the boot's time went:
3447
+ * 12,079 lines and 31 MB on the machine it was measured on, 690ms of
3448
+ * JSON.parse, growing with every session until rotation cuts it at 50 MB — to
3449
+ * fill a ring that holds two thousand events. Five sixths of that parse was
3450
+ * feeding the eviction loop, on the critical path of a boot, every time.
3451
+ *
3452
+ * So the lines arrive newest-first and the loop stops the moment the ring is
3453
+ * full, which makes the cost a property of MAX_BUFFER rather than of how long
3454
+ * the user has been running the deck. Nothing is lost by it: what a forward
3455
+ * replay left in the ring was always the NEWEST admitted events that fit, and
3456
+ * that is exactly the set this collects. A young log — too few events to fill
3457
+ * the ring — is read to its start, and costs what it always did.
3458
+ *
3459
+ * The order of the pushes is still oldest-first. `seq` is assigned by pushEvent
3460
+ * in the order it is called, and a ring numbered backwards would hand every
3461
+ * resuming client a Last-Event-ID that means the opposite of what it says.
3462
+ *
3463
+ * A SCOPED DECK STILL READS FORWARDS, and that is not an oversight. Its
3464
+ * predicate decides the cwd-less enrichment events — ModelObserved,
3465
+ * UsageObserved, SessionNamed, ContextObserved — from the cwd-bearing event
3466
+ * earlier in the log, so backwards it meets the answer after the question and
3467
+ * drops them: the session arrives on the canvas with no model and no tokens.
3468
+ * `replayScope` says which kind of predicate it handed over rather than this
3469
+ * inferring it from the workspace string, so the two cannot drift apart. Making
3470
+ * that case cheap needs an index of where a workspace's lines are, which is a
3471
+ * different change from this one.
3420
3472
  */
3421
3473
  async function replayLog(filePath, workspace = "") {
3422
3474
  if (!existsSync(filePath)) return 0;
3423
- let count = 0;
3424
3475
  let skipped = 0;
3425
3476
  let skippedBytes = 0;
3426
3477
  const admits = replayScope(workspace);
3427
- const rl = createInterface({ input: createReadStream(filePath, { encoding: "utf8" }) });
3428
- for await (const line of rl) {
3429
- if (!line) continue;
3478
+ const replay = (evt) =>
3479
+ pushEvent(evt.payload, evt.source ?? "replay", { receivedAt: evt.receivedAt, replay: true });
3480
+ const parse = (line) => {
3430
3481
  try {
3431
- const evt = JSON.parse(line);
3432
- if (evt && typeof evt === "object" && evt.payload) {
3433
- if (!admits(evt.payload)) continue;
3434
- pushEvent(evt.payload, evt.source ?? "replay", { receivedAt: evt.receivedAt, replay: true });
3435
- count++;
3436
- }
3482
+ return JSON.parse(line);
3437
3483
  } catch {
3438
3484
  skipped++;
3439
3485
  skippedBytes += Buffer.byteLength(line, "utf8");
3486
+ return null;
3440
3487
  }
3488
+ };
3489
+ const usable = (evt) => evt && typeof evt === "object" && evt.payload;
3490
+
3491
+ let count = 0;
3492
+ if (admits.orderDependent) {
3493
+ for await (const line of linesFromStart(filePath)) {
3494
+ if (!line) continue;
3495
+ const evt = parse(line);
3496
+ if (!usable(evt) || !admits(evt.payload)) continue;
3497
+ replay(evt);
3498
+ count++;
3499
+ }
3500
+ } else {
3501
+ // Newest first, so this is filled back to front and then walked in reverse
3502
+ // to push. Bounded by MAX_BUFFER, which is what makes the memory here a
3503
+ // property of the ring rather than of the file.
3504
+ const newestFirst = [];
3505
+ for await (const line of linesFromEnd(filePath)) {
3506
+ if (!line) continue;
3507
+ const evt = parse(line);
3508
+ if (!usable(evt) || !admits(evt.payload)) continue;
3509
+ newestFirst.push(evt);
3510
+ // Everything older than this would be evicted by the events already held,
3511
+ // so reading further is work whose only result is throwing it away.
3512
+ if (newestFirst.length >= MAX_BUFFER) break;
3513
+ }
3514
+ for (let i = newestFirst.length - 1; i >= 0; i--) replay(newestFirst[i]);
3515
+ count = newestFirst.length;
3441
3516
  }
3442
3517
  if (skipped > 0) {
3518
+ // "in the part of the log it read", because that is now a part rather than
3519
+ // the whole: a damaged line older than the ring is never reached, and
3520
+ // claiming to have counted every unreadable line in the file would be a
3521
+ // number this no longer has.
3443
3522
  const kb = (skippedBytes / 1024).toFixed(0);
3444
3523
  console.warn(`${PRODUCT}: skipped ${skipped} unreadable line(s) (${kb}KB) while replaying the event log`);
3445
3524
  }
@@ -5138,7 +5217,25 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
5138
5217
  // On demand only — the process list costs a subprocess on every platform,
5139
5218
  // so it is fetched while the detail panel is open and never on the timer.
5140
5219
  if (req.method === "GET" && url.pathname === "/api/system/processes") {
5141
- return guard(readProcesses().then(procs => send(res, 200, { ok: true, procs })), res);
5220
+ // `total` is how many the machine is running, against the candidates
5221
+ // actually sent. The modal says both, so a reader can see this is a
5222
+ // selection rather than a task manager pretending to be complete.
5223
+ return guard(readProcesses().then(r => send(res, 200, { ok: true, ...r })), res);
5224
+ }
5225
+ // A day of minute buckets, which is far too much to ride along on
5226
+ // /api/system's three-second poll for a chart that is usually closed. Its
5227
+ // own route, fetched only while a modal is open — the same arrangement the
5228
+ // process list has for the same reason. One section per request, because
5229
+ // four sections of a day would be four times too much again.
5230
+ if (req.method === "GET" && url.pathname === "/api/system/history") {
5231
+ const group = url.searchParams.get("group") ?? "";
5232
+ // An allowlist, not a pass-through: the parameter names a section of this
5233
+ // panel and nothing else, so an unknown one is a 400 rather than an empty
5234
+ // chart that looks like a machine with nothing to report.
5235
+ if (!["thermal", "cores", "memory", "load"].includes(group)) {
5236
+ return send(res, 400, { ok: false, error: "unknown_group" });
5237
+ }
5238
+ return send(res, 200, historySnapshot(group));
5142
5239
  }
5143
5240
  if (req.method === "GET" && url.pathname === "/api/codex-quota") return guard(handleCodexQuota(req, res), res);
5144
5241
  if (req.method === "GET" && url.pathname === "/api/ccusage") return guard(handleCcusage(req, res), res);
@@ -0,0 +1,139 @@
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
+ }