@townco/debugger 0.1.23 → 0.1.25

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/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@townco/debugger",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.3.0"
7
7
  },
8
8
  "files": [
9
- "src"
9
+ "src",
10
+ "tsconfig.json"
10
11
  ],
11
12
  "scripts": {
12
13
  "dev": "bun --hot src/index.ts",
@@ -19,18 +20,19 @@
19
20
  "@radix-ui/react-select": "^2.2.6",
20
21
  "@radix-ui/react-slot": "^1.2.3",
21
22
  "@radix-ui/react-tabs": "^1.1.0",
22
- "@townco/otlp-server": "0.1.23",
23
- "@townco/ui": "0.1.68",
23
+ "@townco/otlp-server": "0.1.25",
24
+ "@townco/ui": "0.1.70",
24
25
  "bun-plugin-tailwind": "^0.1.2",
25
26
  "class-variance-authority": "^0.7.1",
26
27
  "clsx": "^2.1.1",
27
28
  "lucide-react": "^0.545.0",
28
- "react": "^19",
29
- "react-dom": "^19",
30
- "tailwind-merge": "^3.3.1"
29
+ "react": "19.2.1",
30
+ "react-dom": "19.2.1",
31
+ "tailwind-merge": "^3.3.1",
32
+ "zod": "^4.1.13"
31
33
  },
32
34
  "devDependencies": {
33
- "@townco/tsconfig": "0.1.65",
35
+ "@townco/tsconfig": "0.1.67",
34
36
  "@types/bun": "latest",
35
37
  "@types/react": "^19",
36
38
  "@types/react-dom": "^19",
package/src/App.tsx CHANGED
@@ -1,8 +1,10 @@
1
1
  import { ThemeProvider } from "@townco/ui/gui";
2
2
  import { Component, type ReactNode } from "react";
3
3
  import "./index.css";
4
+ import { ComparisonView } from "./pages/ComparisonView";
4
5
  import { SessionList } from "./pages/SessionList";
5
6
  import { SessionView } from "./pages/SessionView";
7
+ import { TownHall } from "./pages/TownHall";
6
8
  import { TraceDetail } from "./pages/TraceDetail";
7
9
  import { TraceList } from "./pages/TraceList";
8
10
 
@@ -95,6 +97,17 @@ function AppContent() {
95
97
  return <TraceList />;
96
98
  }
97
99
 
100
+ // Route: /town-hall/compare/:runId
101
+ const comparisonMatch = pathname.match(/^\/town-hall\/compare\/(.+)$/);
102
+ if (comparisonMatch?.[1]) {
103
+ return <ComparisonView runId={comparisonMatch[1]} />;
104
+ }
105
+
106
+ // Route: /town-hall
107
+ if (pathname === "/town-hall") {
108
+ return <TownHall />;
109
+ }
110
+
98
111
  // Default: Session list
99
112
  return <SessionList />;
100
113
  }
@@ -0,0 +1,113 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { ComparisonDb } from "./comparison-db";
4
+
5
+ const TEST_DB = "test-comparison.db";
6
+
7
+ describe("ComparisonDb", () => {
8
+ let db: ComparisonDb;
9
+
10
+ beforeEach(() => {
11
+ db = new ComparisonDb(TEST_DB);
12
+ });
13
+
14
+ afterEach(async () => {
15
+ db.close();
16
+ try {
17
+ await unlink(TEST_DB);
18
+ } catch {}
19
+ });
20
+
21
+ test("initializes tables and migrates", () => {
22
+ // Should not throw
23
+ new ComparisonDb(TEST_DB);
24
+ });
25
+
26
+ test("saves and retrieves config", () => {
27
+ const now = new Date().toISOString();
28
+ const config = {
29
+ id: "test-config",
30
+ dimension: "model" as const,
31
+ controlModel: "model-a",
32
+ variantModel: "model-b",
33
+ createdAt: now,
34
+ updatedAt: now,
35
+ };
36
+
37
+ db.saveConfig(config);
38
+ const saved = db.getConfig("test-config");
39
+
40
+ // Compare non-timestamp fields and verify timestamps exist
41
+ expect(saved?.id).toBe(config.id);
42
+ expect(saved?.dimension).toBe(config.dimension);
43
+ expect(saved?.controlModel).toBe(config.controlModel);
44
+ expect(saved?.variantModel).toBe(config.variantModel);
45
+ expect(saved?.createdAt).toBeDefined();
46
+ expect(saved?.updatedAt).toBeDefined();
47
+ // Optional fields should be undefined when not set
48
+ expect(saved?.variantSystemPrompt).toBeUndefined();
49
+ expect(saved?.variantTools).toBeUndefined();
50
+ });
51
+
52
+ test("saves and retrieves config with tools", () => {
53
+ const config = {
54
+ id: "test-config-tools",
55
+ dimension: "tools" as const,
56
+ variantTools: ["tool-a", "tool-b"],
57
+ createdAt: new Date().toISOString(),
58
+ updatedAt: new Date().toISOString(),
59
+ };
60
+
61
+ db.saveConfig(config);
62
+ const saved = db.getConfig("test-config-tools");
63
+
64
+ expect(saved?.variantTools).toEqual(["tool-a", "tool-b"]);
65
+ });
66
+
67
+ test("creates and lists runs", () => {
68
+ const configId = "config-1";
69
+ db.saveConfig({
70
+ id: configId,
71
+ dimension: "model",
72
+ createdAt: new Date().toISOString(),
73
+ updatedAt: new Date().toISOString(),
74
+ });
75
+
76
+ const run = db.createRun(configId, "session-1", "Hello");
77
+ expect(run.id).toBeDefined();
78
+ expect(run.status).toBe("pending");
79
+
80
+ const runs = db.listRuns();
81
+ expect(runs.length).toBe(1);
82
+ expect(runs[0]?.id).toBe(run.id);
83
+ });
84
+
85
+ test("updates run status and metrics", () => {
86
+ const configId = "config-1";
87
+ db.saveConfig({
88
+ id: configId,
89
+ dimension: "model",
90
+ createdAt: new Date().toISOString(),
91
+ updatedAt: new Date().toISOString(),
92
+ });
93
+
94
+ const run = db.createRun(configId, "session-1", "Hello");
95
+
96
+ const metrics = {
97
+ durationMs: 1000,
98
+ inputTokens: 10,
99
+ outputTokens: 20,
100
+ totalTokens: 30,
101
+ estimatedCost: 0.01,
102
+ toolCallCount: 1,
103
+ };
104
+
105
+ db.updateRunStatus(run.id, "completed", {
106
+ controlMetrics: metrics,
107
+ });
108
+
109
+ const updated = db.getRun(run.id);
110
+ expect(updated?.status).toBe("completed");
111
+ expect(updated?.controlMetrics).toEqual(metrics);
112
+ });
113
+ });
@@ -0,0 +1,332 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { SessionMetricsSchema, VariantToolsSchema } from "./schemas";
3
+ import type {
4
+ ComparisonConfig,
5
+ ComparisonConfigRow,
6
+ ComparisonRun,
7
+ ComparisonRunRow,
8
+ SessionMetrics,
9
+ } from "./types";
10
+
11
+ const SCHEMA_VERSION = 1;
12
+
13
+ export class ComparisonDb {
14
+ private db: Database;
15
+
16
+ constructor(dbPath: string) {
17
+ this.db = new Database(dbPath);
18
+ this.migrate();
19
+ }
20
+
21
+ close() {
22
+ this.db.close();
23
+ }
24
+
25
+ private migrate() {
26
+ // Get current schema version
27
+ const versionRow = this.db
28
+ .query<{ user_version: number }, []>("PRAGMA user_version")
29
+ .get();
30
+ const currentVersion = versionRow?.user_version ?? 0;
31
+
32
+ if (currentVersion < 1) {
33
+ // Initial schema
34
+ this.db.run(`
35
+ CREATE TABLE IF NOT EXISTS comparison_configs (
36
+ id TEXT PRIMARY KEY,
37
+ dimension TEXT NOT NULL,
38
+ control_model TEXT,
39
+ variant_model TEXT,
40
+ variant_system_prompt TEXT,
41
+ variant_tools TEXT,
42
+ created_at TEXT NOT NULL,
43
+ updated_at TEXT NOT NULL
44
+ )
45
+ `);
46
+
47
+ this.db.run(`
48
+ CREATE TABLE IF NOT EXISTS comparison_runs (
49
+ id TEXT PRIMARY KEY,
50
+ config_id TEXT NOT NULL,
51
+ source_session_id TEXT NOT NULL,
52
+ first_user_message TEXT NOT NULL,
53
+ start_message_index INTEGER NOT NULL DEFAULT 0,
54
+ turn_count INTEGER NOT NULL DEFAULT 1,
55
+ control_session_id TEXT,
56
+ variant_session_id TEXT,
57
+ status TEXT NOT NULL DEFAULT 'pending',
58
+ started_at TEXT NOT NULL,
59
+ completed_at TEXT,
60
+ control_metrics TEXT,
61
+ variant_metrics TEXT,
62
+ control_response TEXT,
63
+ variant_response TEXT,
64
+ FOREIGN KEY (config_id) REFERENCES comparison_configs(id)
65
+ )
66
+ `);
67
+
68
+ this.db.run(`
69
+ CREATE INDEX IF NOT EXISTS idx_comparison_runs_source_session
70
+ ON comparison_runs(source_session_id)
71
+ `);
72
+ }
73
+
74
+ // Future migrations would go here:
75
+ // if (currentVersion < 2) { ... }
76
+
77
+ // Update schema version
78
+ this.db.run(`PRAGMA user_version = ${SCHEMA_VERSION}`);
79
+ }
80
+
81
+ // Comparison Config methods
82
+
83
+ saveConfig(config: ComparisonConfig): void {
84
+ const now = new Date().toISOString();
85
+ this.db.run(
86
+ `
87
+ INSERT INTO comparison_configs (id, dimension, control_model, variant_model, variant_system_prompt, variant_tools, created_at, updated_at)
88
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
89
+ ON CONFLICT(id) DO UPDATE SET
90
+ dimension = excluded.dimension,
91
+ control_model = excluded.control_model,
92
+ variant_model = excluded.variant_model,
93
+ variant_system_prompt = excluded.variant_system_prompt,
94
+ variant_tools = excluded.variant_tools,
95
+ updated_at = excluded.updated_at
96
+ `,
97
+ [
98
+ config.id,
99
+ config.dimension,
100
+ config.controlModel ?? null,
101
+ config.variantModel ?? null,
102
+ config.variantSystemPrompt ?? null,
103
+ config.variantTools ? JSON.stringify(config.variantTools) : null,
104
+ config.createdAt || now,
105
+ now,
106
+ ],
107
+ );
108
+ }
109
+
110
+ getConfig(id: string): ComparisonConfig | null {
111
+ const row = this.db
112
+ .query<ComparisonConfigRow, [string]>(
113
+ `SELECT * FROM comparison_configs WHERE id = ?`,
114
+ )
115
+ .get(id);
116
+
117
+ if (!row) return null;
118
+ return this.rowToConfig(row);
119
+ }
120
+
121
+ getLatestConfig(): ComparisonConfig | null {
122
+ const row = this.db
123
+ .query<ComparisonConfigRow, []>(
124
+ `SELECT * FROM comparison_configs ORDER BY updated_at DESC LIMIT 1`,
125
+ )
126
+ .get();
127
+
128
+ if (!row) return null;
129
+ return this.rowToConfig(row);
130
+ }
131
+
132
+ private rowToConfig(row: ComparisonConfigRow): ComparisonConfig {
133
+ let variantTools: string[] | undefined;
134
+ if (row.variant_tools) {
135
+ try {
136
+ const parsed = JSON.parse(row.variant_tools);
137
+ const result = VariantToolsSchema.safeParse(parsed);
138
+ if (result.success) {
139
+ variantTools = result.data;
140
+ } else {
141
+ console.error("Invalid variant_tools schema:", result.error);
142
+ }
143
+ } catch (e) {
144
+ console.error("Failed to parse variant_tools JSON:", e);
145
+ }
146
+ }
147
+
148
+ return {
149
+ id: row.id,
150
+ dimension: row.dimension as ComparisonConfig["dimension"],
151
+ controlModel: row.control_model ?? undefined,
152
+ variantModel: row.variant_model ?? undefined,
153
+ variantSystemPrompt: row.variant_system_prompt ?? undefined,
154
+ variantTools,
155
+ createdAt: row.created_at,
156
+ updatedAt: row.updated_at,
157
+ };
158
+ }
159
+
160
+ // Comparison Run methods
161
+
162
+ createRun(
163
+ configId: string,
164
+ sourceSessionId: string,
165
+ firstUserMessage: string,
166
+ ): ComparisonRun {
167
+ const id = crypto.randomUUID();
168
+ const now = new Date().toISOString();
169
+
170
+ this.db.run(
171
+ `
172
+ INSERT INTO comparison_runs (id, config_id, source_session_id, first_user_message, started_at, status)
173
+ VALUES (?, ?, ?, ?, ?, 'pending')
174
+ `,
175
+ [id, configId, sourceSessionId, firstUserMessage, now],
176
+ );
177
+
178
+ return {
179
+ id,
180
+ configId,
181
+ sourceSessionId,
182
+ firstUserMessage,
183
+ startMessageIndex: 0,
184
+ turnCount: 1,
185
+ controlSessionId: null,
186
+ variantSessionId: null,
187
+ status: "pending",
188
+ startedAt: now,
189
+ completedAt: null,
190
+ controlMetrics: null,
191
+ variantMetrics: null,
192
+ controlResponse: null,
193
+ variantResponse: null,
194
+ };
195
+ }
196
+
197
+ updateRunStatus(
198
+ id: string,
199
+ status: ComparisonRun["status"],
200
+ updates?: {
201
+ controlSessionId?: string;
202
+ variantSessionId?: string;
203
+ controlMetrics?: SessionMetrics;
204
+ variantMetrics?: SessionMetrics;
205
+ controlResponse?: string;
206
+ variantResponse?: string;
207
+ },
208
+ ): void {
209
+ const completedAt =
210
+ status === "completed" || status === "failed"
211
+ ? new Date().toISOString()
212
+ : null;
213
+
214
+ this.db.run(
215
+ `
216
+ UPDATE comparison_runs SET
217
+ status = ?,
218
+ completed_at = COALESCE(?, completed_at),
219
+ control_session_id = COALESCE(?, control_session_id),
220
+ variant_session_id = COALESCE(?, variant_session_id),
221
+ control_metrics = COALESCE(?, control_metrics),
222
+ variant_metrics = COALESCE(?, variant_metrics),
223
+ control_response = COALESCE(?, control_response),
224
+ variant_response = COALESCE(?, variant_response)
225
+ WHERE id = ?
226
+ `,
227
+ [
228
+ status,
229
+ completedAt,
230
+ updates?.controlSessionId ?? null,
231
+ updates?.variantSessionId ?? null,
232
+ updates?.controlMetrics ? JSON.stringify(updates.controlMetrics) : null,
233
+ updates?.variantMetrics ? JSON.stringify(updates.variantMetrics) : null,
234
+ updates?.controlResponse ?? null,
235
+ updates?.variantResponse ?? null,
236
+ id,
237
+ ],
238
+ );
239
+ }
240
+
241
+ getRun(id: string): ComparisonRun | null {
242
+ const row = this.db
243
+ .query<ComparisonRunRow, [string]>(
244
+ `SELECT * FROM comparison_runs WHERE id = ?`,
245
+ )
246
+ .get(id);
247
+
248
+ if (!row) return null;
249
+ return this.rowToRun(row);
250
+ }
251
+
252
+ listRuns(limit = 50, offset = 0): ComparisonRun[] {
253
+ const rows = this.db
254
+ .query<ComparisonRunRow, [number, number]>(
255
+ `SELECT * FROM comparison_runs ORDER BY started_at DESC LIMIT ? OFFSET ?`,
256
+ )
257
+ .all(limit, offset);
258
+
259
+ return rows.map((row) => this.rowToRun(row));
260
+ }
261
+
262
+ listRunsBySourceSession(sourceSessionId: string): ComparisonRun[] {
263
+ const rows = this.db
264
+ .query<ComparisonRunRow, [string]>(
265
+ `SELECT * FROM comparison_runs WHERE source_session_id = ? ORDER BY started_at DESC`,
266
+ )
267
+ .all(sourceSessionId);
268
+
269
+ return rows.map((row) => this.rowToRun(row));
270
+ }
271
+
272
+ /**
273
+ * Get all session IDs that were created as part of comparison runs.
274
+ * This includes both control and variant session IDs.
275
+ */
276
+ getComparisonSessionIds(): string[] {
277
+ const rows = this.db
278
+ .query<
279
+ {
280
+ control_session_id: string | null;
281
+ variant_session_id: string | null;
282
+ },
283
+ []
284
+ >(
285
+ `SELECT control_session_id, variant_session_id FROM comparison_runs WHERE control_session_id IS NOT NULL OR variant_session_id IS NOT NULL`,
286
+ )
287
+ .all();
288
+
289
+ const sessionIds: string[] = [];
290
+ for (const row of rows) {
291
+ if (row.control_session_id) sessionIds.push(row.control_session_id);
292
+ if (row.variant_session_id) sessionIds.push(row.variant_session_id);
293
+ }
294
+ return sessionIds;
295
+ }
296
+
297
+ private parseMetrics(json: string | null): SessionMetrics | null {
298
+ if (!json) return null;
299
+ try {
300
+ const parsed = JSON.parse(json);
301
+ const result = SessionMetricsSchema.safeParse(parsed);
302
+ if (result.success) {
303
+ return result.data;
304
+ }
305
+ console.error("Invalid metrics schema:", result.error);
306
+ return null;
307
+ } catch (e) {
308
+ console.error("Failed to parse metrics JSON:", e);
309
+ return null;
310
+ }
311
+ }
312
+
313
+ private rowToRun(row: ComparisonRunRow): ComparisonRun {
314
+ return {
315
+ id: row.id,
316
+ configId: row.config_id,
317
+ sourceSessionId: row.source_session_id,
318
+ firstUserMessage: row.first_user_message,
319
+ startMessageIndex: row.start_message_index,
320
+ turnCount: row.turn_count,
321
+ controlSessionId: row.control_session_id,
322
+ variantSessionId: row.variant_session_id,
323
+ status: row.status as ComparisonRun["status"],
324
+ startedAt: row.started_at,
325
+ completedAt: row.completed_at,
326
+ controlMetrics: this.parseMetrics(row.control_metrics),
327
+ variantMetrics: this.parseMetrics(row.variant_metrics),
328
+ controlResponse: row.control_response ?? null,
329
+ variantResponse: row.variant_response ?? null,
330
+ };
331
+ }
332
+ }
@@ -1,5 +1,14 @@
1
- import { ChatHeader, cn, ThemeToggle } from "@townco/ui/gui";
2
- import { ArrowLeft } from "lucide-react";
1
+ import {
2
+ ChatHeader,
3
+ cn,
4
+ DropdownMenu,
5
+ DropdownMenuContent,
6
+ DropdownMenuItem,
7
+ DropdownMenuSeparator,
8
+ DropdownMenuTrigger,
9
+ ThemeToggle,
10
+ } from "@townco/ui/gui";
11
+ import { ArrowLeft, Database, MoreVertical } from "lucide-react";
3
12
  import { useEffect, useState } from "react";
4
13
  import { Button } from "@/components/ui/button";
5
14
 
@@ -20,6 +29,7 @@ export function DebuggerHeader({
20
29
  typeof window !== "undefined" ? window.location.pathname : "/";
21
30
  const isSessionsActive = pathname === "/";
22
31
  const isTracesActive = pathname === "/traces";
32
+ const isTownHallActive = pathname.startsWith("/town-hall");
23
33
  const [agentName, setAgentName] = useState<string>("Agent");
24
34
 
25
35
  useEffect(() => {
@@ -35,6 +45,32 @@ export function DebuggerHeader({
35
45
  });
36
46
  }, []);
37
47
 
48
+ const handleResetDatabase = async () => {
49
+ if (
50
+ !window.confirm(
51
+ "Are you sure you want to reset the database? This will delete all traces and sessions.",
52
+ )
53
+ ) {
54
+ return;
55
+ }
56
+
57
+ try {
58
+ const response = await fetch("/api/reset-database", {
59
+ method: "POST",
60
+ });
61
+
62
+ if (response.ok) {
63
+ alert("Database reset successfully. Redirecting to home...");
64
+ window.location.href = "/";
65
+ } else {
66
+ const error = await response.text();
67
+ alert(`Failed to reset database: ${error}`);
68
+ }
69
+ } catch (error) {
70
+ alert(`Error resetting database: ${error}`);
71
+ }
72
+ };
73
+
38
74
  return (
39
75
  <ChatHeader.Root className="border-b border-border bg-card h-16">
40
76
  <div className="flex items-center gap-3 flex-1">
@@ -71,6 +107,17 @@ export function DebuggerHeader({
71
107
  >
72
108
  Traces
73
109
  </a>
110
+ <a
111
+ href="/town-hall"
112
+ className={cn(
113
+ "px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
114
+ isTownHallActive
115
+ ? "bg-muted text-foreground"
116
+ : "text-muted-foreground hover:text-foreground hover:bg-muted/50",
117
+ )}
118
+ >
119
+ Town Hall
120
+ </a>
74
121
  </nav>
75
122
  </div>
76
123
  ) : (
@@ -78,6 +125,19 @@ export function DebuggerHeader({
78
125
  )}
79
126
  </div>
80
127
  <ChatHeader.Actions>
128
+ <DropdownMenu>
129
+ <DropdownMenuTrigger asChild>
130
+ <Button variant="ghost" size="icon">
131
+ <MoreVertical className="size-4" />
132
+ </Button>
133
+ </DropdownMenuTrigger>
134
+ <DropdownMenuContent align="end">
135
+ <DropdownMenuItem onClick={handleResetDatabase}>
136
+ <Database className="mr-2 size-4" />
137
+ Reset Database
138
+ </DropdownMenuItem>
139
+ </DropdownMenuContent>
140
+ </DropdownMenu>
81
141
  <ThemeToggle />
82
142
  </ChatHeader.Actions>
83
143
  </ChatHeader.Root>