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/db.ts
ADDED
|
@@ -0,0 +1,1009 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import sharp from "sharp";
|
|
5
|
+
import type {
|
|
6
|
+
Session,
|
|
7
|
+
Event,
|
|
8
|
+
Iteration,
|
|
9
|
+
Screenshot,
|
|
10
|
+
Artifact,
|
|
11
|
+
FinalResult,
|
|
12
|
+
Walkthrough,
|
|
13
|
+
WalkthroughAction,
|
|
14
|
+
WalkthroughScreenshot,
|
|
15
|
+
} from "./types.js";
|
|
16
|
+
|
|
17
|
+
const DB_PATH = process.env.DB_PATH || "/data/telemetry.db";
|
|
18
|
+
|
|
19
|
+
let db: Database.Database;
|
|
20
|
+
|
|
21
|
+
export function getDb(): Database.Database {
|
|
22
|
+
if (!db) {
|
|
23
|
+
db = new Database(DB_PATH);
|
|
24
|
+
db.pragma("journal_mode = WAL");
|
|
25
|
+
db.pragma("foreign_keys = ON");
|
|
26
|
+
initTables();
|
|
27
|
+
}
|
|
28
|
+
return db;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function initTables(): void {
|
|
32
|
+
const d = getDb();
|
|
33
|
+
|
|
34
|
+
d.exec(`
|
|
35
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
36
|
+
id TEXT PRIMARY KEY,
|
|
37
|
+
worktree_name TEXT NOT NULL,
|
|
38
|
+
ticket_id TEXT,
|
|
39
|
+
ticket_title TEXT,
|
|
40
|
+
branch_name TEXT,
|
|
41
|
+
mode TEXT CHECK(mode IN ('local', 'remote')) DEFAULT 'local',
|
|
42
|
+
status TEXT CHECK(status IN ('in_progress', 'completed', 'failed', 'interrupted')) DEFAULT 'in_progress',
|
|
43
|
+
total_input_tokens INTEGER DEFAULT 0,
|
|
44
|
+
total_output_tokens INTEGER DEFAULT 0,
|
|
45
|
+
total_iterations INTEGER DEFAULT 0,
|
|
46
|
+
max_iterations INTEGER DEFAULT 10,
|
|
47
|
+
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
48
|
+
ended_at TEXT,
|
|
49
|
+
duration_seconds INTEGER,
|
|
50
|
+
final_result TEXT,
|
|
51
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
55
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
56
|
+
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
57
|
+
phase TEXT NOT NULL,
|
|
58
|
+
event_type TEXT NOT NULL,
|
|
59
|
+
message TEXT NOT NULL,
|
|
60
|
+
metadata TEXT,
|
|
61
|
+
tokens_used INTEGER DEFAULT 0,
|
|
62
|
+
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
CREATE TABLE IF NOT EXISTS iterations (
|
|
66
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
67
|
+
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
68
|
+
iteration_number INTEGER NOT NULL,
|
|
69
|
+
trigger TEXT NOT NULL,
|
|
70
|
+
error_summary TEXT NOT NULL,
|
|
71
|
+
fix_description TEXT,
|
|
72
|
+
files_changed TEXT,
|
|
73
|
+
result TEXT CHECK(result IN ('pass', 'fail', 'partial')) NOT NULL,
|
|
74
|
+
duration_seconds INTEGER,
|
|
75
|
+
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
CREATE TABLE IF NOT EXISTS screenshots (
|
|
79
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
80
|
+
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
81
|
+
iteration_number INTEGER,
|
|
82
|
+
phase TEXT NOT NULL,
|
|
83
|
+
description TEXT NOT NULL,
|
|
84
|
+
file_path TEXT NOT NULL,
|
|
85
|
+
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
CREATE TABLE IF NOT EXISTS plan_steps (
|
|
89
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
90
|
+
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
91
|
+
step_number INTEGER NOT NULL,
|
|
92
|
+
title TEXT NOT NULL,
|
|
93
|
+
description TEXT NOT NULL DEFAULT '',
|
|
94
|
+
status TEXT CHECK(status IN ('pending', 'in_progress', 'completed', 'skipped', 'failed')) DEFAULT 'pending',
|
|
95
|
+
parent_step INTEGER,
|
|
96
|
+
file_targets TEXT,
|
|
97
|
+
outcome TEXT,
|
|
98
|
+
started_at TEXT,
|
|
99
|
+
completed_at TEXT,
|
|
100
|
+
duration_seconds INTEGER,
|
|
101
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
CREATE TABLE IF NOT EXISTS insights (
|
|
105
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
106
|
+
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
107
|
+
insight_type TEXT CHECK(insight_type IN ('mistake', 'success_pattern', 'codebase_gotcha', 'optimization', 'rule_learned')) NOT NULL,
|
|
108
|
+
category TEXT NOT NULL,
|
|
109
|
+
title TEXT NOT NULL,
|
|
110
|
+
description TEXT NOT NULL,
|
|
111
|
+
evidence TEXT,
|
|
112
|
+
confidence REAL DEFAULT 0.5,
|
|
113
|
+
times_validated INTEGER DEFAULT 0,
|
|
114
|
+
file_patterns TEXT,
|
|
115
|
+
error_patterns TEXT,
|
|
116
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id);
|
|
120
|
+
CREATE INDEX IF NOT EXISTS idx_iterations_session ON iterations(session_id);
|
|
121
|
+
CREATE INDEX IF NOT EXISTS idx_screenshots_session ON screenshots(session_id);
|
|
122
|
+
CREATE INDEX IF NOT EXISTS idx_plan_steps_session ON plan_steps(session_id);
|
|
123
|
+
CREATE INDEX IF NOT EXISTS idx_insights_session ON insights(session_id);
|
|
124
|
+
CREATE INDEX IF NOT EXISTS idx_insights_type ON insights(insight_type);
|
|
125
|
+
CREATE INDEX IF NOT EXISTS idx_insights_category ON insights(category);
|
|
126
|
+
`);
|
|
127
|
+
|
|
128
|
+
// Migration: add parent_session_id column to sessions
|
|
129
|
+
try {
|
|
130
|
+
d.exec(`ALTER TABLE sessions ADD COLUMN parent_session_id TEXT REFERENCES sessions(id)`);
|
|
131
|
+
} catch {
|
|
132
|
+
// Column already exists — ignore duplicate column error
|
|
133
|
+
}
|
|
134
|
+
d.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id)`);
|
|
135
|
+
|
|
136
|
+
// Migration: add stored_path column to screenshots
|
|
137
|
+
try {
|
|
138
|
+
d.exec(`ALTER TABLE screenshots ADD COLUMN stored_path TEXT`);
|
|
139
|
+
} catch {
|
|
140
|
+
// Column already exists
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Migration: add thumb_path column to screenshots
|
|
144
|
+
try {
|
|
145
|
+
d.exec(`ALTER TABLE screenshots ADD COLUMN thumb_path TEXT`);
|
|
146
|
+
} catch {
|
|
147
|
+
// Column already exists
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Migration: add event_id column to screenshots
|
|
151
|
+
try {
|
|
152
|
+
d.exec(`ALTER TABLE screenshots ADD COLUMN event_id INTEGER REFERENCES events(id)`);
|
|
153
|
+
} catch {
|
|
154
|
+
// Column already exists
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Migration: add user_email column to sessions
|
|
158
|
+
try {
|
|
159
|
+
d.exec(`ALTER TABLE sessions ADD COLUMN user_email TEXT`);
|
|
160
|
+
} catch {
|
|
161
|
+
// Column already exists
|
|
162
|
+
}
|
|
163
|
+
d.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_user_email ON sessions(user_email)`);
|
|
164
|
+
|
|
165
|
+
// Coordinator tables
|
|
166
|
+
d.exec(`
|
|
167
|
+
CREATE TABLE IF NOT EXISTS coordinator_state (
|
|
168
|
+
session_id TEXT PRIMARY KEY,
|
|
169
|
+
state TEXT NOT NULL,
|
|
170
|
+
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
171
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
CREATE TABLE IF NOT EXISTS coordinator_history (
|
|
175
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
176
|
+
session_id TEXT NOT NULL,
|
|
177
|
+
snapshot TEXT NOT NULL,
|
|
178
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
CREATE INDEX IF NOT EXISTS idx_coordinator_history_session ON coordinator_history(session_id);
|
|
182
|
+
`);
|
|
183
|
+
|
|
184
|
+
// Migration: add artifacts table
|
|
185
|
+
d.exec(`
|
|
186
|
+
CREATE TABLE IF NOT EXISTS artifacts (
|
|
187
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
188
|
+
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
189
|
+
artifact_type TEXT NOT NULL,
|
|
190
|
+
description TEXT NOT NULL,
|
|
191
|
+
file_path TEXT NOT NULL,
|
|
192
|
+
stored_path TEXT,
|
|
193
|
+
mime_type TEXT,
|
|
194
|
+
file_size INTEGER,
|
|
195
|
+
metadata TEXT,
|
|
196
|
+
event_id INTEGER REFERENCES events(id),
|
|
197
|
+
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
CREATE INDEX IF NOT EXISTS idx_artifacts_session ON artifacts(session_id);
|
|
201
|
+
`);
|
|
202
|
+
|
|
203
|
+
// Walkthrough tables
|
|
204
|
+
d.exec(`
|
|
205
|
+
CREATE TABLE IF NOT EXISTS walkthroughs (
|
|
206
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
207
|
+
url TEXT NOT NULL,
|
|
208
|
+
app_name TEXT,
|
|
209
|
+
scenario TEXT,
|
|
210
|
+
status TEXT CHECK(status IN ('in_progress', 'pass', 'fail', 'partial')) DEFAULT 'in_progress',
|
|
211
|
+
summary TEXT,
|
|
212
|
+
total_actions INTEGER DEFAULT 0,
|
|
213
|
+
passed INTEGER DEFAULT 0,
|
|
214
|
+
failed INTEGER DEFAULT 0,
|
|
215
|
+
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
216
|
+
ended_at TEXT
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
CREATE TABLE IF NOT EXISTS walkthrough_actions (
|
|
220
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
221
|
+
walkthrough_id INTEGER NOT NULL REFERENCES walkthroughs(id),
|
|
222
|
+
action_type TEXT CHECK(action_type IN ('navigate', 'click', 'type', 'assert', 'wait', 'screenshot')) NOT NULL,
|
|
223
|
+
target TEXT NOT NULL,
|
|
224
|
+
value TEXT,
|
|
225
|
+
status TEXT CHECK(status IN ('pass', 'fail')) NOT NULL,
|
|
226
|
+
message TEXT,
|
|
227
|
+
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
CREATE TABLE IF NOT EXISTS walkthrough_screenshots (
|
|
231
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
232
|
+
walkthrough_id INTEGER NOT NULL REFERENCES walkthroughs(id),
|
|
233
|
+
action_id INTEGER REFERENCES walkthrough_actions(id),
|
|
234
|
+
name TEXT NOT NULL,
|
|
235
|
+
annotation TEXT,
|
|
236
|
+
stored_path TEXT NOT NULL,
|
|
237
|
+
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
CREATE INDEX IF NOT EXISTS idx_walkthrough_actions_wt ON walkthrough_actions(walkthrough_id);
|
|
241
|
+
CREATE INDEX IF NOT EXISTS idx_walkthrough_screenshots_wt ON walkthrough_screenshots(walkthrough_id);
|
|
242
|
+
`);
|
|
243
|
+
|
|
244
|
+
// Ensure storage directories exist
|
|
245
|
+
const screenshotsDir = "/data/screenshots";
|
|
246
|
+
if (!fs.existsSync(screenshotsDir)) {
|
|
247
|
+
fs.mkdirSync(screenshotsDir, { recursive: true });
|
|
248
|
+
}
|
|
249
|
+
const artifactsDir = "/data/artifacts";
|
|
250
|
+
if (!fs.existsSync(artifactsDir)) {
|
|
251
|
+
fs.mkdirSync(artifactsDir, { recursive: true });
|
|
252
|
+
}
|
|
253
|
+
const walkthroughScreenshotsDir = "/data/walkthrough-screenshots";
|
|
254
|
+
if (!fs.existsSync(walkthroughScreenshotsDir)) {
|
|
255
|
+
fs.mkdirSync(walkthroughScreenshotsDir, { recursive: true });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function createSession(
|
|
260
|
+
id: string,
|
|
261
|
+
worktree_name: string,
|
|
262
|
+
ticket_id?: string,
|
|
263
|
+
ticket_title?: string,
|
|
264
|
+
branch_name?: string,
|
|
265
|
+
mode?: string,
|
|
266
|
+
max_iterations?: number,
|
|
267
|
+
parent_session_id?: string,
|
|
268
|
+
user_email?: string
|
|
269
|
+
): void {
|
|
270
|
+
const d = getDb();
|
|
271
|
+
const stmt = d.prepare(`
|
|
272
|
+
INSERT INTO sessions (id, worktree_name, ticket_id, ticket_title, branch_name, mode, max_iterations, parent_session_id, user_email)
|
|
273
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
274
|
+
`);
|
|
275
|
+
stmt.run(
|
|
276
|
+
id,
|
|
277
|
+
worktree_name,
|
|
278
|
+
ticket_id ?? null,
|
|
279
|
+
ticket_title ?? null,
|
|
280
|
+
branch_name ?? null,
|
|
281
|
+
mode ?? "local",
|
|
282
|
+
max_iterations ?? 10,
|
|
283
|
+
parent_session_id ?? null,
|
|
284
|
+
user_email ?? null
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function insertEvent(
|
|
289
|
+
session_id: string,
|
|
290
|
+
phase: string,
|
|
291
|
+
event_type: string,
|
|
292
|
+
message: string,
|
|
293
|
+
metadata?: object,
|
|
294
|
+
tokens_used?: number,
|
|
295
|
+
input_tokens?: number,
|
|
296
|
+
output_tokens?: number
|
|
297
|
+
): number {
|
|
298
|
+
const d = getDb();
|
|
299
|
+
const stmt = d.prepare(`
|
|
300
|
+
INSERT INTO events (session_id, phase, event_type, message, metadata, tokens_used)
|
|
301
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
302
|
+
`);
|
|
303
|
+
const result = stmt.run(
|
|
304
|
+
session_id,
|
|
305
|
+
phase,
|
|
306
|
+
event_type,
|
|
307
|
+
message,
|
|
308
|
+
metadata ? JSON.stringify(metadata) : null,
|
|
309
|
+
tokens_used ?? 0
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
// Accumulate token counts on the session
|
|
313
|
+
const inTok = input_tokens ?? 0;
|
|
314
|
+
const outTok = output_tokens ?? 0;
|
|
315
|
+
if (inTok > 0 || outTok > 0) {
|
|
316
|
+
d.prepare(`
|
|
317
|
+
UPDATE sessions
|
|
318
|
+
SET total_input_tokens = total_input_tokens + ?,
|
|
319
|
+
total_output_tokens = total_output_tokens + ?
|
|
320
|
+
WHERE id = ?
|
|
321
|
+
`).run(inTok, outTok, session_id);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return Number(result.lastInsertRowid);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function insertIteration(
|
|
328
|
+
session_id: string,
|
|
329
|
+
iteration_number: number,
|
|
330
|
+
trigger: string,
|
|
331
|
+
error_summary: string,
|
|
332
|
+
fix_description?: string,
|
|
333
|
+
files_changed?: string[],
|
|
334
|
+
result?: string,
|
|
335
|
+
duration_seconds?: number
|
|
336
|
+
): number {
|
|
337
|
+
const d = getDb();
|
|
338
|
+
|
|
339
|
+
const stmt = d.prepare(`
|
|
340
|
+
INSERT INTO iterations (session_id, iteration_number, trigger, error_summary, fix_description, files_changed, result, duration_seconds)
|
|
341
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
342
|
+
`);
|
|
343
|
+
const res = stmt.run(
|
|
344
|
+
session_id,
|
|
345
|
+
iteration_number,
|
|
346
|
+
trigger,
|
|
347
|
+
error_summary,
|
|
348
|
+
fix_description ?? null,
|
|
349
|
+
files_changed ? JSON.stringify(files_changed) : null,
|
|
350
|
+
result ?? "fail",
|
|
351
|
+
duration_seconds ?? null
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
// Update total_iterations on the session
|
|
355
|
+
d.prepare(`
|
|
356
|
+
UPDATE sessions SET total_iterations = (
|
|
357
|
+
SELECT COUNT(*) FROM iterations WHERE session_id = ?
|
|
358
|
+
) WHERE id = ?
|
|
359
|
+
`).run(session_id, session_id);
|
|
360
|
+
|
|
361
|
+
return Number(res.lastInsertRowid);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function insertScreenshot(
|
|
365
|
+
session_id: string,
|
|
366
|
+
iteration_number: number | undefined,
|
|
367
|
+
phase: string,
|
|
368
|
+
description: string,
|
|
369
|
+
file_path: string,
|
|
370
|
+
event_id?: number
|
|
371
|
+
): number {
|
|
372
|
+
const d = getDb();
|
|
373
|
+
|
|
374
|
+
// Insert first to get the ID
|
|
375
|
+
const stmt = d.prepare(`
|
|
376
|
+
INSERT INTO screenshots (session_id, iteration_number, phase, description, file_path, event_id)
|
|
377
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
378
|
+
`);
|
|
379
|
+
const result = stmt.run(
|
|
380
|
+
session_id,
|
|
381
|
+
iteration_number ?? null,
|
|
382
|
+
phase,
|
|
383
|
+
description,
|
|
384
|
+
file_path,
|
|
385
|
+
event_id ?? null
|
|
386
|
+
);
|
|
387
|
+
const screenshotId = Number(result.lastInsertRowid);
|
|
388
|
+
|
|
389
|
+
// Try to copy the file into /data/screenshots/ for self-contained serving
|
|
390
|
+
const ext = path.extname(file_path) || ".png";
|
|
391
|
+
const storedName = `${session_id}_${screenshotId}${ext}`;
|
|
392
|
+
const thumbName = `${session_id}_${screenshotId}_thumb${ext}`;
|
|
393
|
+
const storedPath = path.join("/data", "screenshots", storedName);
|
|
394
|
+
const thumbPath = path.join("/data", "screenshots", thumbName);
|
|
395
|
+
|
|
396
|
+
try {
|
|
397
|
+
// The file_path might be a host path — try multiple locations
|
|
398
|
+
const candidates = [
|
|
399
|
+
file_path,
|
|
400
|
+
// Map host path into /data mount: /Users/.../outputs/foo → /data/foo
|
|
401
|
+
file_path.replace(/^.*?outputs\//, "/data/"),
|
|
402
|
+
];
|
|
403
|
+
|
|
404
|
+
for (const candidate of candidates) {
|
|
405
|
+
if (fs.existsSync(candidate)) {
|
|
406
|
+
// Copy full-size
|
|
407
|
+
fs.copyFileSync(candidate, storedPath);
|
|
408
|
+
|
|
409
|
+
// Generate thumbnail async (don't block the response)
|
|
410
|
+
sharp(candidate)
|
|
411
|
+
.resize(300)
|
|
412
|
+
.toFile(thumbPath)
|
|
413
|
+
.then(() => {
|
|
414
|
+
d.prepare("UPDATE screenshots SET stored_path = ?, thumb_path = ? WHERE id = ?").run(storedPath, thumbPath, screenshotId);
|
|
415
|
+
})
|
|
416
|
+
.catch(() => {
|
|
417
|
+
// Thumbnail generation failed — just store the full path
|
|
418
|
+
d.prepare("UPDATE screenshots SET stored_path = ? WHERE id = ?").run(storedPath, screenshotId);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
} catch {
|
|
425
|
+
// File copy failed — stored_path stays null, original file_path still works via volume mount
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return screenshotId;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const MIME_TYPES: Record<string, string> = {
|
|
432
|
+
".png": "image/png",
|
|
433
|
+
".jpg": "image/jpeg",
|
|
434
|
+
".jpeg": "image/jpeg",
|
|
435
|
+
".gif": "image/gif",
|
|
436
|
+
".svg": "image/svg+xml",
|
|
437
|
+
".webp": "image/webp",
|
|
438
|
+
".pdf": "application/pdf",
|
|
439
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
440
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
441
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
442
|
+
".json": "application/json",
|
|
443
|
+
".csv": "text/csv",
|
|
444
|
+
".txt": "text/plain",
|
|
445
|
+
".md": "text/markdown",
|
|
446
|
+
".html": "text/html",
|
|
447
|
+
".zip": "application/zip",
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
export function insertArtifact(
|
|
451
|
+
session_id: string,
|
|
452
|
+
file_path: string,
|
|
453
|
+
artifact_type: string,
|
|
454
|
+
description: string,
|
|
455
|
+
metadata?: object,
|
|
456
|
+
event_id?: number
|
|
457
|
+
): number {
|
|
458
|
+
const d = getDb();
|
|
459
|
+
|
|
460
|
+
const ext = path.extname(file_path).toLowerCase();
|
|
461
|
+
const mime_type = MIME_TYPES[ext] || "application/octet-stream";
|
|
462
|
+
|
|
463
|
+
const stmt = d.prepare(`
|
|
464
|
+
INSERT INTO artifacts (session_id, artifact_type, description, file_path, mime_type, metadata, event_id)
|
|
465
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
466
|
+
`);
|
|
467
|
+
const result = stmt.run(
|
|
468
|
+
session_id,
|
|
469
|
+
artifact_type,
|
|
470
|
+
description,
|
|
471
|
+
file_path,
|
|
472
|
+
mime_type,
|
|
473
|
+
metadata ? JSON.stringify(metadata) : null,
|
|
474
|
+
event_id ?? null
|
|
475
|
+
);
|
|
476
|
+
const artifactId = Number(result.lastInsertRowid);
|
|
477
|
+
|
|
478
|
+
// Try to copy the file into /data/artifacts/ for self-contained serving
|
|
479
|
+
const storedName = `${session_id}_${artifactId}${ext}`;
|
|
480
|
+
const storedPath = path.join("/data", "artifacts", storedName);
|
|
481
|
+
|
|
482
|
+
try {
|
|
483
|
+
const candidates = [
|
|
484
|
+
file_path,
|
|
485
|
+
file_path.replace(/^.*?outputs\//, "/data/"),
|
|
486
|
+
];
|
|
487
|
+
|
|
488
|
+
for (const candidate of candidates) {
|
|
489
|
+
if (fs.existsSync(candidate)) {
|
|
490
|
+
fs.copyFileSync(candidate, storedPath);
|
|
491
|
+
const stat = fs.statSync(storedPath);
|
|
492
|
+
d.prepare("UPDATE artifacts SET stored_path = ?, file_size = ? WHERE id = ?").run(
|
|
493
|
+
storedPath,
|
|
494
|
+
stat.size,
|
|
495
|
+
artifactId
|
|
496
|
+
);
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
} catch {
|
|
501
|
+
// File copy failed — stored_path stays null
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return artifactId;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function endSession(
|
|
508
|
+
session_id: string,
|
|
509
|
+
status: string,
|
|
510
|
+
total_input_tokens?: number,
|
|
511
|
+
total_output_tokens?: number,
|
|
512
|
+
final_result?: FinalResult
|
|
513
|
+
): { duration_seconds: number; total_iterations: number } {
|
|
514
|
+
const d = getDb();
|
|
515
|
+
|
|
516
|
+
const session = d
|
|
517
|
+
.prepare(`SELECT started_at, total_iterations, total_input_tokens, total_output_tokens FROM sessions WHERE id = ?`)
|
|
518
|
+
.get(session_id) as { started_at: string; total_iterations: number; total_input_tokens: number; total_output_tokens: number } | undefined;
|
|
519
|
+
|
|
520
|
+
if (!session) {
|
|
521
|
+
throw new Error(`Session ${session_id} not found`);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const startedAt = new Date(session.started_at + "Z");
|
|
525
|
+
const now = new Date();
|
|
526
|
+
const duration_seconds = Math.round((now.getTime() - startedAt.getTime()) / 1000);
|
|
527
|
+
|
|
528
|
+
// Use caller-provided totals only if they exceed the already-accumulated values.
|
|
529
|
+
// This prevents overwriting accumulated event-level tokens with 0.
|
|
530
|
+
let finalInput = Math.max(total_input_tokens ?? 0, session.total_input_tokens);
|
|
531
|
+
let finalOutput = Math.max(total_output_tokens ?? 0, session.total_output_tokens);
|
|
532
|
+
|
|
533
|
+
// Estimation fallback: if still 0/0, estimate from event count.
|
|
534
|
+
// Claude Code agents can't access their own token counts, so this provides
|
|
535
|
+
// a rough estimate rather than showing misleading zeroes.
|
|
536
|
+
if (finalInput === 0 && finalOutput === 0) {
|
|
537
|
+
const eventCount = (d.prepare(
|
|
538
|
+
"SELECT COUNT(*) as count FROM events WHERE session_id = ?"
|
|
539
|
+
).get(session_id) as { count: number }).count;
|
|
540
|
+
|
|
541
|
+
if (eventCount > 0) {
|
|
542
|
+
// Each MCP tool call round-trip is ~3k input + ~1.5k output tokens on average
|
|
543
|
+
finalInput = eventCount * 3000;
|
|
544
|
+
finalOutput = eventCount * 1500;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
d.prepare(`
|
|
549
|
+
UPDATE sessions
|
|
550
|
+
SET status = ?,
|
|
551
|
+
total_input_tokens = ?,
|
|
552
|
+
total_output_tokens = ?,
|
|
553
|
+
final_result = ?,
|
|
554
|
+
ended_at = datetime('now'),
|
|
555
|
+
duration_seconds = ?
|
|
556
|
+
WHERE id = ?
|
|
557
|
+
`).run(
|
|
558
|
+
status,
|
|
559
|
+
finalInput,
|
|
560
|
+
finalOutput,
|
|
561
|
+
final_result ? JSON.stringify(final_result) : null,
|
|
562
|
+
duration_seconds,
|
|
563
|
+
session_id
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
return { duration_seconds, total_iterations: session.total_iterations };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export function createPlan(
|
|
570
|
+
session_id: string,
|
|
571
|
+
steps: Array<{
|
|
572
|
+
step_number: number;
|
|
573
|
+
title: string;
|
|
574
|
+
description?: string;
|
|
575
|
+
parent_step?: number;
|
|
576
|
+
file_targets?: string[];
|
|
577
|
+
}>
|
|
578
|
+
): number[] {
|
|
579
|
+
const d = getDb();
|
|
580
|
+
const stmt = d.prepare(`
|
|
581
|
+
INSERT INTO plan_steps (session_id, step_number, title, description, parent_step, file_targets)
|
|
582
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
583
|
+
`);
|
|
584
|
+
|
|
585
|
+
const ids: number[] = [];
|
|
586
|
+
const insertAll = d.transaction(() => {
|
|
587
|
+
for (const step of steps) {
|
|
588
|
+
const result = stmt.run(
|
|
589
|
+
session_id,
|
|
590
|
+
step.step_number,
|
|
591
|
+
step.title,
|
|
592
|
+
step.description ?? "",
|
|
593
|
+
step.parent_step ?? null,
|
|
594
|
+
step.file_targets ? JSON.stringify(step.file_targets) : null
|
|
595
|
+
);
|
|
596
|
+
ids.push(Number(result.lastInsertRowid));
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
insertAll();
|
|
600
|
+
return ids;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export function updatePlanStep(
|
|
604
|
+
step_id: number,
|
|
605
|
+
status: string,
|
|
606
|
+
outcome?: string
|
|
607
|
+
): void {
|
|
608
|
+
const d = getDb();
|
|
609
|
+
|
|
610
|
+
if (status === "in_progress") {
|
|
611
|
+
d.prepare(`
|
|
612
|
+
UPDATE plan_steps SET status = ?, started_at = datetime('now') WHERE id = ?
|
|
613
|
+
`).run(status, step_id);
|
|
614
|
+
} else if (status === "completed" || status === "failed" || status === "skipped") {
|
|
615
|
+
// Calculate duration from started_at
|
|
616
|
+
const step = d.prepare("SELECT started_at FROM plan_steps WHERE id = ?").get(step_id) as { started_at: string | null } | undefined;
|
|
617
|
+
let duration: number | null = null;
|
|
618
|
+
if (step?.started_at) {
|
|
619
|
+
duration = Math.round((Date.now() - new Date(step.started_at + "Z").getTime()) / 1000);
|
|
620
|
+
}
|
|
621
|
+
d.prepare(`
|
|
622
|
+
UPDATE plan_steps SET status = ?, outcome = ?, completed_at = datetime('now'), duration_seconds = ? WHERE id = ?
|
|
623
|
+
`).run(status, outcome ?? null, duration, step_id);
|
|
624
|
+
} else {
|
|
625
|
+
d.prepare(`UPDATE plan_steps SET status = ? WHERE id = ?`).run(status, step_id);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
export function getPlanSteps(session_id: string): import("./types.js").PlanStep[] {
|
|
630
|
+
const d = getDb();
|
|
631
|
+
return d.prepare(
|
|
632
|
+
"SELECT * FROM plan_steps WHERE session_id = ? ORDER BY step_number ASC"
|
|
633
|
+
).all(session_id) as import("./types.js").PlanStep[];
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export function insertInsight(
|
|
637
|
+
session_id: string,
|
|
638
|
+
insight_type: string,
|
|
639
|
+
category: string,
|
|
640
|
+
title: string,
|
|
641
|
+
description: string,
|
|
642
|
+
evidence?: string,
|
|
643
|
+
confidence?: number,
|
|
644
|
+
file_patterns?: string[],
|
|
645
|
+
error_patterns?: string[]
|
|
646
|
+
): number {
|
|
647
|
+
const d = getDb();
|
|
648
|
+
const stmt = d.prepare(`
|
|
649
|
+
INSERT INTO insights (session_id, insight_type, category, title, description, evidence, confidence, file_patterns, error_patterns)
|
|
650
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
651
|
+
`);
|
|
652
|
+
const result = stmt.run(
|
|
653
|
+
session_id,
|
|
654
|
+
insight_type,
|
|
655
|
+
category,
|
|
656
|
+
title,
|
|
657
|
+
description,
|
|
658
|
+
evidence ?? null,
|
|
659
|
+
confidence ?? 0.5,
|
|
660
|
+
file_patterns ? JSON.stringify(file_patterns) : null,
|
|
661
|
+
error_patterns ? JSON.stringify(error_patterns) : null
|
|
662
|
+
);
|
|
663
|
+
return Number(result.lastInsertRowid);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export function validateInsight(insight_id: number): void {
|
|
667
|
+
const d = getDb();
|
|
668
|
+
d.prepare(`
|
|
669
|
+
UPDATE insights SET times_validated = times_validated + 1, confidence = MIN(1.0, confidence + 0.1)
|
|
670
|
+
WHERE id = ?
|
|
671
|
+
`).run(insight_id);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
export function getInsightsForContext(
|
|
675
|
+
category?: string,
|
|
676
|
+
file_pattern?: string,
|
|
677
|
+
limit: number = 20
|
|
678
|
+
): import("./types.js").Insight[] {
|
|
679
|
+
const d = getDb();
|
|
680
|
+
|
|
681
|
+
if (category && file_pattern) {
|
|
682
|
+
return d.prepare(`
|
|
683
|
+
SELECT * FROM insights
|
|
684
|
+
WHERE category = ? AND (file_patterns LIKE ? OR file_patterns IS NULL)
|
|
685
|
+
ORDER BY confidence DESC, times_validated DESC
|
|
686
|
+
LIMIT ?
|
|
687
|
+
`).all(category, `%${file_pattern}%`, limit) as import("./types.js").Insight[];
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (category) {
|
|
691
|
+
return d.prepare(`
|
|
692
|
+
SELECT * FROM insights
|
|
693
|
+
WHERE category = ?
|
|
694
|
+
ORDER BY confidence DESC, times_validated DESC
|
|
695
|
+
LIMIT ?
|
|
696
|
+
`).all(category, limit) as import("./types.js").Insight[];
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
return d.prepare(`
|
|
700
|
+
SELECT * FROM insights
|
|
701
|
+
ORDER BY confidence DESC, times_validated DESC
|
|
702
|
+
LIMIT ?
|
|
703
|
+
`).all(limit) as import("./types.js").Insight[];
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
export function getSessionSummary(session_id?: string): {
|
|
707
|
+
session: Session;
|
|
708
|
+
events: Event[];
|
|
709
|
+
iterations: Iteration[];
|
|
710
|
+
screenshots: Screenshot[];
|
|
711
|
+
artifacts: Artifact[];
|
|
712
|
+
insights: import("./types.js").Insight[];
|
|
713
|
+
plan_steps: import("./types.js").PlanStep[];
|
|
714
|
+
child_sessions: Session[];
|
|
715
|
+
} | null {
|
|
716
|
+
const d = getDb();
|
|
717
|
+
|
|
718
|
+
let session: Session | undefined;
|
|
719
|
+
if (session_id) {
|
|
720
|
+
session = d
|
|
721
|
+
.prepare(`SELECT * FROM sessions WHERE id = ?`)
|
|
722
|
+
.get(session_id) as Session | undefined;
|
|
723
|
+
} else {
|
|
724
|
+
session = d
|
|
725
|
+
.prepare(`SELECT * FROM sessions ORDER BY created_at DESC LIMIT 1`)
|
|
726
|
+
.get() as Session | undefined;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (!session) {
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const events = d
|
|
734
|
+
.prepare(`SELECT * FROM events WHERE session_id = ? ORDER BY timestamp ASC`)
|
|
735
|
+
.all(session.id) as Event[];
|
|
736
|
+
|
|
737
|
+
const iterations = d
|
|
738
|
+
.prepare(
|
|
739
|
+
`SELECT * FROM iterations WHERE session_id = ? ORDER BY iteration_number ASC`
|
|
740
|
+
)
|
|
741
|
+
.all(session.id) as Iteration[];
|
|
742
|
+
|
|
743
|
+
const screenshots = d
|
|
744
|
+
.prepare(`SELECT * FROM screenshots WHERE session_id = ? ORDER BY timestamp ASC`)
|
|
745
|
+
.all(session.id) as Screenshot[];
|
|
746
|
+
|
|
747
|
+
const artifacts = d
|
|
748
|
+
.prepare(`SELECT * FROM artifacts WHERE session_id = ? ORDER BY timestamp ASC`)
|
|
749
|
+
.all(session.id) as Artifact[];
|
|
750
|
+
|
|
751
|
+
const insights = d
|
|
752
|
+
.prepare(`SELECT * FROM insights WHERE session_id = ? ORDER BY confidence DESC`)
|
|
753
|
+
.all(session.id) as import("./types.js").Insight[];
|
|
754
|
+
|
|
755
|
+
const plan_steps = d
|
|
756
|
+
.prepare(`SELECT * FROM plan_steps WHERE session_id = ? ORDER BY step_number ASC`)
|
|
757
|
+
.all(session.id) as import("./types.js").PlanStep[];
|
|
758
|
+
|
|
759
|
+
const child_sessions = d
|
|
760
|
+
.prepare(`SELECT * FROM sessions WHERE parent_session_id = ? ORDER BY created_at ASC`)
|
|
761
|
+
.all(session.id) as Session[];
|
|
762
|
+
|
|
763
|
+
return { session, events, iterations, screenshots, artifacts, insights, plan_steps, child_sessions };
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
export interface ComparisonFinding {
|
|
767
|
+
severity: string;
|
|
768
|
+
title: string;
|
|
769
|
+
details: string;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
export interface ComparisonResult {
|
|
773
|
+
slide: number;
|
|
774
|
+
template: string;
|
|
775
|
+
diff_pct: number;
|
|
776
|
+
findings: ComparisonFinding[];
|
|
777
|
+
message: string;
|
|
778
|
+
screenshot_id: number | null;
|
|
779
|
+
screenshot_url: string | null;
|
|
780
|
+
timestamp: string;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
export function getComparisonResults(
|
|
784
|
+
session_id: string,
|
|
785
|
+
min_severity?: string
|
|
786
|
+
): { results: ComparisonResult[]; summary: Record<string, unknown> | null } {
|
|
787
|
+
const d = getDb();
|
|
788
|
+
|
|
789
|
+
// Get per-slide comparison events (phase=verification, event_type=action with findings in metadata)
|
|
790
|
+
const events = d.prepare(`
|
|
791
|
+
SELECT e.id, e.message, e.metadata, e.timestamp
|
|
792
|
+
FROM events e
|
|
793
|
+
WHERE e.session_id = ?
|
|
794
|
+
AND e.phase = 'verification'
|
|
795
|
+
AND e.event_type = 'action'
|
|
796
|
+
AND e.metadata LIKE '%"findings"%'
|
|
797
|
+
ORDER BY e.timestamp ASC
|
|
798
|
+
`).all(session_id) as Array<{ id: number; message: string; metadata: string | null; timestamp: string }>;
|
|
799
|
+
|
|
800
|
+
const severityRank: Record<string, number> = { CRITICAL: 3, NOTABLE: 2, MINOR: 1, GOOD: 0 };
|
|
801
|
+
const minRank = min_severity ? (severityRank[min_severity.toUpperCase()] ?? 0) : 0;
|
|
802
|
+
|
|
803
|
+
const results: ComparisonResult[] = [];
|
|
804
|
+
|
|
805
|
+
for (const ev of events) {
|
|
806
|
+
if (!ev.metadata) continue;
|
|
807
|
+
let meta: Record<string, unknown>;
|
|
808
|
+
try {
|
|
809
|
+
meta = JSON.parse(ev.metadata);
|
|
810
|
+
} catch {
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const findings = (meta.findings as ComparisonFinding[] | undefined) ?? [];
|
|
815
|
+
|
|
816
|
+
// Filter by minimum severity if requested
|
|
817
|
+
const filtered = min_severity
|
|
818
|
+
? findings.filter((f) => (severityRank[f.severity?.toUpperCase()] ?? 0) >= minRank)
|
|
819
|
+
: findings;
|
|
820
|
+
|
|
821
|
+
if (min_severity && filtered.length === 0) continue;
|
|
822
|
+
|
|
823
|
+
// Find linked screenshot
|
|
824
|
+
const screenshot = d.prepare(
|
|
825
|
+
"SELECT id FROM screenshots WHERE session_id = ? AND event_id = ? LIMIT 1"
|
|
826
|
+
).get(session_id, ev.id) as { id: number } | undefined;
|
|
827
|
+
|
|
828
|
+
// If no event_id link, try matching by timestamp proximity
|
|
829
|
+
let screenshotId = screenshot?.id ?? null;
|
|
830
|
+
if (!screenshotId) {
|
|
831
|
+
const nearby = d.prepare(
|
|
832
|
+
"SELECT id FROM screenshots WHERE session_id = ? AND phase = 'verification' AND abs(julianday(timestamp) - julianday(?)) < 0.001 LIMIT 1"
|
|
833
|
+
).get(session_id, ev.timestamp) as { id: number } | undefined;
|
|
834
|
+
screenshotId = nearby?.id ?? null;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
results.push({
|
|
838
|
+
slide: (meta.slide as number) ?? 0,
|
|
839
|
+
template: (meta.template as string) ?? "unknown",
|
|
840
|
+
diff_pct: (meta.diff_pct as number) ?? 0,
|
|
841
|
+
findings: filtered,
|
|
842
|
+
message: ev.message,
|
|
843
|
+
screenshot_id: screenshotId,
|
|
844
|
+
screenshot_url: screenshotId ? `/api/screenshots/by-id/${screenshotId}` : null,
|
|
845
|
+
timestamp: ev.timestamp,
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Get the summary event if it exists
|
|
850
|
+
const summaryEvent = d.prepare(`
|
|
851
|
+
SELECT metadata FROM events
|
|
852
|
+
WHERE session_id = ?
|
|
853
|
+
AND phase = 'deliverables'
|
|
854
|
+
AND event_type = 'info'
|
|
855
|
+
AND metadata LIKE '%"total"%'
|
|
856
|
+
ORDER BY timestamp DESC LIMIT 1
|
|
857
|
+
`).get(session_id) as { metadata: string | null } | undefined;
|
|
858
|
+
|
|
859
|
+
let summary: Record<string, unknown> | null = null;
|
|
860
|
+
if (summaryEvent?.metadata) {
|
|
861
|
+
try {
|
|
862
|
+
summary = JSON.parse(summaryEvent.metadata);
|
|
863
|
+
} catch { /* ignore */ }
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
return { results, summary };
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
export function listSessions(
|
|
870
|
+
limit: number = 10,
|
|
871
|
+
status?: string,
|
|
872
|
+
parent_session_id?: string | null,
|
|
873
|
+
user_email?: string
|
|
874
|
+
): Session[] {
|
|
875
|
+
const d = getDb();
|
|
876
|
+
const conditions: string[] = [];
|
|
877
|
+
const params: unknown[] = [];
|
|
878
|
+
|
|
879
|
+
if (parent_session_id) {
|
|
880
|
+
conditions.push("parent_session_id = ?");
|
|
881
|
+
params.push(parent_session_id);
|
|
882
|
+
} else {
|
|
883
|
+
// Default: only show top-level sessions (no parent)
|
|
884
|
+
conditions.push("parent_session_id IS NULL");
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
if (status) {
|
|
888
|
+
conditions.push("status = ?");
|
|
889
|
+
params.push(status);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
if (user_email) {
|
|
893
|
+
conditions.push("user_email = ?");
|
|
894
|
+
params.push(user_email);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
898
|
+
params.push(limit);
|
|
899
|
+
|
|
900
|
+
return d
|
|
901
|
+
.prepare(`SELECT * FROM sessions ${where} ORDER BY created_at DESC LIMIT ?`)
|
|
902
|
+
.all(...params) as Session[];
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// --- Walkthrough functions ---
|
|
906
|
+
|
|
907
|
+
export function createWalkthrough(
|
|
908
|
+
url: string,
|
|
909
|
+
app_name?: string,
|
|
910
|
+
scenario?: string
|
|
911
|
+
): number {
|
|
912
|
+
const d = getDb();
|
|
913
|
+
const result = d.prepare(`
|
|
914
|
+
INSERT INTO walkthroughs (url, app_name, scenario)
|
|
915
|
+
VALUES (?, ?, ?)
|
|
916
|
+
`).run(url, app_name ?? null, scenario ?? null);
|
|
917
|
+
return Number(result.lastInsertRowid);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
export function insertWalkthroughAction(
|
|
921
|
+
walkthrough_id: number,
|
|
922
|
+
action_type: string,
|
|
923
|
+
target: string,
|
|
924
|
+
value?: string,
|
|
925
|
+
status?: string,
|
|
926
|
+
message?: string
|
|
927
|
+
): number {
|
|
928
|
+
const d = getDb();
|
|
929
|
+
const result = d.prepare(`
|
|
930
|
+
INSERT INTO walkthrough_actions (walkthrough_id, action_type, target, value, status, message)
|
|
931
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
932
|
+
`).run(walkthrough_id, action_type, target, value ?? null, status ?? "pass", message ?? null);
|
|
933
|
+
return Number(result.lastInsertRowid);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
export function insertWalkthroughScreenshot(
|
|
937
|
+
walkthrough_id: number,
|
|
938
|
+
name: string,
|
|
939
|
+
image_data?: string,
|
|
940
|
+
annotation?: string,
|
|
941
|
+
action_id?: number,
|
|
942
|
+
file_path_input?: string
|
|
943
|
+
): number {
|
|
944
|
+
const d = getDb();
|
|
945
|
+
|
|
946
|
+
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
947
|
+
const filename = `wt_${walkthrough_id}_${Date.now()}_${safeName}.png`;
|
|
948
|
+
const storedPath = path.join("/data", "walkthrough-screenshots", filename);
|
|
949
|
+
|
|
950
|
+
if (file_path_input && fs.existsSync(file_path_input)) {
|
|
951
|
+
// Copy from file path
|
|
952
|
+
fs.copyFileSync(file_path_input, storedPath);
|
|
953
|
+
} else if (image_data) {
|
|
954
|
+
// Decode base64 and save to disk
|
|
955
|
+
const buffer = Buffer.from(image_data, "base64");
|
|
956
|
+
fs.writeFileSync(storedPath, buffer);
|
|
957
|
+
} else {
|
|
958
|
+
throw new Error("Either image_data or file_path must be provided");
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
const result = d.prepare(`
|
|
962
|
+
INSERT INTO walkthrough_screenshots (walkthrough_id, action_id, name, annotation, stored_path)
|
|
963
|
+
VALUES (?, ?, ?, ?, ?)
|
|
964
|
+
`).run(walkthrough_id, action_id ?? null, name, annotation ?? null, storedPath);
|
|
965
|
+
return Number(result.lastInsertRowid);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
export function endWalkthrough(
|
|
969
|
+
walkthrough_id: number,
|
|
970
|
+
status: string,
|
|
971
|
+
summary: string,
|
|
972
|
+
total_actions?: number,
|
|
973
|
+
passed?: number,
|
|
974
|
+
failed?: number
|
|
975
|
+
): void {
|
|
976
|
+
const d = getDb();
|
|
977
|
+
d.prepare(`
|
|
978
|
+
UPDATE walkthroughs
|
|
979
|
+
SET status = ?, summary = ?, total_actions = ?, passed = ?, failed = ?, ended_at = datetime('now')
|
|
980
|
+
WHERE id = ?
|
|
981
|
+
`).run(status, summary, total_actions ?? 0, passed ?? 0, failed ?? 0, walkthrough_id);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
export function listWalkthroughs(limit: number = 50): Walkthrough[] {
|
|
985
|
+
const d = getDb();
|
|
986
|
+
return d.prepare(
|
|
987
|
+
"SELECT * FROM walkthroughs ORDER BY started_at DESC LIMIT ?"
|
|
988
|
+
).all(limit) as Walkthrough[];
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
export function getWalkthroughDetail(id: number): {
|
|
992
|
+
walkthrough: Walkthrough;
|
|
993
|
+
actions: WalkthroughAction[];
|
|
994
|
+
screenshots: WalkthroughScreenshot[];
|
|
995
|
+
} | null {
|
|
996
|
+
const d = getDb();
|
|
997
|
+
const walkthrough = d.prepare("SELECT * FROM walkthroughs WHERE id = ?").get(id) as Walkthrough | undefined;
|
|
998
|
+
if (!walkthrough) return null;
|
|
999
|
+
|
|
1000
|
+
const actions = d.prepare(
|
|
1001
|
+
"SELECT * FROM walkthrough_actions WHERE walkthrough_id = ? ORDER BY timestamp ASC"
|
|
1002
|
+
).all(id) as WalkthroughAction[];
|
|
1003
|
+
|
|
1004
|
+
const screenshots = d.prepare(
|
|
1005
|
+
"SELECT * FROM walkthrough_screenshots WHERE walkthrough_id = ? ORDER BY timestamp ASC"
|
|
1006
|
+
).all(id) as WalkthroughScreenshot[];
|
|
1007
|
+
|
|
1008
|
+
return { walkthrough, actions, screenshots };
|
|
1009
|
+
}
|