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.
- package/Dockerfile +22 -0
- package/bin/tanuki.mjs +251 -0
- package/frontend/eslint.config.js +23 -0
- package/frontend/index.html +13 -0
- package/frontend/package.json +39 -0
- package/frontend/src/App.tsx +232 -0
- package/frontend/src/assets/hero.png +0 -0
- package/frontend/src/assets/react.svg +1 -0
- package/frontend/src/assets/vite.svg +1 -0
- package/frontend/src/components/ArtifactsPanel.tsx +429 -0
- package/frontend/src/components/ChildStreams.tsx +176 -0
- package/frontend/src/components/CoordinatorPage.tsx +317 -0
- package/frontend/src/components/Header.tsx +108 -0
- package/frontend/src/components/InsightsPanel.tsx +142 -0
- package/frontend/src/components/IterationsTable.tsx +98 -0
- package/frontend/src/components/KnowledgePage.tsx +308 -0
- package/frontend/src/components/LoginPage.tsx +55 -0
- package/frontend/src/components/PlanProgress.tsx +163 -0
- package/frontend/src/components/QualityReport.tsx +276 -0
- package/frontend/src/components/ScreenshotUpload.tsx +117 -0
- package/frontend/src/components/ScreenshotsGrid.tsx +266 -0
- package/frontend/src/components/SessionDetail.tsx +265 -0
- package/frontend/src/components/SessionList.tsx +234 -0
- package/frontend/src/components/SettingsPage.tsx +213 -0
- package/frontend/src/components/StreamComms.tsx +228 -0
- package/frontend/src/components/TanukiLogo.tsx +16 -0
- package/frontend/src/components/Timeline.tsx +416 -0
- package/frontend/src/components/WalkthroughPage.tsx +458 -0
- package/frontend/src/hooks/useApi.ts +81 -0
- package/frontend/src/hooks/useAuth.ts +54 -0
- package/frontend/src/hooks/useKnowledge.ts +33 -0
- package/frontend/src/hooks/useWebSocket.ts +95 -0
- package/frontend/src/index.css +66 -0
- package/frontend/src/lib/api.ts +15 -0
- package/frontend/src/lib/utils.ts +58 -0
- package/frontend/src/main.tsx +10 -0
- package/frontend/src/types.ts +181 -0
- package/frontend/tsconfig.app.json +32 -0
- package/frontend/tsconfig.json +7 -0
- package/frontend/vite.config.ts +25 -0
- package/install.sh +87 -0
- package/package.json +63 -0
- package/src/api-keys.ts +97 -0
- package/src/auth.ts +165 -0
- package/src/coordinator.ts +136 -0
- package/src/dashboard-server.ts +5 -0
- package/src/dashboard.ts +826 -0
- package/src/db.ts +1009 -0
- package/src/index.ts +20 -0
- package/src/middleware.ts +76 -0
- package/src/tools.ts +864 -0
- package/src/types-shim.d.ts +18 -0
- package/src/types.ts +171 -0
- package/tsconfig.json +19 -0
package/src/dashboard.ts
ADDED
|
@@ -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
|
+
}
|