@slack/radar-mcp 1.3.0 → 1.5.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 +25 -14
- package/dist/mcp/index.js +565 -374
- package/dist/mcp/tools.js +135 -10
- package/dist/shared/android.d.ts +11 -2
- package/dist/shared/android.js +17 -6
- package/dist/shared/constants.d.ts +20 -0
- package/dist/shared/constants.js +20 -0
- package/dist/shared/db.d.ts +75 -0
- package/dist/shared/db.js +403 -0
- package/dist/shared/screen.d.ts +132 -0
- package/dist/shared/screen.js +576 -0
- package/dist/web/bin.js +64 -30
- package/dist/web/log-session.d.ts +89 -0
- package/dist/web/log-session.js +144 -0
- package/dist/web/public/index.html +1065 -712
- package/dist/web/server.d.ts +29 -0
- package/dist/web/server.js +900 -28
- package/dist/web/spec.d.ts +91 -0
- package/dist/web/spec.js +153 -0
- package/package.json +2 -1
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-long host-side logcat capture for the web dashboard.
|
|
3
|
+
*
|
|
4
|
+
* The device does not buffer logcat for us (the on-device radar server captures
|
|
5
|
+
* network/RTM/clog into ring buffers, but logcat is a host-side `adb logcat`
|
|
6
|
+
* stream with no replayable device buffer). So when a browser switches dashboard
|
|
7
|
+
* tabs, the SSE `/stream` connection is torn down and reopened, and anything that
|
|
8
|
+
* used to drive logcat off the per-connection lifecycle would lose every line
|
|
9
|
+
* emitted while no stream was attached — the user sees their log count shrink on
|
|
10
|
+
* a tab switch.
|
|
11
|
+
*
|
|
12
|
+
* This module fixes that the way Android Studio does: ONE logcat process per
|
|
13
|
+
* device for the whole server session, feeding a bounded session ring buffer.
|
|
14
|
+
* Each `/stream` connection subscribes as a listener and is first replayed the
|
|
15
|
+
* entire retained buffer, then receives live lines. Switching tabs only
|
|
16
|
+
* adds/removes a listener; the underlying capture and its history are untouched.
|
|
17
|
+
*
|
|
18
|
+
* The class takes its spawn and parse functions by injection so it is unit
|
|
19
|
+
* testable without spawning a real `adb logcat` (the integration path is covered
|
|
20
|
+
* by the smoke test on a real device).
|
|
21
|
+
*/
|
|
22
|
+
import type { ChildProcess } from "child_process";
|
|
23
|
+
/** A parsed logcat frame, as delivered to the dashboard over SSE. */
|
|
24
|
+
export interface LogFrame {
|
|
25
|
+
id: number;
|
|
26
|
+
timestamp: number;
|
|
27
|
+
log_ts: string;
|
|
28
|
+
pid: number;
|
|
29
|
+
tid: number;
|
|
30
|
+
level: string;
|
|
31
|
+
tag: string;
|
|
32
|
+
message: string;
|
|
33
|
+
package?: string;
|
|
34
|
+
}
|
|
35
|
+
/** Spawn a process producing logcat lines on stdout. Injectable for tests. */
|
|
36
|
+
export type SpawnLogcat = () => ChildProcess;
|
|
37
|
+
/** Parse one raw logcat line into a frame, or null if it is not a log line. */
|
|
38
|
+
export type ParseLine = (line: string) => LogFrame | null;
|
|
39
|
+
/** Receives one frame. `replay` is true for buffered history, false for live. */
|
|
40
|
+
export type LogListener = (frame: LogFrame, replay: boolean) => void;
|
|
41
|
+
/**
|
|
42
|
+
* A single session-long logcat capture with a bounded ring buffer and listener
|
|
43
|
+
* fan-out. One instance is shared across every dashboard stream connection.
|
|
44
|
+
*/
|
|
45
|
+
export declare class LogSession {
|
|
46
|
+
private readonly spawnLogcat;
|
|
47
|
+
private readonly parseLine;
|
|
48
|
+
private readonly cap;
|
|
49
|
+
private child;
|
|
50
|
+
private lineBuf;
|
|
51
|
+
private readonly ring;
|
|
52
|
+
private readonly listeners;
|
|
53
|
+
private cleanupRegistered;
|
|
54
|
+
constructor(spawnLogcat: SpawnLogcat, parseLine: ParseLine, cap?: number);
|
|
55
|
+
/** True while a logcat child is running. */
|
|
56
|
+
isRunning(): boolean;
|
|
57
|
+
/** Number of frames currently retained (for tests / diagnostics). */
|
|
58
|
+
bufferSize(): number;
|
|
59
|
+
/** Number of attached stream listeners (for tests / diagnostics). */
|
|
60
|
+
listenerCount(): number;
|
|
61
|
+
/**
|
|
62
|
+
* Start the capture if it is not already running. Idempotent: a second call
|
|
63
|
+
* while a child is alive is a no-op, so concurrent tab opens cannot fan out
|
|
64
|
+
* duplicate logcat processes. Returns true if a capture is running after the
|
|
65
|
+
* call (already-running counts as success), false if the spawn failed.
|
|
66
|
+
*/
|
|
67
|
+
start(): boolean;
|
|
68
|
+
/** Stop the capture and drop the child. The ring buffer is preserved. */
|
|
69
|
+
stop(): void;
|
|
70
|
+
/**
|
|
71
|
+
* Install process-exit handlers (once) so the persistent `adb logcat` child is
|
|
72
|
+
* killed when the server process exits. Without this, the capture orphans when
|
|
73
|
+
* the server is terminated by signal — notably when `open_radar_dashboard`
|
|
74
|
+
* spawns the dashboard as a child and later kills it by PID — and orphaned adb
|
|
75
|
+
* logcat readers accumulate across dashboard open/close cycles, holding the
|
|
76
|
+
* device's logcat stream. Mirrors logcat-capture.ts's registerCleanup().
|
|
77
|
+
*/
|
|
78
|
+
registerCleanup(): void;
|
|
79
|
+
/**
|
|
80
|
+
* Subscribe a stream to the session. The listener is FIRST replayed the entire
|
|
81
|
+
* retained ring (replay=true) so a freshly opened tab shows full history, then
|
|
82
|
+
* receives every subsequent live frame (replay=false). Returns an unsubscribe
|
|
83
|
+
* function; call it when the stream closes (this does NOT stop the capture).
|
|
84
|
+
*/
|
|
85
|
+
subscribe(listener: LogListener): () => void;
|
|
86
|
+
private onChildGone;
|
|
87
|
+
private onData;
|
|
88
|
+
private push;
|
|
89
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { MAX_LOG_SESSION_BUFFER } from "../shared/constants.js";
|
|
2
|
+
/**
|
|
3
|
+
* A single session-long logcat capture with a bounded ring buffer and listener
|
|
4
|
+
* fan-out. One instance is shared across every dashboard stream connection.
|
|
5
|
+
*/
|
|
6
|
+
export class LogSession {
|
|
7
|
+
spawnLogcat;
|
|
8
|
+
parseLine;
|
|
9
|
+
cap;
|
|
10
|
+
child = null;
|
|
11
|
+
lineBuf = "";
|
|
12
|
+
ring = [];
|
|
13
|
+
listeners = new Set();
|
|
14
|
+
cleanupRegistered = false;
|
|
15
|
+
constructor(spawnLogcat, parseLine, cap = MAX_LOG_SESSION_BUFFER) {
|
|
16
|
+
this.spawnLogcat = spawnLogcat;
|
|
17
|
+
this.parseLine = parseLine;
|
|
18
|
+
this.cap = cap > 0 ? cap : MAX_LOG_SESSION_BUFFER;
|
|
19
|
+
}
|
|
20
|
+
/** True while a logcat child is running. */
|
|
21
|
+
isRunning() {
|
|
22
|
+
return this.child !== null;
|
|
23
|
+
}
|
|
24
|
+
/** Number of frames currently retained (for tests / diagnostics). */
|
|
25
|
+
bufferSize() {
|
|
26
|
+
return this.ring.length;
|
|
27
|
+
}
|
|
28
|
+
/** Number of attached stream listeners (for tests / diagnostics). */
|
|
29
|
+
listenerCount() {
|
|
30
|
+
return this.listeners.size;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Start the capture if it is not already running. Idempotent: a second call
|
|
34
|
+
* while a child is alive is a no-op, so concurrent tab opens cannot fan out
|
|
35
|
+
* duplicate logcat processes. Returns true if a capture is running after the
|
|
36
|
+
* call (already-running counts as success), false if the spawn failed.
|
|
37
|
+
*/
|
|
38
|
+
start() {
|
|
39
|
+
if (this.child)
|
|
40
|
+
return true;
|
|
41
|
+
let child;
|
|
42
|
+
try {
|
|
43
|
+
child = this.spawnLogcat();
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
this.child = child;
|
|
49
|
+
this.lineBuf = "";
|
|
50
|
+
child.stdout?.on("data", (c) => this.onData(c));
|
|
51
|
+
// A crashed/killed logcat (device unplugged, adb server restart) clears the
|
|
52
|
+
// child so the next connect() can restart it. The retained ring survives so
|
|
53
|
+
// history is not lost across a transient device drop.
|
|
54
|
+
child.on("error", () => this.onChildGone(child));
|
|
55
|
+
child.on("exit", () => this.onChildGone(child));
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
/** Stop the capture and drop the child. The ring buffer is preserved. */
|
|
59
|
+
stop() {
|
|
60
|
+
const child = this.child;
|
|
61
|
+
this.child = null;
|
|
62
|
+
this.lineBuf = "";
|
|
63
|
+
if (child) {
|
|
64
|
+
try {
|
|
65
|
+
child.kill();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// already dead
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Install process-exit handlers (once) so the persistent `adb logcat` child is
|
|
74
|
+
* killed when the server process exits. Without this, the capture orphans when
|
|
75
|
+
* the server is terminated by signal — notably when `open_radar_dashboard`
|
|
76
|
+
* spawns the dashboard as a child and later kills it by PID — and orphaned adb
|
|
77
|
+
* logcat readers accumulate across dashboard open/close cycles, holding the
|
|
78
|
+
* device's logcat stream. Mirrors logcat-capture.ts's registerCleanup().
|
|
79
|
+
*/
|
|
80
|
+
registerCleanup() {
|
|
81
|
+
if (this.cleanupRegistered)
|
|
82
|
+
return;
|
|
83
|
+
this.cleanupRegistered = true;
|
|
84
|
+
process.on("exit", () => this.stop());
|
|
85
|
+
process.on("SIGINT", () => {
|
|
86
|
+
this.stop();
|
|
87
|
+
process.exit(130);
|
|
88
|
+
});
|
|
89
|
+
process.on("SIGTERM", () => {
|
|
90
|
+
this.stop();
|
|
91
|
+
process.exit(143);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Subscribe a stream to the session. The listener is FIRST replayed the entire
|
|
96
|
+
* retained ring (replay=true) so a freshly opened tab shows full history, then
|
|
97
|
+
* receives every subsequent live frame (replay=false). Returns an unsubscribe
|
|
98
|
+
* function; call it when the stream closes (this does NOT stop the capture).
|
|
99
|
+
*/
|
|
100
|
+
subscribe(listener) {
|
|
101
|
+
// Replay a snapshot of the current ring. Snapshot first so a frame arriving
|
|
102
|
+
// mid-replay is not delivered twice (once via replay, once live).
|
|
103
|
+
const snapshot = this.ring.slice();
|
|
104
|
+
for (const frame of snapshot)
|
|
105
|
+
listener(frame, true);
|
|
106
|
+
this.listeners.add(listener);
|
|
107
|
+
return () => {
|
|
108
|
+
this.listeners.delete(listener);
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
onChildGone(child) {
|
|
112
|
+
// Guard against a late event from an already-replaced child.
|
|
113
|
+
if (this.child === child) {
|
|
114
|
+
this.child = null;
|
|
115
|
+
this.lineBuf = "";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
onData(chunk) {
|
|
119
|
+
this.lineBuf += chunk.toString();
|
|
120
|
+
let nl;
|
|
121
|
+
while ((nl = this.lineBuf.indexOf("\n")) >= 0) {
|
|
122
|
+
const line = this.lineBuf.slice(0, nl);
|
|
123
|
+
this.lineBuf = this.lineBuf.slice(nl + 1);
|
|
124
|
+
const frame = this.parseLine(line);
|
|
125
|
+
if (frame)
|
|
126
|
+
this.push(frame);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
push(frame) {
|
|
130
|
+
this.ring.push(frame);
|
|
131
|
+
if (this.ring.length > this.cap)
|
|
132
|
+
this.ring.shift();
|
|
133
|
+
for (const listener of this.listeners) {
|
|
134
|
+
// A throwing listener (e.g. a closed response) must not kill the capture
|
|
135
|
+
// or starve the other listeners.
|
|
136
|
+
try {
|
|
137
|
+
listener(frame, false);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// best-effort fan-out
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|