tanuki-telemetry 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/Dockerfile +22 -0
  2. package/bin/tanuki.mjs +251 -0
  3. package/frontend/eslint.config.js +23 -0
  4. package/frontend/index.html +13 -0
  5. package/frontend/package.json +39 -0
  6. package/frontend/src/App.tsx +232 -0
  7. package/frontend/src/assets/hero.png +0 -0
  8. package/frontend/src/assets/react.svg +1 -0
  9. package/frontend/src/assets/vite.svg +1 -0
  10. package/frontend/src/components/ArtifactsPanel.tsx +429 -0
  11. package/frontend/src/components/ChildStreams.tsx +176 -0
  12. package/frontend/src/components/CoordinatorPage.tsx +317 -0
  13. package/frontend/src/components/Header.tsx +108 -0
  14. package/frontend/src/components/InsightsPanel.tsx +142 -0
  15. package/frontend/src/components/IterationsTable.tsx +98 -0
  16. package/frontend/src/components/KnowledgePage.tsx +308 -0
  17. package/frontend/src/components/LoginPage.tsx +55 -0
  18. package/frontend/src/components/PlanProgress.tsx +163 -0
  19. package/frontend/src/components/QualityReport.tsx +276 -0
  20. package/frontend/src/components/ScreenshotUpload.tsx +117 -0
  21. package/frontend/src/components/ScreenshotsGrid.tsx +266 -0
  22. package/frontend/src/components/SessionDetail.tsx +265 -0
  23. package/frontend/src/components/SessionList.tsx +234 -0
  24. package/frontend/src/components/SettingsPage.tsx +213 -0
  25. package/frontend/src/components/StreamComms.tsx +228 -0
  26. package/frontend/src/components/TanukiLogo.tsx +16 -0
  27. package/frontend/src/components/Timeline.tsx +416 -0
  28. package/frontend/src/components/WalkthroughPage.tsx +458 -0
  29. package/frontend/src/hooks/useApi.ts +81 -0
  30. package/frontend/src/hooks/useAuth.ts +54 -0
  31. package/frontend/src/hooks/useKnowledge.ts +33 -0
  32. package/frontend/src/hooks/useWebSocket.ts +95 -0
  33. package/frontend/src/index.css +66 -0
  34. package/frontend/src/lib/api.ts +15 -0
  35. package/frontend/src/lib/utils.ts +58 -0
  36. package/frontend/src/main.tsx +10 -0
  37. package/frontend/src/types.ts +181 -0
  38. package/frontend/tsconfig.app.json +32 -0
  39. package/frontend/tsconfig.json +7 -0
  40. package/frontend/vite.config.ts +25 -0
  41. package/install.sh +87 -0
  42. package/package.json +63 -0
  43. package/src/api-keys.ts +97 -0
  44. package/src/auth.ts +165 -0
  45. package/src/coordinator.ts +136 -0
  46. package/src/dashboard-server.ts +5 -0
  47. package/src/dashboard.ts +826 -0
  48. package/src/db.ts +1009 -0
  49. package/src/index.ts +20 -0
  50. package/src/middleware.ts +76 -0
  51. package/src/tools.ts +864 -0
  52. package/src/types-shim.d.ts +18 -0
  53. package/src/types.ts +171 -0
  54. package/tsconfig.json +19 -0
@@ -0,0 +1,826 @@
1
+ import express from "express";
2
+ import path from "path";
3
+ import fs from "fs";
4
+ import http from "http";
5
+ import { WebSocketServer, WebSocket } from "ws";
6
+ import multer from "multer";
7
+ import session from "express-session";
8
+ import passport from "passport";
9
+ import Database from "better-sqlite3";
10
+ import BetterSqlite3SessionStore from "better-sqlite3-session-store";
11
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
+ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
13
+ import { getDb, listSessions } from "./db.js";
14
+ import { registerTools } from "./tools.js";
15
+ import { initAuthTables, setupPassport, createAuthRouter } from "./auth.js";
16
+ import { createApiKeyRouter } from "./api-keys.js";
17
+ import { dashboardAuthMiddleware, apiKeyMiddleware, wsAuthCheck, getUserEmailFromRequest, AUTH_ENABLED } from "./middleware.js";
18
+ import type { Session, Event, Iteration, Screenshot, Artifact, Insight, PlanStep } from "./types.js";
19
+ import { listCoordinatorSessions, getCoordinatorState, getCoordinatorHistory } from "./coordinator.js";
20
+ import { fileURLToPath } from "url";
21
+
22
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
+
24
+ const app = express();
25
+ app.set("trust proxy", 1); // Trust Railway's reverse proxy for secure cookies
26
+ app.use(express.json());
27
+ const DASHBOARD_PORT = parseInt(process.env.PORT || "3333", 10);
28
+ const POLL_INTERVAL_MS = 1000;
29
+
30
+ // --- Auth setup ---
31
+ if (AUTH_ENABLED) {
32
+ initAuthTables();
33
+ setupPassport();
34
+ }
35
+
36
+ const SqliteStore = BetterSqlite3SessionStore(session);
37
+
38
+ // Use a separate SQLite DB for session store to avoid table name conflict
39
+ // (the store hardcodes table name "sessions" which conflicts with our telemetry sessions table)
40
+ let sessionStoreInstance: InstanceType<typeof SqliteStore> | undefined;
41
+ if (AUTH_ENABLED) {
42
+ const dbPath = process.env.DB_PATH || "/data/telemetry.db";
43
+ const sessionDbPath = dbPath.replace(/\.db$/, "-sessions.db");
44
+ const sessionDb = new Database(sessionDbPath);
45
+ sessionDb.pragma("journal_mode = WAL");
46
+ sessionStoreInstance = new SqliteStore({
47
+ client: sessionDb,
48
+ expired: { clear: true, intervalMs: 900000 },
49
+ });
50
+ }
51
+
52
+ app.use(
53
+ session({
54
+ store: sessionStoreInstance,
55
+ secret: process.env.SESSION_SECRET || "telemetry-local-dev-secret",
56
+ resave: false,
57
+ saveUninitialized: false,
58
+ cookie: {
59
+ secure: process.env.NODE_ENV === "production",
60
+ httpOnly: true,
61
+ maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
62
+ sameSite: "lax",
63
+ },
64
+ })
65
+ );
66
+
67
+ if (AUTH_ENABLED) {
68
+ app.use(passport.initialize());
69
+ app.use(passport.session());
70
+ app.use("/auth", createAuthRouter());
71
+ }
72
+
73
+ const TANUKI_VERSION = "1.1.0";
74
+
75
+ // Health check (unauthenticated — for Railway)
76
+ app.get("/health", (_req, res) => {
77
+ res.json({ ok: true, version: TANUKI_VERSION });
78
+ });
79
+
80
+ // Version endpoint
81
+ app.get("/api/version", (_req, res) => {
82
+ res.json({ version: TANUKI_VERSION });
83
+ });
84
+
85
+ // API key management routes
86
+ app.use("/api/keys", createApiKeyRouter());
87
+
88
+ // Apply MCP auth
89
+ app.use("/mcp", apiKeyMiddleware);
90
+
91
+ // Configure multer for screenshot uploads
92
+ const upload = multer({
93
+ storage: multer.diskStorage({
94
+ destination: (_req, _file, cb) => {
95
+ const sessionId = String(_req.params.sessionId || "unknown");
96
+ // Look up the session to get worktree_name for directory
97
+ const d = getDb();
98
+ const session = d.prepare("SELECT worktree_name FROM sessions WHERE id = ?").get(sessionId) as { worktree_name: string } | undefined;
99
+ const dirName = session?.worktree_name || sessionId;
100
+ const dir = path.join("/data", dirName, "screenshots");
101
+ fs.mkdirSync(dir, { recursive: true });
102
+ cb(null, dir);
103
+ },
104
+ filename: (_req, file, cb) => {
105
+ // Preserve original name or generate timestamp-based name
106
+ const ext = path.extname(file.originalname) || ".png";
107
+ const base = path.basename(file.originalname, ext).replace(/[^a-zA-Z0-9_-]/g, "_");
108
+ const timestamp = Date.now();
109
+ cb(null, `${base}-${timestamp}${ext}`);
110
+ },
111
+ }),
112
+ limits: { fileSize: 20 * 1024 * 1024 }, // 20MB max
113
+ fileFilter: (_req, file, cb) => {
114
+ if (file.mimetype.startsWith("image/")) {
115
+ cb(null, true);
116
+ } else {
117
+ cb(new Error("Only image files allowed"));
118
+ }
119
+ },
120
+ });
121
+
122
+ // --- API Routes (protected by dashboard auth) ---
123
+
124
+ app.use("/api", dashboardAuthMiddleware);
125
+
126
+ app.get("/api/sessions", (_req, res) => {
127
+ const limit = parseInt(_req.query.limit as string) || 20;
128
+ const status = _req.query.status as string | undefined;
129
+ const parentId = _req.query.parent_session_id as string | undefined;
130
+ const userEmail = _req.query.user_email as string | undefined;
131
+
132
+ const sessions = listSessions(limit, status, parentId, userEmail);
133
+ res.json(sessions);
134
+ });
135
+
136
+ app.get("/api/sessions/:id", (req, res) => {
137
+ const d = getDb();
138
+ const session = d
139
+ .prepare("SELECT * FROM sessions WHERE id = ?")
140
+ .get(req.params.id) as Session | undefined;
141
+
142
+ if (!session) {
143
+ res.status(404).json({ error: "Session not found" });
144
+ return;
145
+ }
146
+
147
+ const events = d
148
+ .prepare("SELECT * FROM events WHERE session_id = ? ORDER BY timestamp ASC")
149
+ .all(session.id) as Event[];
150
+
151
+ const iterations = d
152
+ .prepare(
153
+ "SELECT * FROM iterations WHERE session_id = ? ORDER BY iteration_number ASC"
154
+ )
155
+ .all(session.id) as Iteration[];
156
+
157
+ const screenshots = d
158
+ .prepare(
159
+ "SELECT * FROM screenshots WHERE session_id = ? ORDER BY timestamp ASC"
160
+ )
161
+ .all(session.id) as Screenshot[];
162
+
163
+ const artifacts = d
164
+ .prepare(
165
+ "SELECT * FROM artifacts WHERE session_id = ? ORDER BY timestamp ASC"
166
+ )
167
+ .all(session.id) as Artifact[];
168
+
169
+ const insights = d
170
+ .prepare(
171
+ "SELECT * FROM insights WHERE session_id = ? ORDER BY confidence DESC"
172
+ )
173
+ .all(session.id) as Insight[];
174
+
175
+ const plan_steps = d
176
+ .prepare(
177
+ "SELECT * FROM plan_steps WHERE session_id = ? ORDER BY step_number ASC"
178
+ )
179
+ .all(session.id) as PlanStep[];
180
+
181
+ const child_sessions = d
182
+ .prepare(
183
+ "SELECT * FROM sessions WHERE parent_session_id = ? ORDER BY created_at ASC"
184
+ )
185
+ .all(session.id) as Session[];
186
+
187
+ res.json({ session, events, iterations, screenshots, artifacts, insights, plan_steps, child_sessions });
188
+ });
189
+
190
+ app.get("/api/stats", (_req, res) => {
191
+ const d = getDb();
192
+
193
+ const total = (
194
+ d.prepare("SELECT COUNT(*) as count FROM sessions").get() as {
195
+ count: number;
196
+ }
197
+ ).count;
198
+
199
+ const avgIterations = (
200
+ d
201
+ .prepare(
202
+ "SELECT AVG(total_iterations) as avg FROM sessions WHERE status IN ('completed','failed')"
203
+ )
204
+ .get() as { avg: number | null }
205
+ ).avg;
206
+
207
+ const avgDuration = (
208
+ d
209
+ .prepare(
210
+ "SELECT AVG(duration_seconds) as avg FROM sessions WHERE duration_seconds IS NOT NULL"
211
+ )
212
+ .get() as { avg: number | null }
213
+ ).avg;
214
+
215
+ const totalTokens = (
216
+ d
217
+ .prepare(
218
+ "SELECT SUM(total_input_tokens + total_output_tokens) as total FROM sessions"
219
+ )
220
+ .get() as { total: number | null }
221
+ ).total;
222
+
223
+ const completed = (
224
+ d
225
+ .prepare("SELECT COUNT(*) as count FROM sessions WHERE status = 'completed'")
226
+ .get() as { count: number }
227
+ ).count;
228
+
229
+ const failed = (
230
+ d
231
+ .prepare("SELECT COUNT(*) as count FROM sessions WHERE status = 'failed'")
232
+ .get() as { count: number }
233
+ ).count;
234
+
235
+ res.json({
236
+ total_sessions: total,
237
+ avg_iterations: avgIterations ? Math.round(avgIterations * 10) / 10 : 0,
238
+ avg_duration_seconds: avgDuration ? Math.round(avgDuration) : 0,
239
+ total_tokens: totalTokens ?? 0,
240
+ completed,
241
+ failed,
242
+ pass_rate:
243
+ completed + failed > 0
244
+ ? Math.round((completed / (completed + failed)) * 100)
245
+ : 0,
246
+ });
247
+ });
248
+
249
+ // Knowledge API
250
+ app.get("/api/knowledge", (_req, res) => {
251
+ const d = getDb();
252
+ const category = _req.query.category as string | undefined;
253
+ const limit = parseInt(_req.query.limit as string) || 50;
254
+
255
+ let insights: Insight[];
256
+ if (category) {
257
+ insights = d
258
+ .prepare(
259
+ "SELECT * FROM insights WHERE category = ? ORDER BY confidence DESC, times_validated DESC LIMIT ?"
260
+ )
261
+ .all(category, limit) as Insight[];
262
+ } else {
263
+ insights = d
264
+ .prepare(
265
+ "SELECT * FROM insights ORDER BY confidence DESC, times_validated DESC LIMIT ?"
266
+ )
267
+ .all(limit) as Insight[];
268
+ }
269
+
270
+ const categories = d
271
+ .prepare(
272
+ "SELECT category, COUNT(*) as count, AVG(confidence) as avg_confidence FROM insights GROUP BY category ORDER BY count DESC"
273
+ )
274
+ .all() as Array<{ category: string; count: number; avg_confidence: number }>;
275
+
276
+ res.json({ insights, categories, total: insights.length });
277
+ });
278
+
279
+ // Upload screenshot
280
+ app.post("/api/sessions/:sessionId/screenshots", upload.single("file"), (req, res) => {
281
+ const d = getDb();
282
+ const { sessionId } = req.params;
283
+ const file = req.file;
284
+
285
+ if (!file) {
286
+ res.status(400).json({ error: "No file uploaded" });
287
+ return;
288
+ }
289
+
290
+ // Verify session exists
291
+ const session = d.prepare("SELECT id, worktree_name FROM sessions WHERE id = ?").get(sessionId) as { id: string; worktree_name: string } | undefined;
292
+ if (!session) {
293
+ // Clean up uploaded file
294
+ fs.unlinkSync(file.path);
295
+ res.status(404).json({ error: "Session not found" });
296
+ return;
297
+ }
298
+
299
+ const description = (req.body?.description as string) || file.originalname;
300
+ const phase = (req.body?.phase as string) || "verification";
301
+ const iterationNumber = req.body?.iteration_number ? parseInt(req.body.iteration_number) : null;
302
+
303
+ // Insert into screenshots table
304
+ const stmt = d.prepare(`
305
+ INSERT INTO screenshots (session_id, iteration_number, phase, description, file_path)
306
+ VALUES (?, ?, ?, ?, ?)
307
+ `);
308
+ const result = stmt.run(
309
+ sessionId,
310
+ iterationNumber,
311
+ phase,
312
+ description,
313
+ file.path
314
+ );
315
+
316
+ res.json({
317
+ screenshot_id: Number(result.lastInsertRowid),
318
+ file_path: file.path,
319
+ url: `/api/screenshots/${session.worktree_name}/screenshots/${file.filename}`,
320
+ });
321
+ });
322
+
323
+ // Coordinator API
324
+ app.get("/api/coordinator/sessions", (_req, res) => {
325
+ const limit = parseInt(_req.query.limit as string) || 10;
326
+ const sessions = listCoordinatorSessions(limit);
327
+ res.json(sessions);
328
+ });
329
+
330
+ app.get("/api/coordinator/sessions/:id", (req, res) => {
331
+ const state = getCoordinatorState(req.params.id);
332
+ if (!state) {
333
+ res.status(404).json({ error: "Coordinator session not found" });
334
+ return;
335
+ }
336
+ const history = getCoordinatorHistory(state.session_id);
337
+ res.json({ state, history });
338
+ });
339
+
340
+ // Coordinator live feed — latest events across all workspace sessions
341
+ app.get("/api/coordinator/sessions/:id/live", (req, res) => {
342
+ const state = getCoordinatorState(req.params.id);
343
+ if (!state) {
344
+ res.status(404).json({ error: "Coordinator session not found" });
345
+ return;
346
+ }
347
+
348
+ const d = getDb();
349
+ const sessionIds = Object.values(state.workspaces)
350
+ .map((ws) => ws.session_id)
351
+ .filter(Boolean) as string[];
352
+
353
+ if (sessionIds.length === 0) {
354
+ res.json({ events: [], workspace_events: {} });
355
+ return;
356
+ }
357
+
358
+ const placeholders = sessionIds.map(() => "?").join(",");
359
+
360
+ // Last 50 events across all workspace sessions
361
+ const events = d.prepare(`
362
+ SELECT e.*, s.worktree_name FROM events e
363
+ JOIN sessions s ON e.session_id = s.id
364
+ WHERE e.session_id IN (${placeholders})
365
+ ORDER BY e.timestamp DESC LIMIT 50
366
+ `).all(...sessionIds) as Array<Event & { worktree_name: string }>;
367
+
368
+ // Last event per workspace session
369
+ const workspaceEvents: Record<string, Event & { worktree_name: string }> = {};
370
+ for (const sid of sessionIds) {
371
+ const last = d.prepare(`
372
+ SELECT e.*, s.worktree_name FROM events e
373
+ JOIN sessions s ON e.session_id = s.id
374
+ WHERE e.session_id = ?
375
+ ORDER BY e.timestamp DESC LIMIT 1
376
+ `).get(sid) as (Event & { worktree_name: string }) | undefined;
377
+ if (last) workspaceEvents[sid] = last;
378
+ }
379
+
380
+ res.json({ events: events.reverse(), workspace_events: workspaceEvents });
381
+ });
382
+
383
+ // Serve artifact by database ID
384
+ app.get("/api/artifacts/by-id/:id", (req, res) => {
385
+ const d = getDb();
386
+ const artifact = d.prepare("SELECT stored_path, file_path, mime_type FROM artifacts WHERE id = ?").get(req.params.id) as { stored_path: string | null; file_path: string; mime_type: string | null } | undefined;
387
+
388
+ if (!artifact) {
389
+ res.status(404).json({ error: "Artifact not found" });
390
+ return;
391
+ }
392
+
393
+ const candidates = [
394
+ artifact.stored_path,
395
+ artifact.file_path,
396
+ artifact.file_path?.replace(/^.*?outputs\//, "/data/"),
397
+ ].filter(Boolean) as string[];
398
+
399
+ for (const candidate of candidates) {
400
+ if (fs.existsSync(candidate)) {
401
+ if (artifact.mime_type) {
402
+ res.type(artifact.mime_type);
403
+ }
404
+ res.sendFile(path.resolve(candidate));
405
+ return;
406
+ }
407
+ }
408
+
409
+ res.status(404).json({ error: "Artifact file not found on disk" });
410
+ });
411
+
412
+ // Serve screenshot by database ID — self-contained, doesn't need volume path mapping
413
+ app.get("/api/screenshots/by-id/:id", (req, res) => {
414
+ const d = getDb();
415
+ const screenshot = d.prepare("SELECT stored_path, thumb_path, file_path FROM screenshots WHERE id = ?").get(req.params.id) as { stored_path: string | null; thumb_path: string | null; file_path: string } | undefined;
416
+
417
+ if (!screenshot) {
418
+ res.status(404).json({ error: "Screenshot not found" });
419
+ return;
420
+ }
421
+
422
+ const wantThumb = req.query.size === "thumb";
423
+
424
+ // If thumbnail requested and available, serve it
425
+ if (wantThumb && screenshot.thumb_path && fs.existsSync(screenshot.thumb_path)) {
426
+ res.sendFile(path.resolve(screenshot.thumb_path));
427
+ return;
428
+ }
429
+
430
+ // Serve full-size: prefer stored_path, fall back to file_path via volume
431
+ const candidates = [
432
+ screenshot.stored_path,
433
+ screenshot.file_path,
434
+ screenshot.file_path?.replace(/^.*?outputs\//, "/data/"),
435
+ ].filter(Boolean) as string[];
436
+
437
+ for (const candidate of candidates) {
438
+ if (fs.existsSync(candidate)) {
439
+ res.sendFile(path.resolve(candidate));
440
+ return;
441
+ }
442
+ }
443
+
444
+ res.status(404).json({ error: "Screenshot file not found on disk" });
445
+ });
446
+
447
+ // --- Walkthrough API (must be before /api/screenshots/* wildcard) ---
448
+
449
+ app.get("/api/walkthroughs", (_req, res) => {
450
+ const d = getDb();
451
+ const limit = parseInt(_req.query.limit as string) || 50;
452
+ const walkthroughs = d.prepare(
453
+ "SELECT * FROM walkthroughs ORDER BY started_at DESC LIMIT ?"
454
+ ).all(limit);
455
+ res.json({ walkthroughs });
456
+ });
457
+
458
+ app.get("/api/walkthroughs/:id", (req, res) => {
459
+ const d = getDb();
460
+ const id = parseInt(req.params.id, 10);
461
+ if (isNaN(id)) {
462
+ res.status(400).json({ error: "Invalid walkthrough ID" });
463
+ return;
464
+ }
465
+
466
+ const walkthrough = d.prepare("SELECT * FROM walkthroughs WHERE id = ?").get(id);
467
+ if (!walkthrough) {
468
+ res.status(404).json({ error: "Walkthrough not found" });
469
+ return;
470
+ }
471
+
472
+ const actions = d.prepare(
473
+ "SELECT * FROM walkthrough_actions WHERE walkthrough_id = ? ORDER BY timestamp ASC"
474
+ ).all(id);
475
+
476
+ const screenshots = d.prepare(
477
+ "SELECT * FROM walkthrough_screenshots WHERE walkthrough_id = ? ORDER BY timestamp ASC"
478
+ ).all(id);
479
+
480
+ res.json({ walkthrough, actions, screenshots });
481
+ });
482
+
483
+ app.get("/api/walkthroughs/:id/screenshots/:screenshotId", (req, res) => {
484
+ const d = getDb();
485
+ const screenshotId = parseInt(req.params.screenshotId, 10);
486
+ if (isNaN(screenshotId)) {
487
+ res.status(400).json({ error: "Invalid screenshot ID" });
488
+ return;
489
+ }
490
+
491
+ const screenshot = d.prepare(
492
+ "SELECT stored_path FROM walkthrough_screenshots WHERE id = ? AND walkthrough_id = ?"
493
+ ).get(screenshotId, parseInt(req.params.id, 10)) as { stored_path: string } | undefined;
494
+
495
+ if (!screenshot || !fs.existsSync(screenshot.stored_path)) {
496
+ res.status(404).json({ error: "Screenshot not found" });
497
+ return;
498
+ }
499
+
500
+ res.sendFile(path.resolve(screenshot.stored_path));
501
+ });
502
+
503
+ // Serve screenshot files from /data mount (legacy path-based)
504
+ app.get("/api/screenshots/*", (req, res) => {
505
+ const requestedPath = (req.params as unknown as Record<string, string>)["0"];
506
+ let filePath: string;
507
+ if (requestedPath.startsWith("/")) {
508
+ filePath = requestedPath;
509
+ } else {
510
+ filePath = path.join("/data", requestedPath);
511
+ }
512
+
513
+ const resolved = path.resolve(filePath);
514
+ if (!resolved.startsWith("/data")) {
515
+ res.status(403).json({ error: "Access denied" });
516
+ return;
517
+ }
518
+
519
+ if (!fs.existsSync(resolved)) {
520
+ res.status(404).json({ error: "File not found" });
521
+ return;
522
+ }
523
+
524
+ res.sendFile(resolved);
525
+ });
526
+
527
+ // --- MCP SSE Transport ---
528
+
529
+ const mcpTransports: Record<string, SSEServerTransport> = {};
530
+
531
+ // SSE endpoint — clients connect here to establish the MCP stream
532
+ app.get("/mcp/sse", async (req, res) => {
533
+ console.error("[telemetry] New MCP SSE connection");
534
+ try {
535
+ const transport = new SSEServerTransport("/mcp/messages", res);
536
+ const sessionId = transport.sessionId;
537
+ mcpTransports[sessionId] = transport;
538
+
539
+ transport.onclose = () => {
540
+ console.error(`[telemetry] MCP SSE closed: ${sessionId}`);
541
+ delete mcpTransports[sessionId];
542
+ };
543
+
544
+ // Create a fresh MCP server for this connection, injecting the authenticated user's email
545
+ const connUserEmail = getUserEmailFromRequest(req);
546
+ const mcpServer = new McpServer({
547
+ name: "telemetry-mcp",
548
+ version: "1.0.0",
549
+ });
550
+ registerTools(mcpServer, connUserEmail);
551
+ await mcpServer.connect(transport);
552
+
553
+ console.error(`[telemetry] MCP SSE established: ${sessionId}`);
554
+ } catch (error) {
555
+ console.error("[telemetry] Error establishing MCP SSE:", error);
556
+ if (!res.headersSent) {
557
+ res.status(500).send("Error establishing SSE stream");
558
+ }
559
+ }
560
+ });
561
+
562
+ // Messages endpoint — clients POST JSON-RPC messages here
563
+ app.post("/mcp/messages", async (req, res) => {
564
+ const sessionId = req.query.sessionId as string;
565
+ const transport = mcpTransports[sessionId];
566
+
567
+ if (!transport) {
568
+ res.status(400).json({ error: "Unknown session ID" });
569
+ return;
570
+ }
571
+
572
+ try {
573
+ await transport.handlePostMessage(req, res, req.body);
574
+ } catch (error) {
575
+ console.error("[telemetry] Error handling MCP message:", error);
576
+ if (!res.headersSent) {
577
+ res.status(500).send("Error handling request");
578
+ }
579
+ }
580
+ });
581
+
582
+ // --- Serve React frontend ---
583
+
584
+ const frontendDist = path.resolve(__dirname, "../frontend/dist");
585
+
586
+ if (fs.existsSync(frontendDist)) {
587
+ app.use(express.static(frontendDist));
588
+
589
+ // SPA fallback — serve index.html for non-API routes
590
+ app.get("*", (_req, res) => {
591
+ res.sendFile(path.join(frontendDist, "index.html"));
592
+ });
593
+ } else {
594
+ app.get("/", (_req, res) => {
595
+ res.type("html").send(`<!DOCTYPE html>
596
+ <html><head><title>tanuki</title></head>
597
+ <body style="background:#0a0a0a;color:#666;font-family:monospace;display:flex;align-items:center;justify-content:center;height:100vh;">
598
+ <pre>frontend not built — run: cd frontend && npm run build</pre>
599
+ </body></html>`);
600
+ });
601
+ }
602
+
603
+ // --- WebSocket live feed ---
604
+
605
+ interface WaterMark {
606
+ lastEventId: number;
607
+ lastIterationId: number;
608
+ lastScreenshotId: number;
609
+ lastArtifactId: number;
610
+ lastInsightId: number;
611
+ lastPlanStepId: number;
612
+ planStepVersions: Map<number, string>;
613
+ sessionVersions: Map<string, string>;
614
+ coordinatorVersions: Map<string, string>; // session_id -> updated_at
615
+ }
616
+
617
+ function getWaterMark(): WaterMark {
618
+ const d = getDb();
619
+ const lastEvent = d.prepare("SELECT MAX(id) as id FROM events").get() as { id: number | null };
620
+ const lastIteration = d.prepare("SELECT MAX(id) as id FROM iterations").get() as { id: number | null };
621
+ const lastScreenshot = d.prepare("SELECT MAX(id) as id FROM screenshots").get() as { id: number | null };
622
+ const lastArtifact = d.prepare("SELECT MAX(id) as id FROM artifacts").get() as { id: number | null };
623
+ const lastInsight = d.prepare("SELECT MAX(id) as id FROM insights").get() as { id: number | null };
624
+ const lastPlanStep = d.prepare("SELECT MAX(id) as id FROM plan_steps").get() as { id: number | null };
625
+
626
+ const sessions = d.prepare(
627
+ "SELECT id, status, total_input_tokens, total_output_tokens, total_iterations, duration_seconds FROM sessions ORDER BY created_at DESC LIMIT 20"
628
+ ).all() as Array<{ id: string; status: string; total_input_tokens: number; total_output_tokens: number; total_iterations: number; duration_seconds: number | null }>;
629
+
630
+ const sessionVersions = new Map<string, string>();
631
+ for (const s of sessions) {
632
+ sessionVersions.set(s.id, `${s.status}:${s.total_input_tokens}:${s.total_output_tokens}:${s.total_iterations}:${s.duration_seconds}`);
633
+ }
634
+
635
+ // Track plan step statuses for change detection
636
+ const planSteps = d.prepare(
637
+ "SELECT id, status, outcome FROM plan_steps ORDER BY id DESC LIMIT 100"
638
+ ).all() as Array<{ id: number; status: string; outcome: string | null }>;
639
+
640
+ const planStepVersions = new Map<number, string>();
641
+ for (const ps of planSteps) {
642
+ planStepVersions.set(ps.id, `${ps.status}:${ps.outcome ?? ""}`);
643
+ }
644
+
645
+ // Track coordinator state changes
646
+ const coordinatorRows = d.prepare(
647
+ "SELECT session_id, updated_at FROM coordinator_state"
648
+ ).all() as Array<{ session_id: string; updated_at: string }>;
649
+
650
+ const coordinatorVersions = new Map<string, string>();
651
+ for (const cr of coordinatorRows) {
652
+ coordinatorVersions.set(cr.session_id, cr.updated_at);
653
+ }
654
+
655
+ return {
656
+ lastEventId: lastEvent.id ?? 0,
657
+ lastIterationId: lastIteration.id ?? 0,
658
+ lastScreenshotId: lastScreenshot.id ?? 0,
659
+ lastArtifactId: lastArtifact.id ?? 0,
660
+ lastInsightId: lastInsight.id ?? 0,
661
+ lastPlanStepId: lastPlanStep.id ?? 0,
662
+ planStepVersions,
663
+ sessionVersions,
664
+ coordinatorVersions,
665
+ };
666
+ }
667
+
668
+ function diffAndBroadcast(wss: WebSocketServer, prev: WaterMark): WaterMark {
669
+ const d = getDb();
670
+ const current = getWaterMark();
671
+ const messages: Array<{ type: string; data: unknown }> = [];
672
+
673
+ // New events
674
+ if (current.lastEventId > prev.lastEventId) {
675
+ const newEvents = d.prepare(
676
+ "SELECT * FROM events WHERE id > ? ORDER BY id ASC"
677
+ ).all(prev.lastEventId) as Event[];
678
+ for (const ev of newEvents) {
679
+ messages.push({ type: "event", data: ev });
680
+ }
681
+ }
682
+
683
+ // New iterations
684
+ if (current.lastIterationId > prev.lastIterationId) {
685
+ const newIterations = d.prepare(
686
+ "SELECT * FROM iterations WHERE id > ? ORDER BY id ASC"
687
+ ).all(prev.lastIterationId) as import("./types.js").Iteration[];
688
+ for (const it of newIterations) {
689
+ messages.push({ type: "iteration", data: it });
690
+ }
691
+ }
692
+
693
+ // New screenshots
694
+ if (current.lastScreenshotId > prev.lastScreenshotId) {
695
+ const newScreenshots = d.prepare(
696
+ "SELECT * FROM screenshots WHERE id > ? ORDER BY id ASC"
697
+ ).all(prev.lastScreenshotId) as Screenshot[];
698
+ for (const sc of newScreenshots) {
699
+ messages.push({ type: "screenshot", data: sc });
700
+ }
701
+ }
702
+
703
+ // New artifacts
704
+ if (current.lastArtifactId > prev.lastArtifactId) {
705
+ const newArtifacts = d.prepare(
706
+ "SELECT * FROM artifacts WHERE id > ? ORDER BY id ASC"
707
+ ).all(prev.lastArtifactId) as Artifact[];
708
+ for (const art of newArtifacts) {
709
+ messages.push({ type: "artifact", data: art });
710
+ }
711
+ }
712
+
713
+ // New insights
714
+ if (current.lastInsightId > prev.lastInsightId) {
715
+ const newInsights = d.prepare(
716
+ "SELECT * FROM insights WHERE id > ? ORDER BY id ASC"
717
+ ).all(prev.lastInsightId) as Insight[];
718
+ for (const ins of newInsights) {
719
+ messages.push({ type: "insight", data: ins });
720
+ }
721
+ }
722
+
723
+ // New plan steps
724
+ if (current.lastPlanStepId > prev.lastPlanStepId) {
725
+ const newSteps = d.prepare(
726
+ "SELECT * FROM plan_steps WHERE id > ? ORDER BY id ASC"
727
+ ).all(prev.lastPlanStepId) as PlanStep[];
728
+ for (const step of newSteps) {
729
+ messages.push({ type: "plan_step_new", data: step });
730
+ }
731
+ }
732
+
733
+ // Plan step status changes
734
+ for (const [id, version] of current.planStepVersions) {
735
+ const prevVersion = prev.planStepVersions.get(id);
736
+ if (prevVersion !== undefined && prevVersion !== version) {
737
+ const step = d.prepare("SELECT * FROM plan_steps WHERE id = ?").get(id) as PlanStep;
738
+ messages.push({ type: "plan_step_update", data: step });
739
+ }
740
+ }
741
+
742
+ // Coordinator state changes
743
+ for (const [id, updatedAt] of current.coordinatorVersions) {
744
+ const prevUpdatedAt = prev.coordinatorVersions.get(id);
745
+ if (prevUpdatedAt !== updatedAt) {
746
+ messages.push({ type: "coordinator_update", data: { session_id: id, updated_at: updatedAt } });
747
+ }
748
+ }
749
+
750
+ // Session status changes
751
+ for (const [id, version] of current.sessionVersions) {
752
+ const prevVersion = prev.sessionVersions.get(id);
753
+ if (prevVersion !== version) {
754
+ const session = d.prepare("SELECT * FROM sessions WHERE id = ?").get(id) as Session;
755
+ messages.push({ type: "session_update", data: session });
756
+ }
757
+ }
758
+
759
+ // New sessions (in current but not in prev)
760
+ for (const [id] of current.sessionVersions) {
761
+ if (!prev.sessionVersions.has(id)) {
762
+ const session = d.prepare("SELECT * FROM sessions WHERE id = ?").get(id) as Session;
763
+ messages.push({ type: "session_new", data: session });
764
+ }
765
+ }
766
+
767
+ // Broadcast
768
+ if (messages.length > 0) {
769
+ const payload = JSON.stringify({ messages });
770
+ for (const client of wss.clients) {
771
+ if (client.readyState === WebSocket.OPEN) {
772
+ client.send(payload);
773
+ }
774
+ }
775
+ }
776
+
777
+ return current;
778
+ }
779
+
780
+ export function startDashboard(): void {
781
+ const server = http.createServer(app);
782
+ const wss = new WebSocketServer({ server, path: "/ws" });
783
+
784
+ let waterMark = getWaterMark();
785
+
786
+ // Poll for changes and broadcast
787
+ setInterval(() => {
788
+ try {
789
+ waterMark = diffAndBroadcast(wss, waterMark);
790
+ } catch (e) {
791
+ // DB might not be ready yet
792
+ }
793
+ }, POLL_INTERVAL_MS);
794
+
795
+ wss.on("connection", (ws, req) => {
796
+ // Check WebSocket auth
797
+ if (!wsAuthCheck(req as unknown as import("express").Request)) {
798
+ ws.close(4001, "Unauthorized");
799
+ return;
800
+ }
801
+
802
+ // Send current state snapshot on connect
803
+ const d = getDb();
804
+ const activeSessions = d.prepare(
805
+ "SELECT * FROM sessions WHERE status = 'in_progress' ORDER BY created_at DESC LIMIT 5"
806
+ ).all() as Session[];
807
+
808
+ ws.send(JSON.stringify({
809
+ type: "connected",
810
+ active_sessions: activeSessions,
811
+ timestamp: new Date().toISOString(),
812
+ }));
813
+ });
814
+
815
+ server.listen(DASHBOARD_PORT, "0.0.0.0", () => {
816
+ console.error(
817
+ `[telemetry] Dashboard running at http://localhost:${DASHBOARD_PORT}`
818
+ );
819
+ console.error(
820
+ `[telemetry] MCP SSE endpoint at http://localhost:${DASHBOARD_PORT}/mcp/sse`
821
+ );
822
+ console.error(
823
+ `[telemetry] WebSocket live feed at ws://localhost:${DASHBOARD_PORT}/ws`
824
+ );
825
+ });
826
+ }