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.
Files changed (70) hide show
  1. package/README.md +6 -477
  2. package/package.json +14 -48
  3. package/shim.js +107 -0
  4. package/LICENSE +0 -661
  5. package/LICENSING.md +0 -82
  6. package/THIRD_PARTY_NOTICES.md +0 -395
  7. package/bin/agent-dag.js +0 -626
  8. package/bin/deck.js +0 -1805
  9. package/dist/web/assets/index-CJYsv0lr.css +0 -1
  10. package/dist/web/assets/index-Ifm23DDC.js +0 -270
  11. package/dist/web/index.html +0 -49
  12. package/hook/hook.js +0 -542
  13. package/release-notes.json +0 -398
  14. package/src/server/activity.mjs +0 -52
  15. package/src/server/agent-activity.mjs +0 -522
  16. package/src/server/args.mjs +0 -183
  17. package/src/server/auto-update.mjs +0 -79
  18. package/src/server/block-notify.mjs +0 -173
  19. package/src/server/boot-deadline.mjs +0 -127
  20. package/src/server/brand.mjs +0 -16
  21. package/src/server/browser-history.mjs +0 -497
  22. package/src/server/browser-presence.mjs +0 -211
  23. package/src/server/browser-profiles.mjs +0 -279
  24. package/src/server/browser-react.mjs +0 -284
  25. package/src/server/browser-watch-store.mjs +0 -350
  26. package/src/server/browser-watch.mjs +0 -905
  27. package/src/server/ccusage.mjs +0 -1168
  28. package/src/server/claude-accounts.mjs +0 -951
  29. package/src/server/claude-dir.mjs +0 -213
  30. package/src/server/codex-auth.mjs +0 -388
  31. package/src/server/codex-dir.mjs +0 -171
  32. package/src/server/codex-quota.mjs +0 -449
  33. package/src/server/codex-usage.mjs +0 -512
  34. package/src/server/cswap-admin.mjs +0 -1562
  35. package/src/server/cswap-auto.mjs +0 -658
  36. package/src/server/cswap-install.mjs +0 -641
  37. package/src/server/deck-home.mjs +0 -243
  38. package/src/server/deck-prefs.mjs +0 -301
  39. package/src/server/deck-probe.mjs +0 -111
  40. package/src/server/detach.mjs +0 -244
  41. package/src/server/exec.mjs +0 -996
  42. package/src/server/global-install.mjs +0 -67
  43. package/src/server/hwmonitor.mjs +0 -56
  44. package/src/server/index.mjs +0 -6043
  45. package/src/server/installer.mjs +0 -912
  46. package/src/server/invoked-as.mjs +0 -144
  47. package/src/server/lan-about.mjs +0 -119
  48. package/src/server/lan-engine.mjs +0 -952
  49. package/src/server/lan-reach.mjs +0 -256
  50. package/src/server/lan-socket.mjs +0 -682
  51. package/src/server/lan-sync.mjs +0 -941
  52. package/src/server/lhm-parse.mjs +0 -91
  53. package/src/server/log-tail.mjs +0 -139
  54. package/src/server/log-writer.mjs +0 -322
  55. package/src/server/login-service.mjs +0 -473
  56. package/src/server/macmon.mjs +0 -310
  57. package/src/server/npx.mjs +0 -264
  58. package/src/server/open-url.mjs +0 -242
  59. package/src/server/presence.mjs +0 -40
  60. package/src/server/quota.mjs +0 -792
  61. package/src/server/relay-guard.mjs +0 -507
  62. package/src/server/reset-label.mjs +0 -78
  63. package/src/server/retire-sound-hook.mjs +0 -349
  64. package/src/server/running-deck.mjs +0 -234
  65. package/src/server/self-update.mjs +0 -1380
  66. package/src/server/stop-deck.mjs +0 -171
  67. package/src/server/supervisor.mjs +0 -392
  68. package/src/server/system-metrics.mjs +0 -1825
  69. package/src/server/term.mjs +0 -686
  70. package/src/server/uv-bootstrap.mjs +0 -337
@@ -1,497 +0,0 @@
1
- // Reading Chrome's visit log out from under a browser that is still writing it.
2
- //
3
- // Browser Watch wants one thing: the URLs navigated to since the last poll.
4
- // Chrome (and every Chromium fork — Brave, Edge, Vivaldi) keeps them in a
5
- // SQLite file called `History` in the profile directory, and three separate
6
- // facts stand between that file and a list of rows. All three were measured on
7
- // this machine against a real 21 MB Brave profile rather than reasoned about,
8
- // because each of them fails in a way that looks like something else.
9
- //
10
- // ── 1. THE LIVE FILE CANNOT BE OPENED, EVEN READ-ONLY ───────────────────────
11
- //
12
- // Chrome holds an exclusive lock for as long as it is running. A read-only open
13
- // of the real path answers `SQLITE_BUSY: database is locked` — not "empty", not
14
- // "no such table", a hard error on the very first statement. So the file is
15
- // COPIED and the copy is read. 21 MB copies in 168 ms and 20 of 20 copies came
16
- // back readable; that is the whole cost of the poll.
17
- //
18
- // The copy is not the tidy kind either. `journal_mode` on this file is `delete`,
19
- // NOT WAL — there is no `-wal`/`-shm` sidecar to copy alongside it, which is the
20
- // usual Chromium advice and is wrong here. What does sit beside it during a
21
- // write is a `History-journal` file holding the UNDO image, so a copy taken
22
- // mid-transaction can contain writes Chrome was about to roll back, and a copy
23
- // taken at exactly the wrong moment can be a torn page — `SQLITE_NOTADB: file is
24
- // not a database`, reproduced here by handing the reader 4 KB of /dev/urandom.
25
- // A few extra rows are harmless for a feature that lists navigations. A throw is
26
- // not: this runs on a poll inside the deck's own process, so an uncaught one
27
- // takes the whole deck down over a browser that happened to be busy. Nothing
28
- // below throws; a failure comes back as `degraded` with a reason.
29
- //
30
- // ── 2. `visit_time` DOES NOT FIT IN A JAVASCRIPT NUMBER ─────────────────────
31
- //
32
- // Chrome counts MICROSECONDS since 1601-01-01 UTC, so today's values are around
33
- // 1.34e16 — past 2^53. `node:sqlite` refuses to guess and throws rather than
34
- // hand back a rounded integer:
35
- //
36
- // RangeError: The value of column 0 is too large to be represented as a
37
- // JavaScript number: 13432716408648765 (code: ERR_OUT_OF_RANGE)
38
- //
39
- // That is a throw from `.all()`, i.e. the whole query fails, not one row. So
40
- // every 64-bit column in the SELECT is read as `CAST(… AS TEXT)` and stays a
41
- // STRING until `chromeTimeToMs` converts it through BigInt. `transition` is cast
42
- // too even though its values are 32-bit today, because the failure is per-STATEMENT
43
- // rather than per-column: one out-of-range column loses every row of the query,
44
- // so there is no version of this worth leaving to chance for one saved cast.
45
- //
46
- // ── 3. THERE MAY BE NO SQLITE READER AT ALL ─────────────────────────────────
47
- //
48
- // `node:sqlite` exists only from Node 22.5, and this package declares
49
- // `engines: { node: ">=18" }` — while CI runs Node 22 on all three OSes and
50
- // nothing on 18. That gap is registered in node-floor.test.ts rather than
51
- // described here, because the sentence that used to stand in this spot claimed
52
- // a matrix entry that has never existed and stood for a release and a half. A
53
- // comment cannot be wrong about a file it does not read; the register can. A
54
- // top-level
55
- // `import "node:sqlite"` is therefore not an option: it throws
56
- // ERR_UNKNOWN_BUILTIN_MODULE at module load, before any of this file's own error
57
- // handling exists, and takes the server's import graph with it. It is loaded
58
- // through a dynamic import inside a try/catch, once.
59
- //
60
- // The fallback is the `sqlite3` CLI, which ships with macOS (/usr/bin/sqlite3,
61
- // 3.51.0 here) and is usually present on Linux — and is ABSENT ON WINDOWS, where
62
- // Microsoft ships no sqlite3.exe. So the third answer is real and has to be a
63
- // first-class one: `{ kind: "none" }`, `degraded: true`, no rows, and the caller
64
- // falls back to the file's mtime — "something navigated at 09:06, no URL" is a
65
- // weaker signal than a list of URLs and it is a great deal better than a blank
66
- // panel and a crash.
67
- import { copyFile, mkdir, rm } from "node:fs/promises";
68
- import { createRequire } from "node:module";
69
- import { tmpdir } from "node:os";
70
- import { join } from "node:path";
71
- import { randomUUID } from "node:crypto";
72
- import { constants as fsConstants } from "node:fs";
73
- const { COPYFILE_EXCL } = fsConstants;
74
- import { pathLookup, run } from "./exec.mjs";
75
-
76
- /**
77
- * 1601-01-01 → 1970-01-01, in microseconds.
78
- *
79
- * A BigInt because that is the unit the arithmetic has to happen in: the whole
80
- * point of the conversion is that the operands do not fit in a double, so doing
81
- * it as `Number(t) / 1000 - 11644473600000` rounds the input before it is ever
82
- * used. The error is under a microsecond and it is still the wrong shape — it
83
- * produces a FRACTIONAL millisecond (1788242808648.7637 for the newest row on
84
- * this machine), which then feeds a Date, a diff and a watermark.
85
- */
86
- const EPOCH_DELTA_US = 11644473600000000n;
87
- const US_PER_MS = 1000n;
88
-
89
- /**
90
- * Chrome time (microseconds since 1601, as a STRING) → JS epoch milliseconds.
91
- *
92
- * The argument is a string because every caller gets it out of a `CAST(… AS
93
- * TEXT)` column; a Number is accepted too, and is exactly the lossy input this
94
- * function exists to avoid, so it is converted through its decimal spelling.
95
- *
96
- * Division truncates toward zero, which is floor for every value this can be
97
- * handed — Chrome cannot record a visit before 1601. It is stated because a
98
- * machine whose clock sat before 1970 would produce a negative microsecond count
99
- * and truncation would round it toward the future by up to 1 ms, which is a
100
- * rounding artefact rather than a bug worth branching for.
101
- *
102
- * A value that is not a run of digits answers NaN rather than throwing.
103
- * `BigInt("")` and `BigInt("abc")` both throw SyntaxError, and this is called
104
- * once per row inside a poll: one unreadable row must cost that row, not the
105
- * request. readVisitsSince drops rows whose time is not finite.
106
- */
107
- export function chromeTimeToMs(t) {
108
- const digits = String(t ?? "").trim();
109
- if (!/^-?\d+$/.test(digits)) return NaN;
110
- return Number((BigInt(digits) - EPOCH_DELTA_US) / US_PER_MS);
111
- }
112
-
113
- /**
114
- * JS epoch milliseconds → Chrome time, as a STRING.
115
- *
116
- * A string because the result does not fit in a Number — today's values are
117
- * 1.34e16 — so returning one would corrupt the watermark it exists to produce.
118
- * That is the caller this function is for: a first poll has no watermark and
119
- * seeds one from the wall clock, which is what keeps the first read to "since
120
- * the deck started" instead of the user's entire browsing history.
121
- *
122
- * A non-finite input answers "0", the same floor a first call with no watermark
123
- * gets. Zero means 1601, so nothing is skipped: an unusable input costs a wide
124
- * read, never a silently missed navigation.
125
- */
126
- export function msToChromeTime(ms) {
127
- const whole = Math.trunc(Number(ms));
128
- if (!Number.isFinite(whole)) return "0";
129
- return String(BigInt(whole) * US_PER_MS + EPOCH_DELTA_US);
130
- }
131
-
132
- /**
133
- * The SELECT, in one place, for both backends.
134
- *
135
- * `floor` is the `>` operand: `"?"` for the node:sqlite path, which binds it as
136
- * a BigInt, and a validated run of digits for the CLI path, which has no way to
137
- * bind a parameter at all. Everything else about the statement is byte-identical
138
- * between the two — one query to read, one query to get the casts right.
139
- *
140
- * Verified against the real profile: 4,893 rows in 15 ms with node:sqlite.
141
- */
142
- const visitsSql = (floor) =>
143
- "SELECT u.url AS url, CAST(v.visit_time AS TEXT) t, CAST(v.transition AS TEXT) tr" +
144
- " FROM visits v JOIN urls u ON u.id = v.url" +
145
- ` WHERE v.visit_time > ${floor}` +
146
- " ORDER BY v.visit_time";
147
-
148
- /**
149
- * The floor as a run of digits, or "0".
150
- *
151
- * Two jobs, and the second is the one that matters. It normalises "no watermark
152
- * yet" — undefined, null, "" — to the 1601 floor. And it is the ONLY thing
153
- * standing between a stored watermark and the CLI's command line, where the
154
- * value is pasted into SQL text because sqlite3(1) offers no parameter binding.
155
- * The watermark is the deck's own output round-tripped through the caller's
156
- * state, not user input in the web sense; it is validated anyway, because
157
- * "nobody can reach that value" is a claim about code that is not in this file.
158
- */
159
- function chromeFloor(since) {
160
- const digits = String(since ?? "").trim();
161
- return /^\d+$/.test(digits) ? digits : "0";
162
- }
163
-
164
- /** Compare two digit strings by value, without BigInt. Runs once per row. */
165
- function cmpDigits(a, b) {
166
- const x = a.replace(/^0+(?=\d)/, "");
167
- const y = b.replace(/^0+(?=\d)/, "");
168
- if (x.length !== y.length) return x.length - y.length;
169
- return x < y ? -1 : x > y ? 1 : 0;
170
- }
171
-
172
- /** The one line of an error worth putting in a `reason`. */
173
- function why(err) {
174
- const text = String(err?.message ?? err ?? "unknown");
175
- return text.split("\n")[0].slice(0, 200);
176
- }
177
-
178
- /** Cached answer of the real probe. A promise, so two concurrent first calls
179
- * share one dynamic import and one PATH walk rather than racing to do both. */
180
- let memo = null;
181
-
182
- /**
183
- * `node:sqlite`, loaded through Node's own resolver rather than the bundler's.
184
- *
185
- * `await import("node:sqlite")` is the obvious spelling and it is the wrong one
186
- * here. Vite's builtin list predates the module, so under the test runner it
187
- * strips the `node:` prefix and looks for a package called `sqlite` instead:
188
- * "Failed to load url sqlite (resolved id: sqlite)". vitest 2 carries that bug
189
- * (vitest-dev/vitest#7177, fixed upstream in #7179 and not in the 2.x pinned
190
- * here), and its cost was not a failing test. `sqliteBackend()` simply resolved
191
- * to the CLI arm in every case, so the branch most users actually run was the
192
- * one branch no test could reach — and the suite was green about it.
193
- *
194
- * `createRequire` goes straight to Node, which knows the module on any runtime
195
- * that has it, and behaves identically in production, where no bundler is
196
- * involved at all. Still inside the caller's try/catch: a Node without the
197
- * module throws here exactly as the dynamic import did.
198
- */
199
- const requireNode = createRequire(import.meta.url);
200
-
201
- /**
202
- * Node prints `ExperimentalWarning: SQLite is an experimental feature` to
203
- * stderr the first time the module is loaded, and that stderr is the terminal
204
- * the user started the deck in. It is a warning about a decision they did not
205
- * make, about a module they cannot choose, at a moment they were looking at a
206
- * browser tab — and there is nothing they can do with it.
207
- *
208
- * Only this one warning, and only by swapping the default listener for one that
209
- * forwards everything else untouched: a blanket NODE_NO_WARNINGS would also
210
- * swallow a deprecation the deck genuinely needs to hear about. Installed lazily
211
- * on the first load rather than at import, so a deck whose panel is never opened
212
- * never touches the process's listeners at all.
213
- */
214
- let quieted = false;
215
- function quietSqliteWarning() {
216
- if (quieted) return;
217
- quieted = true;
218
- const existing = process.listeners("warning");
219
- process.removeAllListeners("warning");
220
- process.on("warning", warning => {
221
- if (warning?.name === "ExperimentalWarning" && /\bSQLite\b/.test(warning.message ?? "")) return;
222
- for (const listener of existing) listener(warning);
223
- });
224
- }
225
-
226
- const loadSqlite = async () => {
227
- quietSqliteWarning();
228
- return requireNode("node:sqlite");
229
- };
230
-
231
- /**
232
- * Which SQLite reader this machine has, resolved once and cached.
233
- *
234
- * `{ kind: "node-sqlite" }` | `{ kind: "sqlite3-cli", bin }` | `{ kind: "none" }`
235
- *
236
- * ORDER IS DELIBERATE. node:sqlite is in-process — no spawn, no command line, no
237
- * output to parse, and parameter binding — so it wins wherever it exists. The
238
- * CLI is the fallback rather than the default because every call to it costs a
239
- * process, and this runs on a poll.
240
- *
241
- * The dynamic import is inside a try/catch and covers more than "Node 18 has no
242
- * such module". Between 22.5 and 22.12 node:sqlite existed but required
243
- * `--experimental-sqlite`, which the deck is not started with, and the import
244
- * fails there too — the same catch, the same fallback, no version arithmetic
245
- * anywhere in this file. `DatabaseSync` is checked for by name because that is
246
- * what the read path actually calls; a future module that exists under this
247
- * specifier without it would otherwise be selected and then throw per poll.
248
- *
249
- * The CLI is found with the repo's own `pathLookup` rather than by spawning
250
- * `sqlite3` and seeing what happens, for the reason exec.mjs was written: on
251
- * Windows the thing on PATH is `sqlite3.exe`, spawn is not a shell and applies
252
- * no PATHEXT, so a bare-name probe answers ENOENT on the one platform where the
253
- * answer decides whether the feature exists at all.
254
- *
255
- * `deps` is for tests and is NEVER memoised — a probe with an injected PATH must
256
- * not become this process's permanent answer, and the real answer must not be
257
- * whatever a test asked for first. Passing nothing takes the cache; passing even
258
- * `{}` re-probes.
259
- */
260
- export async function sqliteBackend(deps) {
261
- if (deps) return probeBackend(deps);
262
- memo ??= probeBackend({});
263
- return memo;
264
- }
265
-
266
- async function probeBackend({
267
- importSqlite = loadSqlite,
268
- lookup = pathLookup,
269
- platform = process.platform,
270
- env = process.env,
271
- } = {}) {
272
- try {
273
- const mod = await importSqlite();
274
- if (typeof mod?.DatabaseSync === "function") return { kind: "node-sqlite" };
275
- } catch {
276
- // Node < 22.5, or 22.5–22.12 without --experimental-sqlite. Both are "no
277
- // in-process reader", which is a state and not an error.
278
- }
279
- const bin = lookup("sqlite3", platform, { pathEnv: env.PATH ?? env.Path ?? "" });
280
- if (bin) return { kind: "sqlite3-cli", bin };
281
- return { kind: "none" };
282
- }
283
-
284
- /** Distinguishes this deck's copies from a sibling deck's in a shared copyDir.
285
- * pid alone is not enough — one deck polls repeatedly and must not read a copy
286
- * it is still writing. */
287
- let copySeq = 0;
288
-
289
- /**
290
- * Every navigation newer than `sinceChromeTime`.
291
- *
292
- * `{ rows, watermark, degraded, reason }`
293
- * rows [{ url, timeMs, transition }], oldest first
294
- * watermark the newest chrome time seen, as a string, or the input unchanged
295
- * when there was nothing to see — store it and hand it back next
296
- * poll
297
- * degraded true when no rows could be read for a reason that is not "no new
298
- * navigations": no SQLite reader on this machine, no readable copy,
299
- * a torn image. The caller falls back to the file's mtime in all of
300
- * them, which is why they share one flag rather than one each — the
301
- * distinction that matters to a caller is "is this list complete",
302
- * and `reason` carries the rest for the log.
303
- * reason null on success, otherwise a stable slug and a detail:
304
- * "no-sqlite-reader: …" | "copy-failed: …" | "unreadable-copy: …"
305
- *
306
- * `opts.copyDir` is where the copy of the locked file goes; `opts.backend` skips
307
- * the probe when the caller already resolved it; `opts.deps` injects the
308
- * filesystem, the runner and the import for tests.
309
- *
310
- * NEVER THROWS. Not "rarely" — this is called from a poll in the deck's own
311
- * process and the inputs are a file another program owns, so the failure modes
312
- * are ordinary rather than exceptional.
313
- */
314
- export async function readVisitsSince(historyPath, sinceChromeTime, opts = {}) {
315
- const { copyDir = join(tmpdir(), "ccdeck-browser-watch"), backend } = opts;
316
- const deps = opts.deps ?? {};
317
- const {
318
- copyFile: copy = copyFile,
319
- mkdir: makeDir = mkdir,
320
- rm: remove = rm,
321
- run: exec = run,
322
- importSqlite = loadSqlite,
323
- } = deps;
324
-
325
- const floor = chromeFloor(sinceChromeTime);
326
- // What comes back when there is nothing to advance to. The input verbatim
327
- // where it was usable, so a caller that stores it sees no change at all; the
328
- // normalised floor where it was not, so the answer is always a string a later
329
- // call can be handed.
330
- const unchanged = floor === String(sinceChromeTime ?? "").trim() ? String(sinceChromeTime) : floor;
331
-
332
- const chosen = backend ?? await sqliteBackend(opts.deps);
333
- if (!chosen || chosen.kind === "none") {
334
- return {
335
- rows: [],
336
- watermark: unchanged,
337
- degraded: true,
338
- reason: "no-sqlite-reader: node:sqlite needs Node 22.5+ and no sqlite3 was found on PATH",
339
- };
340
- }
341
-
342
- let copyPath = null;
343
- try {
344
- // MODE 0700, AND A NAME NOBODY ELSE CAN PREDICT.
345
- //
346
- // `os.tmpdir()` is per-user on macOS (/var/folders/…, 0700) and on Windows,
347
- // and on Linux it is the shared, world-writable /tmp. A fixed directory
348
- // name and `history-<pid>-<n>.sqlite` inside it meant three things there,
349
- // all of them avoidable:
350
- //
351
- // * a complete, unencrypted copy of the user's browsing history, mode
352
- // 0644, under a predictable path, readable by every other account on
353
- // the machine for the life of the poll;
354
- // * another UID can create the directory first — `mkdir` with `recursive`
355
- // swallows EEXIST and keeps THEIR mode — and then read every copy, or
356
- // plant a symlink at the name and have this overwrite a file the user
357
- // owns, because `copyFile` was called without COPYFILE_EXCL;
358
- // * a second user on the same box then fails EACCES on a directory they
359
- // cannot write, and their deck is degraded for good.
360
- //
361
- // The mode is set on creation AND after, because the directory may already
362
- // exist from an earlier run of this same deck.
363
- await makeDir(copyDir, { recursive: true, mode: 0o700 });
364
- copyPath = join(copyDir, `history-${process.pid}-${++copySeq}-${randomUUID().slice(0, 8)}.sqlite`);
365
- // COPYFILE_EXCL: refuse rather than write through a symlink or over a file
366
- // that is already there. A refusal is one degraded poll; the alternative is
367
- // clobbering whatever the name pointed at.
368
- await copy(historyPath, copyPath, COPYFILE_EXCL);
369
- } catch (err) {
370
- // The browser is not installed, the profile moved, the disk is full. All of
371
- // them are "no rows this poll", none of them is a reason to stop polling.
372
- await discard(remove, copyPath);
373
- return { rows: [], watermark: unchanged, degraded: true, reason: `copy-failed: ${why(err)}` };
374
- }
375
-
376
- let raw;
377
- try {
378
- raw = chosen.kind === "node-sqlite"
379
- ? await readViaNode(copyPath, floor, importSqlite)
380
- : await readViaCli(copyPath, floor, chosen.bin, exec);
381
- } catch (err) {
382
- return { rows: [], watermark: unchanged, degraded: true, reason: `unreadable-copy: ${why(err)}` };
383
- } finally {
384
- // A 21 MB file per poll. Left behind, this fills the user's temp directory
385
- // at the rate the deck polls — and the copy is a full, unencrypted list of
386
- // everywhere they have been, which is not a thing to leave lying around
387
- // under a predictable name.
388
- await discard(remove, copyPath);
389
- }
390
-
391
- const rows = [];
392
- let top = floor;
393
- for (const row of raw) {
394
- const timeMs = chromeTimeToMs(row?.t);
395
- // A row with no URL or an unreadable time is dropped rather than repaired.
396
- // It cannot be drawn and it must not become the watermark, because a
397
- // watermark taken from a value this could not read would skip every real
398
- // row behind it, permanently.
399
- if (!row?.url || !Number.isFinite(timeMs)) continue;
400
- const transition = Number(row.tr);
401
- rows.push({ url: String(row.url), timeMs, transition: Number.isFinite(transition) ? transition : 0 });
402
- const t = String(row.t).trim();
403
- if (cmpDigits(t, top) > 0) top = t;
404
- }
405
-
406
- // `top` rather than the last row's time. The ORDER BY makes those the same
407
- // today, and the watermark is the one value whose being wrong loses rows
408
- // forever rather than for one poll — so it is computed from what was read
409
- // instead of from an assumption about how it was sorted.
410
- return { rows, watermark: rows.length ? top : unchanged, degraded: false, reason: null };
411
- }
412
-
413
- /**
414
- * Delete the copy, and never let the deletion be the thing that fails.
415
- *
416
- * `maxRetries` is Node's own answer to a Windows file whose last handle is still
417
- * closing — see __tests__/rm-temp-dir.ts, which paid for that knowledge twice.
418
- * The catch is on top of it because a leftover 21 MB file in a temp directory is
419
- * not worth a failed poll.
420
- */
421
- async function discard(remove, path) {
422
- if (!path) return;
423
- try {
424
- await remove(path, { force: true, maxRetries: 5, retryDelay: 20 });
425
- } catch {
426
- // The OS still has it. It is in a temp directory and it is one file.
427
- }
428
- }
429
-
430
- /**
431
- * The in-process read.
432
- *
433
- * `readOnly` is passed even though the target is a copy this module owns: it
434
- * stops SQLite creating a `-journal` beside it and makes an attempt to write a
435
- * bug rather than a silent edit of the snapshot. Older node:sqlite builds that
436
- * predate the option ignore it, which on a private copy is harmless — the
437
- * fallback that would otherwise be needed here would have to tell an unknown
438
- * option apart from "file is not a database", and guessing at that is worse than
439
- * opening a scratch file read-write.
440
- *
441
- * The floor is BOUND, as a BigInt, because it does not fit in a Number either.
442
- * Binding it as a string would work by SQLite's column affinity rules rather
443
- * than by intent — the comparison would go through an implicit TEXT→INTEGER
444
- * conversion that the schema happens to ask for — and that is a thing to rely on
445
- * only when there is no alternative. Here there is one.
446
- */
447
- async function readViaNode(file, floor, importSqlite) {
448
- const { DatabaseSync } = await importSqlite();
449
- const db = new DatabaseSync(file, { readOnly: true });
450
- try {
451
- return db.prepare(visitsSql("?")).all(BigInt(floor));
452
- } finally {
453
- try { db.close(); } catch { /* already closed, or never opened cleanly */ }
454
- }
455
- }
456
-
457
- // sqlite3 -ascii separators: 0x1F between columns, 0x1E after every row
458
- // including the last. Chosen over `-json` because `-json` needs sqlite3 3.33
459
- // (2020) and Ubuntu 20.04 still ships 3.31, while `-ascii` has been there since
460
- // 3.8 — and over the default `|` because a URL may legally contain a pipe,
461
- // where 0x1F and 0x1E cannot appear in one at all. Confirmed against the real
462
- // profile: 0 of 172,000 URLs contain either separator or a newline.
463
- const UNIT = "\u001f";
464
- const RECORD = "\u001e";
465
-
466
- /**
467
- * The out-of-process read.
468
- *
469
- * Through the repo's `run` rather than `execFile` directly, for what exec.mjs
470
- * exists to do: candidate spelling on Windows, a deadline that reports before it
471
- * kills, and a contract that answers `{ ok: false }` instead of rejecting.
472
- *
473
- * `maxBuffer` is raised well past run's 4 MB default. The steady-state poll
474
- * returns kilobytes, but a caller that seeds its watermark at 0 asks for the
475
- * entire history in one statement — 170k rows here — and the failure mode of the
476
- * default is ENOBUFS, which arrives looking like a broken database rather than
477
- * like a large one.
478
- */
479
- async function readViaCli(file, floor, bin, exec) {
480
- const res = await exec(bin, ["-readonly", "-ascii", file, visitsSql(floor)], {
481
- timeout: 15_000,
482
- maxBuffer: 64 << 20,
483
- });
484
- if (!res?.ok) {
485
- // sqlite3 puts "file is not a database" on stderr and exits 26. Reported,
486
- // never rethrown as-is, so the reason names the tool that said it.
487
- const said = why(res?.stderr || res?.stdout || "");
488
- throw new Error(`sqlite3 exited ${res?.code ?? "?"}${said ? `: ${said}` : ""}`);
489
- }
490
- const out = [];
491
- for (const record of String(res.stdout ?? "").split(RECORD)) {
492
- if (!record) continue;
493
- const [url, t, tr] = record.split(UNIT);
494
- out.push({ url, t, tr });
495
- }
496
- return out;
497
- }