@slack/radar-mcp 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/shared/db.js CHANGED
@@ -353,6 +353,24 @@ export function assertReadOnlyQuery(sql) {
353
353
  throw new Error("that function/statement is not allowed (read-only, no file access)");
354
354
  }
355
355
  }
356
+ /**
357
+ * True for EXPLAIN / EXPLAIN QUERY PLAN, which sqlite3 prints as a text table (ignoring -json).
358
+ * Leading-token match; assertReadOnlyQuery gates the same leading keyword, so both reject on
359
+ * comment-prefixed queries before this routing matters.
360
+ */
361
+ export function isExplainQuery(sql) {
362
+ return /^\s*explain\b/i.test(sql);
363
+ }
364
+ /**
365
+ * sqlite3 ignores -json for EXPLAIN (prints text table instead of JSON).
366
+ * Shape it into row objects so the web grid renders the plan readably.
367
+ */
368
+ export function parseExplainOutput(out) {
369
+ return out
370
+ .split("\n")
371
+ .filter((line) => line.trim().length > 0)
372
+ .map((line) => ({ plan: line }));
373
+ }
356
374
  /**
357
375
  * Run a read-only query against a (lazily pulled) local copy. The query is gated by
358
376
  * assertReadOnlyQuery (see there for the full read-only/exfil rationale). Surfaces sqlite's
@@ -365,6 +383,12 @@ export async function queryDatabase(name, sql, user) {
365
383
  const local = localPath(name, u);
366
384
  if (!fs.existsSync(local))
367
385
  await pullDatabase(name, u);
386
+ // EXPLAIN / EXPLAIN QUERY PLAN are read-query-shaped (they pass the gate) but sqlite3 IGNORES
387
+ // -json for them: it prints a fixed-width opcode/plan TEXT table, not JSON, so JSON.parse below
388
+ // would throw "Unexpected token" and the user sees a confusing parse error for a query that
389
+ // actually ran. Handle them as text: one result row per output line under a single column, so
390
+ // the existing grid renders the plan readably instead of choking.
391
+ const explain = isExplainQuery(sql);
368
392
  let out;
369
393
  try {
370
394
  // Open the pulled copy NORMALLY (not `immutable`, not `-readonly`): pullDatabase brought
@@ -375,14 +399,15 @@ export async function queryDatabase(name, sql, user) {
375
399
  // is touched. `-safe` is the load-bearing read-only/exfil control (blocks ATTACH/readfile/
376
400
  // writefile/load_extension at the engine level) and holds in normal mode too. The path is
377
401
  // URI-encoded so spaces/specials are safe. Async exec so a large query does not block the
378
- // event loop.
402
+ // event loop. -json for normal queries; plain text for EXPLAIN (sqlite ignores -json there).
379
403
  const uri = `file:${encodeURI(local)}`;
380
- const r = await execFileP(sqliteBin(), ["-safe", "-json", uri, sql], {
404
+ const args = explain ? ["-safe", uri, sql] : ["-safe", "-json", uri, sql];
405
+ const r = await execFileP(sqliteBin(), args, {
381
406
  timeout: 15000,
382
407
  maxBuffer: SQLITE_MAX_BUFFER,
383
408
  encoding: "utf8",
384
409
  });
385
- out = String(r.stdout) || "[]";
410
+ out = String(r.stdout) || (explain ? "" : "[]");
386
411
  }
387
412
  catch (e) {
388
413
  const err = e;
@@ -390,6 +415,8 @@ export async function queryDatabase(name, sql, user) {
390
415
  const m = stderr.match(/(no such table:.*|no such column:.*|near ".*|syntax error.*|.*error.*)/i);
391
416
  throw new Error(m ? m[0].trim() : stderr.split("\n")[0] || "query failed");
392
417
  }
418
+ if (explain)
419
+ return parseExplainOutput(out);
393
420
  return JSON.parse(out);
394
421
  }
395
422
  /** Size in bytes of a pulled local copy for the given user, or 0 if not pulled. */
@@ -0,0 +1,75 @@
1
+ import type { ParsedSSE } from "./stream.js";
2
+ /**
3
+ * Point the spool dir at a per-process-unique path. Call once at startup with
4
+ * process.pid. Mirrors db.ts initializeDatabasePath: every MCP shares one port,
5
+ * so a pid key is the collision-free scope.
6
+ */
7
+ export declare function initializeSpoolDir(scopeKey: number): void;
8
+ /**
9
+ * Delete spool files older than GC_TTL_MS. Runs at session enable. Bounds disk
10
+ * usage without losing recent session records. dir + now injectable for tests.
11
+ * Mirrors gcOldCaptures (logcat-capture.ts). Returns the count removed.
12
+ */
13
+ export declare function gcOldSpools(dir?: string, now?: number): number;
14
+ export declare function buildSpoolFilePath(now?: number): string;
15
+ export declare function epochFromSpoolPath(filePath: string | null): string;
16
+ export declare function isSpooling(): boolean;
17
+ export declare function getSpoolFilePath(): string | null;
18
+ /**
19
+ * Read the current session spool back into parsed frames, oldest first (append
20
+ * order). Separate from the write path; reads the live file by its own handle,
21
+ * never touching the writer's fd or state. Returns [] when no spool is open, the
22
+ * file is gone, or it cannot be read. Never throws into the caller: a query over
23
+ * a missing or damaged spool must degrade to empty, not fail the tool.
24
+ *
25
+ * Reads the whole file and lets the query shaper's byte cap bound the OUTPUT,
26
+ * rather than tailing here: a filter can match an old frame near the top, so a
27
+ * byte-tail read could drop a frame the caller asked for. Rotation already caps
28
+ * the live file at a normal-session size, so a whole read is safe.
29
+ *
30
+ * A torn final line (the process died mid-append, before the newline) is skipped
31
+ * so one bad tail line never sinks the whole read. Any line that will not parse
32
+ * is dropped the same way.
33
+ */
34
+ export declare function readSpool(): ParsedSSE[];
35
+ /**
36
+ * Open a fresh spool file for append and set module state. Ensures the dir,
37
+ * runs gcOldSpools first, returns the path (or null on failure). Idempotent: a
38
+ * second call while a spool is open returns the current path without reopening.
39
+ */
40
+ export declare function startSpool(now?: number): string | null;
41
+ /**
42
+ * Append one frame as a single JSONL line. On the hot path (every streamed
43
+ * event) and inside the wait_for_events notify path. Synchronous on purpose:
44
+ * a sync append keeps frames in arrival order with no interleave, which an async
45
+ * write queue would not guarantee. It is also cheap and never lets an error
46
+ * reach the caller, so a spool write failure can never break bufferEvent or
47
+ * waiter delivery. No-op if no spool is open. Errors are swallowed after one
48
+ * breadcrumb.
49
+ */
50
+ export declare function writeFrame(frame: ParsedSSE): void;
51
+ /**
52
+ * Close the fd and clear state, but KEEP the file on disk. Called on explicit
53
+ * disable and on process exit. The file survives so a just-ended session is
54
+ * still readable; the GC reclaims it after the TTL. This matches logcat, which
55
+ * also keeps its capture file on disable and lets the GC reclaim it. Never
56
+ * called on the transient device-error path.
57
+ */
58
+ export declare function stopSpool(): void;
59
+ /**
60
+ * If the open file exceeds ROTATION_BYTES, rename it `.rotated-<ts>` (readable
61
+ * until the GC reclaims it) and open a fresh file. Rotation is the one place
62
+ * capture-everything yields to bounded disk: a normal session never rotates,
63
+ * this is only the disk-safety backstop.
64
+ *
65
+ * Safe rename-not-truncate, mirroring rotateCaptureIfNeeded: truncating in
66
+ * place would leave the next append past EOF. No-op if no spool is open or the
67
+ * file is under cap. Returns the new path, or null when nothing rotated.
68
+ */
69
+ export declare function rotateSpoolIfNeeded(now?: number, capBytes?: number): string | null;
70
+ export declare function registerCleanup(): void;
71
+ export declare function _setSpoolDirForTest(dir: string): void;
72
+ export declare function _setRotationBytesForTest(n: number): void;
73
+ export declare function _setWriteCountForTest(n: number): void;
74
+ export declare function _getRotationCheckEveryForTest(): number;
75
+ export declare function _closeFdLeavingStateForTest(): void;
@@ -0,0 +1,366 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ /**
5
+ * Appends every streamed SSE frame (network, rtm, clog, log, trace) to a
6
+ * per-session file so the full session record survives buffer eviction. The
7
+ * device rings and the host push ring both drop the oldest entry once full, so
8
+ * a long session loses its early events; the spool keeps the whole session. It
9
+ * does not replace the rings. The device ring stays the source of truth: it
10
+ * holds full payloads for detail and search, carries history from before the
11
+ * host connected, and serves the dashboard and every /api endpoint. This spool
12
+ * is a downstream summary log.
13
+ *
14
+ * It captures every frame delivered to the host, not every frame the device
15
+ * produced. Frames dropped during a transient device reconnect (callRadar tears
16
+ * the stream down and reconnects) never reach bufferEvent, so they are not
17
+ * spooled. Capture starts when the stream connects; there is no device backfill.
18
+ *
19
+ * A profile switch mid-session (activate_for_user) keeps appending to the same
20
+ * file, so two profiles' frames merge with no per-frame profile tag. Fine while
21
+ * nothing reads the spool; splitting by profile is for whoever adds the reader.
22
+ *
23
+ * Modeled on logcat-capture.ts: a session-scoped host file with a time-based GC,
24
+ * exit-handler cleanup, and safe rename-not-truncate rotation. One deliberate
25
+ * difference: no idle timeout. logcat idles out because a read can come long
26
+ * after the last write; this spool must hold the entire session, so it stops
27
+ * only on explicit disable or process exit.
28
+ *
29
+ * Lifecycle: startSpool when the session is enabled; stopSpool (which closes the
30
+ * fd but keeps the file) on explicit disable and on process exit, so a just-ended
31
+ * session stays readable until the GC reclaims it. Never on a transient device
32
+ * error. callRadar clears the enabled flag on a device blip; closing the spool
33
+ * there would cut the record short on a momentary drop.
34
+ */
35
+ // Under os.tmpdir() (not ~/.slack-radar) so the OS reclaims it if the process
36
+ // dies unclean (crash, kill -9, no disable). Our own stopSpool + exit handlers +
37
+ // the GC still run first; the temp dir is the backstop for the unclean case.
38
+ // Same choice db.ts makes for its pulled-DB scratch, and same reason.
39
+ //
40
+ // Pid-scoped like db.ts's DB_TMP: every MCP shares one RADAR_PORT, so a fixed
41
+ // dir lets two concurrent MCP processes build the same events-<now>.jsonl path
42
+ // and interleave sessions, and one process's gcOldSpools would walk the other's
43
+ // live files. Scoping by pid gives each process its own dir. initializeSpoolDir
44
+ // runs at startup; the default is the unscoped path so unit tests that never
45
+ // call it still get a usable location.
46
+ let SPOOL_DIR = path.join(os.tmpdir(), "slack-radar-events");
47
+ let ROTATION_BYTES = 100 * 1024 * 1024;
48
+ const GC_TTL_MS = 7 * 24 * 60 * 60 * 1000;
49
+ // Cheap-cadence rotation check: run needsRotation only every Nth write so the
50
+ // hot path is a plain fd append almost always.
51
+ const ROTATION_CHECK_EVERY = 1000;
52
+ const state = {
53
+ fd: null,
54
+ filePath: null,
55
+ startedAt: null,
56
+ writeCount: 0,
57
+ };
58
+ // One-time breadcrumb latch: if writeFrame ever fails, log once so a spool that
59
+ // dies mid-session (ENOSPC/EACCES/EBADF) is diagnosable, without spamming a line
60
+ // per frame for the rest of the session. Reset on each startSpool.
61
+ let writeErrorLogged = false;
62
+ /**
63
+ * Point the spool dir at a per-process-unique path. Call once at startup with
64
+ * process.pid. Mirrors db.ts initializeDatabasePath: every MCP shares one port,
65
+ * so a pid key is the collision-free scope.
66
+ */
67
+ export function initializeSpoolDir(scopeKey) {
68
+ SPOOL_DIR = path.join(os.tmpdir(), `slack-radar-events-${scopeKey}`);
69
+ }
70
+ function ensureSpoolDir() {
71
+ if (!fs.existsSync(SPOOL_DIR)) {
72
+ // 0700: the spool holds raw bodies, auth headers, and message content under
73
+ // a world-traversable tmpdir, so scope it to the owner. Same choice db.ts
74
+ // makes for its pulled-DB scratch in the same location.
75
+ fs.mkdirSync(SPOOL_DIR, { recursive: true, mode: 0o700 });
76
+ }
77
+ }
78
+ /**
79
+ * Delete spool files older than GC_TTL_MS. Runs at session enable. Bounds disk
80
+ * usage without losing recent session records. dir + now injectable for tests.
81
+ * Mirrors gcOldCaptures (logcat-capture.ts). Returns the count removed.
82
+ */
83
+ export function gcOldSpools(dir = SPOOL_DIR, now = Date.now()) {
84
+ if (!fs.existsSync(dir))
85
+ return 0;
86
+ let removed = 0;
87
+ for (const name of fs.readdirSync(dir)) {
88
+ // Match the live files and their rotated archives.
89
+ if (!name.startsWith("events-") || !name.includes(".jsonl"))
90
+ continue;
91
+ const full = path.join(dir, name);
92
+ try {
93
+ const stat = fs.statSync(full);
94
+ if (now - stat.mtimeMs > GC_TTL_MS) {
95
+ fs.unlinkSync(full);
96
+ removed += 1;
97
+ }
98
+ }
99
+ catch {
100
+ // ignore; file may have vanished between readdir and stat
101
+ }
102
+ }
103
+ return removed;
104
+ }
105
+ export function buildSpoolFilePath(now = Date.now()) {
106
+ return path.join(SPOOL_DIR, `events-${now}.jsonl`);
107
+ }
108
+ // The epoch that pins a query_session cursor to one spool file: the <ts> in events-<ts>.jsonl. It
109
+ // changes on every rotation and re-enable, so a cursor minted against one file is detectably stale
110
+ // against another. Pure inverse of buildSpoolFilePath's naming; a null path (no spool) yields "".
111
+ // Owned here next to the name it strips so the mint and the check can never drift on the affixes.
112
+ export function epochFromSpoolPath(filePath) {
113
+ if (filePath === null)
114
+ return "";
115
+ return path.basename(filePath).replace(/^events-/, "").replace(/\.jsonl$/, "");
116
+ }
117
+ export function isSpooling() {
118
+ return state.fd !== null;
119
+ }
120
+ export function getSpoolFilePath() {
121
+ return state.filePath;
122
+ }
123
+ /**
124
+ * Read the current session spool back into parsed frames, oldest first (append
125
+ * order). Separate from the write path; reads the live file by its own handle,
126
+ * never touching the writer's fd or state. Returns [] when no spool is open, the
127
+ * file is gone, or it cannot be read. Never throws into the caller: a query over
128
+ * a missing or damaged spool must degrade to empty, not fail the tool.
129
+ *
130
+ * Reads the whole file and lets the query shaper's byte cap bound the OUTPUT,
131
+ * rather than tailing here: a filter can match an old frame near the top, so a
132
+ * byte-tail read could drop a frame the caller asked for. Rotation already caps
133
+ * the live file at a normal-session size, so a whole read is safe.
134
+ *
135
+ * A torn final line (the process died mid-append, before the newline) is skipped
136
+ * so one bad tail line never sinks the whole read. Any line that will not parse
137
+ * is dropped the same way.
138
+ */
139
+ export function readSpool() {
140
+ const filePath = state.filePath;
141
+ if (filePath === null)
142
+ return [];
143
+ let raw;
144
+ try {
145
+ raw = fs.readFileSync(filePath, "utf-8");
146
+ }
147
+ catch {
148
+ return [];
149
+ }
150
+ const frames = [];
151
+ for (const line of raw.split("\n")) {
152
+ if (line.length === 0)
153
+ continue;
154
+ try {
155
+ const parsed = JSON.parse(line);
156
+ // Only a well-formed frame: an object with a string `type` and an `event` object. A bare
157
+ // null/number/string (valid JSON, wrong shape) is dropped so a reader can dereference
158
+ // frame.event and frame.type without a guard on every field.
159
+ if (typeof parsed === "object" &&
160
+ parsed !== null &&
161
+ typeof parsed.type === "string" &&
162
+ typeof parsed.event === "object" &&
163
+ parsed.event !== null) {
164
+ frames.push(parsed);
165
+ }
166
+ }
167
+ catch {
168
+ // Torn or malformed line: skip it, keep the rest.
169
+ }
170
+ }
171
+ return frames;
172
+ }
173
+ /**
174
+ * Open a fresh spool file for append and set module state. Ensures the dir,
175
+ * runs gcOldSpools first, returns the path (or null on failure). Idempotent: a
176
+ * second call while a spool is open returns the current path without reopening.
177
+ */
178
+ export function startSpool(now = Date.now()) {
179
+ if (isSpooling())
180
+ return state.filePath;
181
+ try {
182
+ ensureSpoolDir();
183
+ gcOldSpools();
184
+ const filePath = buildSpoolFilePath(now);
185
+ // 0600 for the same reason as the 0700 dir: the file is raw captured
186
+ // traffic, readable only by the owner. Matches db.ts's pulled-DB scratch.
187
+ const fd = fs.openSync(filePath, "a", 0o600);
188
+ state.fd = fd;
189
+ state.filePath = filePath;
190
+ state.startedAt = now;
191
+ state.writeCount = 0;
192
+ writeErrorLogged = false;
193
+ return filePath;
194
+ }
195
+ catch {
196
+ // Failed to open the spool. Leave state clean so writeFrame no-ops.
197
+ state.fd = null;
198
+ state.filePath = null;
199
+ state.startedAt = null;
200
+ state.writeCount = 0;
201
+ return null;
202
+ }
203
+ }
204
+ /**
205
+ * Append one frame as a single JSONL line. On the hot path (every streamed
206
+ * event) and inside the wait_for_events notify path. Synchronous on purpose:
207
+ * a sync append keeps frames in arrival order with no interleave, which an async
208
+ * write queue would not guarantee. It is also cheap and never lets an error
209
+ * reach the caller, so a spool write failure can never break bufferEvent or
210
+ * waiter delivery. No-op if no spool is open. Errors are swallowed after one
211
+ * breadcrumb.
212
+ */
213
+ export function writeFrame(frame) {
214
+ if (state.fd === null)
215
+ return;
216
+ try {
217
+ // Loop until the whole buffer is written. fs.writeSync can short-write on a
218
+ // signal or a near-full disk; a partial line with no newline would fuse
219
+ // onto the next frame and break the JSONL boundary a reader would need.
220
+ const buf = Buffer.from(JSON.stringify(frame) + "\n");
221
+ let offset = 0;
222
+ while (offset < buf.length) {
223
+ offset += fs.writeSync(state.fd, buf, offset, buf.length - offset);
224
+ }
225
+ state.writeCount += 1;
226
+ if (state.writeCount % ROTATION_CHECK_EVERY === 0) {
227
+ rotateSpoolIfNeeded();
228
+ }
229
+ }
230
+ catch (err) {
231
+ // Disk full, EACCES, closed fd: swallow so the caller's waiter delivery is
232
+ // never affected. Log once so a spool that quietly died mid-session is
233
+ // diagnosable instead of silent.
234
+ if (!writeErrorLogged) {
235
+ writeErrorLogged = true;
236
+ console.error(`[radar] event spool write failed, spool may be truncated: ${err}`);
237
+ }
238
+ }
239
+ }
240
+ /**
241
+ * Close the fd and clear state, but KEEP the file on disk. Called on explicit
242
+ * disable and on process exit. The file survives so a just-ended session is
243
+ * still readable; the GC reclaims it after the TTL. This matches logcat, which
244
+ * also keeps its capture file on disable and lets the GC reclaim it. Never
245
+ * called on the transient device-error path.
246
+ */
247
+ export function stopSpool() {
248
+ const fd = state.fd;
249
+ state.fd = null;
250
+ state.filePath = null;
251
+ state.startedAt = null;
252
+ state.writeCount = 0;
253
+ if (fd !== null) {
254
+ try {
255
+ fs.closeSync(fd);
256
+ }
257
+ catch {
258
+ // already closed
259
+ }
260
+ }
261
+ }
262
+ /**
263
+ * If the open file exceeds ROTATION_BYTES, rename it `.rotated-<ts>` (readable
264
+ * until the GC reclaims it) and open a fresh file. Rotation is the one place
265
+ * capture-everything yields to bounded disk: a normal session never rotates,
266
+ * this is only the disk-safety backstop.
267
+ *
268
+ * Safe rename-not-truncate, mirroring rotateCaptureIfNeeded: truncating in
269
+ * place would leave the next append past EOF. No-op if no spool is open or the
270
+ * file is under cap. Returns the new path, or null when nothing rotated.
271
+ */
272
+ export function rotateSpoolIfNeeded(now = Date.now(), capBytes = ROTATION_BYTES) {
273
+ const fd = state.fd;
274
+ const current = state.filePath;
275
+ if (fd === null || !current)
276
+ return null;
277
+ let size;
278
+ try {
279
+ size = fs.fstatSync(fd).size;
280
+ }
281
+ catch {
282
+ return null;
283
+ }
284
+ if (size <= capBytes)
285
+ return null;
286
+ try {
287
+ fs.closeSync(fd);
288
+ }
289
+ catch {
290
+ // already closed
291
+ }
292
+ state.fd = null;
293
+ try {
294
+ fs.renameSync(current, `${current}.rotated-${now}`);
295
+ }
296
+ catch {
297
+ // Rename failure is non-fatal: the file may have vanished. Fall through
298
+ // and open a fresh spool.
299
+ }
300
+ // Reopen a fresh file. startSpool is a no-op while a spool is open, so we
301
+ // cleared state.fd above first.
302
+ const reopened = startSpool(now);
303
+ if (reopened === null) {
304
+ // Reopen failed (disk full is exactly when rotation fires). state.fd stays
305
+ // null, so writeFrame no-ops for the rest of the session. Leave one
306
+ // breadcrumb so a dead-mid-session spool is diagnosable. A reopen-retry is
307
+ // out of scope here.
308
+ console.error("[radar] event spool rotation reopen failed; spool stopped");
309
+ }
310
+ return reopened;
311
+ }
312
+ /**
313
+ * Install once at process start so the spool file is deleted when the MCP
314
+ * server exits. Copies logcat-capture.ts registerCleanup, including the
315
+ * SIGINT/SIGTERM exit codes 130/143.
316
+ */
317
+ let cleanupRegistered = false;
318
+ export function registerCleanup() {
319
+ if (cleanupRegistered)
320
+ return;
321
+ cleanupRegistered = true;
322
+ const cleanup = () => stopSpool();
323
+ process.on("exit", cleanup);
324
+ process.on("SIGINT", () => {
325
+ cleanup();
326
+ process.exit(130);
327
+ });
328
+ process.on("SIGTERM", () => {
329
+ cleanup();
330
+ process.exit(143);
331
+ });
332
+ }
333
+ // Test-only: point the spool at an isolated dir so a test never writes to the
334
+ // real one. Production uses initializeSpoolDir(pid) at startup instead.
335
+ export function _setSpoolDirForTest(dir) {
336
+ SPOOL_DIR = dir;
337
+ }
338
+ // Test-only: lower the rotation cap so the periodic writeFrame check can drive a
339
+ // real rename without writing 100MB. The internal rotate call reads this default.
340
+ export function _setRotationBytesForTest(n) {
341
+ ROTATION_BYTES = n;
342
+ }
343
+ // Test-only: lower the write count so a test can trigger the periodic rotation
344
+ // check without writing ROTATION_CHECK_EVERY frames. Not for production use.
345
+ export function _setWriteCountForTest(n) {
346
+ state.writeCount = n;
347
+ }
348
+ // Test-only: the periodic rotation cadence, so a test can drive writeFrame to
349
+ // the rotation branch without hard-coding the constant.
350
+ export function _getRotationCheckEveryForTest() {
351
+ return ROTATION_CHECK_EVERY;
352
+ }
353
+ // Test-only: close the underlying fd WITHOUT clearing state, so a test can
354
+ // prove writeFrame swallows a write-to-closed-fd error. Production code must
355
+ // never leave a closed fd in state; only stopSpool/rotate close it, and both
356
+ // null the fd.
357
+ export function _closeFdLeavingStateForTest() {
358
+ if (state.fd !== null) {
359
+ try {
360
+ fs.closeSync(state.fd);
361
+ }
362
+ catch {
363
+ // already closed
364
+ }
365
+ }
366
+ }
@@ -6,11 +6,15 @@ export interface RadarEvent {
6
6
  [key: string]: unknown;
7
7
  }
8
8
  export interface ParsedSSE {
9
- type: "network" | "rtm" | "clog" | "log" | "trace";
9
+ type: "network" | "rtm" | "clog" | "log" | "trace" | `${string}_detail`;
10
10
  event: RadarEvent;
11
11
  }
12
12
  export declare const localNetworkBuffer: RadarEvent[];
13
13
  export declare const localRtmBuffer: RadarEvent[];
14
+ /** Set the reconnect hook. Called once after each successful reconnect. Pass null to clear it. */
15
+ export declare function setOnReconnect(fn: (() => void) | null): void;
16
+ export declare function noteStreamConnected(): void;
17
+ export declare function _resetReconnectStateForTest(): void;
14
18
  export declare function isStreamConnected(): boolean;
15
19
  export declare function clearLocalBuffers(): void;
16
20
  export declare function getStreamConnection(): http.ClientRequest | null;
@@ -1,5 +1,6 @@
1
1
  import http from "http";
2
2
  import { RADAR_HOST, RADAR_PORT, MAX_LOCAL_BUFFER } from "./constants.js";
3
+ import { writeFrame } from "./event-spool.js";
3
4
  let transport = null;
4
5
  /** Set the transport used to check forwarding state. Call once at startup. */
5
6
  export function setTransport(t) {
@@ -10,6 +11,37 @@ export const localRtmBuffer = [];
10
11
  let streamConnection = null;
11
12
  let streamConnected = false;
12
13
  const eventWaiters = [];
14
+ // Fires once after a RECONNECT (not the first connect), so the owner can backfill the device ring
15
+ // rows the ~2s gap dropped. index.ts sets it to the backfill; unset it is a no-op, so the web
16
+ // server (which shares this module) and tests are unaffected. hasConnectedOnce gates it to a true
17
+ // reconnect: the enable path already backfills the first connect, so firing here too would double
18
+ // the work (dedup would drop it, but the extra device pulls are wasted).
19
+ let onReconnect = null;
20
+ let hasConnectedOnce = false;
21
+ /** Set the reconnect hook. Called once after each successful reconnect. Pass null to clear it. */
22
+ export function setOnReconnect(fn) {
23
+ onReconnect = fn;
24
+ }
25
+ // Run on every successful stream connect. Fires the reconnect hook only on a true reconnect (a
26
+ // prior connect happened), never on the first connect (the enable path backfills that one). The
27
+ // hook is guarded so it can never throw into the stream. Exported for tests so the fires-on-
28
+ // reconnect / no-op-when-unset logic is checkable without a live socket.
29
+ export function noteStreamConnected() {
30
+ if (hasConnectedOnce && onReconnect) {
31
+ try {
32
+ onReconnect();
33
+ }
34
+ catch {
35
+ // A hook failure must not break the stream read loop.
36
+ }
37
+ }
38
+ hasConnectedOnce = true;
39
+ }
40
+ // Test-only: clear the connect/hook state so a test starts from a known first-connect baseline.
41
+ export function _resetReconnectStateForTest() {
42
+ onReconnect = null;
43
+ hasConnectedOnce = false;
44
+ }
13
45
  export function isStreamConnected() {
14
46
  return streamConnected;
15
47
  }
@@ -42,6 +74,10 @@ export function bufferEvent(parsed) {
42
74
  if (localRtmBuffer.length > MAX_LOCAL_BUFFER)
43
75
  localRtmBuffer.shift();
44
76
  }
77
+ // Spool every frame to the session file. writeFrame swallows its own errors
78
+ // and no-ops when no spool is open, so this cannot break the buffer or the
79
+ // waiter-notify path below.
80
+ writeFrame(parsed);
45
81
  notifyWaiters(parsed);
46
82
  }
47
83
  function notifyWaiters(parsed) {
@@ -100,6 +136,9 @@ export function connectStream() {
100
136
  return;
101
137
  const req = http.get({ host: RADAR_HOST, port: RADAR_PORT, path: "/api/stream", timeout: 0 }, (res) => {
102
138
  streamConnected = true;
139
+ // Fire the reconnect hook on a true reconnect (not the first connect). Kept in its own fn so
140
+ // the logic is unit-testable without a live socket.
141
+ noteStreamConnected();
103
142
  let partial = "";
104
143
  res.on("data", (chunk) => {
105
144
  partial += chunk.toString();
@@ -47,6 +47,12 @@ export class LogSession {
47
47
  }
48
48
  this.child = child;
49
49
  this.lineBuf = "";
50
+ // A background diagnostic child must not keep the host process alive on its
51
+ // own. The listening dashboard socket is what holds the event loop open in
52
+ // production; when that closes (a test, or a clean shutdown) node should be
53
+ // free to exit even if a logcat capture is still running. registerCleanup
54
+ // still kills the child on exit, so unref only removes the loop-blocking ref.
55
+ child.unref?.();
50
56
  child.stdout?.on("data", (c) => this.onData(c));
51
57
  // A crashed/killed logcat (device unplugged, adb server restart) clears the
52
58
  // child so the next connect() can restart it. The retained ring survives so