@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/README.md +14 -5
- package/dist/mcp/index.js +134 -15
- package/dist/mcp/radar-response.d.ts +21 -0
- package/dist/mcp/radar-response.js +38 -0
- package/dist/mcp/session-backfill.d.ts +26 -0
- package/dist/mcp/session-backfill.js +156 -0
- package/dist/mcp/session-detail.d.ts +36 -0
- package/dist/mcp/session-detail.js +109 -0
- package/dist/mcp/session-query.d.ts +57 -0
- package/dist/mcp/session-query.js +327 -0
- package/dist/mcp/snapshot.d.ts +4 -2
- package/dist/mcp/snapshot.js +25 -12
- package/dist/mcp/tools.js +25 -3
- package/dist/shared/constants.d.ts +8 -0
- package/dist/shared/constants.js +8 -0
- package/dist/shared/db.d.ts +13 -0
- package/dist/shared/db.js +30 -3
- package/dist/shared/event-spool.d.ts +75 -0
- package/dist/shared/event-spool.js +366 -0
- package/dist/shared/stream.d.ts +5 -1
- package/dist/shared/stream.js +39 -0
- package/dist/web/log-session.js +6 -0
- package/dist/web/public/next/store.js +54 -2
- package/package.json +3 -2
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
|
|
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
|
+
}
|
package/dist/shared/stream.d.ts
CHANGED
|
@@ -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;
|
package/dist/shared/stream.js
CHANGED
|
@@ -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();
|
package/dist/web/log-session.js
CHANGED
|
@@ -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
|