@townco/debugger 0.1.27 → 0.1.29
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 +7 -4
- package/src/App.tsx +6 -0
- package/src/analysis/analyzer.ts +235 -0
- package/src/analysis/embeddings.ts +97 -0
- package/src/analysis/schema.ts +67 -0
- package/src/analysis/types.ts +132 -0
- package/src/analysis-db.ts +238 -0
- package/src/comparison-db.test.ts +28 -5
- package/src/comparison-db.ts +57 -9
- package/src/components/AnalyzeAllButton.tsx +81 -0
- package/src/components/DebuggerHeader.tsx +12 -0
- package/src/components/SessionAnalysisButton.tsx +109 -0
- package/src/components/SessionAnalysisDialog.tsx +155 -0
- package/src/components/UnifiedTimeline.tsx +3 -3
- package/src/components/ui/dialog.tsx +120 -0
- package/src/db.ts +3 -2
- package/src/lib/metrics.ts +101 -5
- package/src/pages/ComparisonView.tsx +258 -135
- package/src/pages/FindSessions.tsx +230 -0
- package/src/pages/SessionList.tsx +76 -10
- package/src/pages/SessionView.tsx +33 -1
- package/src/pages/TownHall.tsx +345 -187
- package/src/schemas.ts +27 -8
- package/src/server.ts +337 -3
- package/src/types.ts +11 -2
package/src/schemas.ts
CHANGED
|
@@ -1,15 +1,34 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
const numberWithDefault = z
|
|
4
|
+
.number()
|
|
5
|
+
.optional()
|
|
6
|
+
.transform((val) => val ?? 0);
|
|
4
7
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
toolCallCount: z.number(),
|
|
8
|
+
const ToolCallSchema = z.object({
|
|
9
|
+
name: z.string(),
|
|
10
|
+
input: z.unknown(),
|
|
11
|
+
output: z.unknown(),
|
|
12
|
+
startTimeUnixNano: z.number().optional(),
|
|
13
|
+
endTimeUnixNano: z.number().optional(),
|
|
12
14
|
});
|
|
13
15
|
|
|
16
|
+
export const VariantToolsSchema = z.array(z.string());
|
|
17
|
+
|
|
18
|
+
export const SessionMetricsSchema = z
|
|
19
|
+
.object({
|
|
20
|
+
durationMs: numberWithDefault,
|
|
21
|
+
inputTokens: numberWithDefault,
|
|
22
|
+
outputTokens: numberWithDefault,
|
|
23
|
+
totalTokens: numberWithDefault,
|
|
24
|
+
estimatedCost: z.number().catch(0),
|
|
25
|
+
toolCallCount: numberWithDefault,
|
|
26
|
+
toolCalls: z.array(ToolCallSchema).optional().default([]),
|
|
27
|
+
})
|
|
28
|
+
.transform((metrics) => ({
|
|
29
|
+
...metrics,
|
|
30
|
+
toolCalls: metrics.toolCalls ?? [],
|
|
31
|
+
}));
|
|
32
|
+
|
|
14
33
|
export type VariantTools = z.infer<typeof VariantToolsSchema>;
|
|
15
34
|
export type SessionMetrics = z.infer<typeof SessionMetricsSchema>;
|
package/src/server.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resetDb } from "@townco/otlp-server/db";
|
|
2
2
|
import { createOtlpServer } from "@townco/otlp-server/http";
|
|
3
3
|
import { serve } from "bun";
|
|
4
|
+
import { AnalysisDb } from "./analysis-db";
|
|
4
5
|
import { ComparisonDb } from "./comparison-db";
|
|
5
6
|
import { DebuggerDb } from "./db";
|
|
6
7
|
import index from "./index.html";
|
|
@@ -10,6 +11,7 @@ import type {
|
|
|
10
11
|
AgentConfig,
|
|
11
12
|
ComparisonConfig,
|
|
12
13
|
ConversationTrace,
|
|
14
|
+
SessionMetrics,
|
|
13
15
|
Span,
|
|
14
16
|
} from "./types";
|
|
15
17
|
|
|
@@ -56,6 +58,9 @@ export function startDebuggerServer(
|
|
|
56
58
|
const comparisonDbPath = dbPath.replace(/\.db$/, "-comparison.db");
|
|
57
59
|
const comparisonDb = new ComparisonDb(comparisonDbPath);
|
|
58
60
|
|
|
61
|
+
// Create analysis database - uses main debugger database
|
|
62
|
+
const analysisDb = new AnalysisDb(dbPath);
|
|
63
|
+
|
|
59
64
|
// Helper to fetch agent config from agent server
|
|
60
65
|
async function fetchAgentConfig(): Promise<AgentConfig | null> {
|
|
61
66
|
try {
|
|
@@ -133,7 +138,9 @@ export function startDebuggerServer(
|
|
|
133
138
|
"/api/sessions": {
|
|
134
139
|
GET(req) {
|
|
135
140
|
const url = new URL(req.url);
|
|
136
|
-
const limit = Number.parseInt(
|
|
141
|
+
const limit = Number.parseInt(
|
|
142
|
+
url.searchParams.get("limit") || "1000",
|
|
143
|
+
);
|
|
137
144
|
const offset = Number.parseInt(url.searchParams.get("offset") || "0");
|
|
138
145
|
const sessions = db.listSessions(limit, offset);
|
|
139
146
|
return Response.json(sessions);
|
|
@@ -258,7 +265,7 @@ export function startDebuggerServer(
|
|
|
258
265
|
const body = await req.json();
|
|
259
266
|
const config: ComparisonConfig = {
|
|
260
267
|
id: body.id || crypto.randomUUID(),
|
|
261
|
-
|
|
268
|
+
dimensions: body.dimensions || [],
|
|
262
269
|
controlModel: body.controlModel,
|
|
263
270
|
variantModel: body.variantModel,
|
|
264
271
|
variantSystemPrompt: body.variantSystemPrompt,
|
|
@@ -269,6 +276,7 @@ export function startDebuggerServer(
|
|
|
269
276
|
comparisonDb.saveConfig(config);
|
|
270
277
|
return Response.json({ id: config.id });
|
|
271
278
|
} catch (error) {
|
|
279
|
+
console.error("Error saving comparison config:", error);
|
|
272
280
|
return Response.json(
|
|
273
281
|
{ error: "Invalid request body" },
|
|
274
282
|
{ status: 400 },
|
|
@@ -311,7 +319,53 @@ export function startDebuggerServer(
|
|
|
311
319
|
{ status: 404 },
|
|
312
320
|
);
|
|
313
321
|
}
|
|
314
|
-
|
|
322
|
+
|
|
323
|
+
const config = comparisonDb.getConfig(run.configId);
|
|
324
|
+
const controlModel =
|
|
325
|
+
config?.controlModel ??
|
|
326
|
+
config?.variantModel ??
|
|
327
|
+
"claude-sonnet-4-5-20250929";
|
|
328
|
+
const variantModel =
|
|
329
|
+
config?.variantModel ??
|
|
330
|
+
config?.controlModel ??
|
|
331
|
+
"claude-sonnet-4-5-20250929";
|
|
332
|
+
|
|
333
|
+
const maybeRefreshMetrics = (
|
|
334
|
+
sessionId: string | null,
|
|
335
|
+
cached: SessionMetrics | null,
|
|
336
|
+
model: string,
|
|
337
|
+
): SessionMetrics | null => {
|
|
338
|
+
if (!sessionId) return cached;
|
|
339
|
+
const needsRefresh =
|
|
340
|
+
!cached ||
|
|
341
|
+
cached.totalTokens === 0 ||
|
|
342
|
+
cached.toolCallCount === 0 ||
|
|
343
|
+
!cached.toolCalls ||
|
|
344
|
+
cached.toolCalls.length === 0;
|
|
345
|
+
if (!needsRefresh) return cached;
|
|
346
|
+
|
|
347
|
+
const spans = db.getSpansBySessionAttribute(sessionId);
|
|
348
|
+
if (spans.length === 0) return cached;
|
|
349
|
+
const traces = db.listTraces(100, 0, sessionId);
|
|
350
|
+
return extractSessionMetrics(traces, spans, model);
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const controlMetrics = maybeRefreshMetrics(
|
|
354
|
+
run.controlSessionId,
|
|
355
|
+
run.controlMetrics,
|
|
356
|
+
controlModel,
|
|
357
|
+
);
|
|
358
|
+
const variantMetrics = maybeRefreshMetrics(
|
|
359
|
+
run.variantSessionId,
|
|
360
|
+
run.variantMetrics,
|
|
361
|
+
variantModel,
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
return Response.json({
|
|
365
|
+
...run,
|
|
366
|
+
controlMetrics,
|
|
367
|
+
variantMetrics,
|
|
368
|
+
});
|
|
315
369
|
},
|
|
316
370
|
},
|
|
317
371
|
|
|
@@ -449,6 +503,279 @@ export function startDebuggerServer(
|
|
|
449
503
|
},
|
|
450
504
|
},
|
|
451
505
|
|
|
506
|
+
"/api/analyze-session/:sessionId": {
|
|
507
|
+
async POST(req) {
|
|
508
|
+
const sessionId = req.params.sessionId;
|
|
509
|
+
|
|
510
|
+
try {
|
|
511
|
+
// Import analyzer dynamically to avoid loading at startup
|
|
512
|
+
const { analyzeSession } = await import("./analysis/analyzer.js");
|
|
513
|
+
|
|
514
|
+
// Fetch session from agent server via ACP HTTP API
|
|
515
|
+
const sessionResponse = await fetch(
|
|
516
|
+
`${agentServerUrl}/sessions/${sessionId}`,
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
if (!sessionResponse.ok) {
|
|
520
|
+
if (sessionResponse.status === 404) {
|
|
521
|
+
return Response.json(
|
|
522
|
+
{ error: "Session not found" },
|
|
523
|
+
{ status: 404 },
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
throw new Error(
|
|
527
|
+
`Failed to fetch session: ${sessionResponse.statusText}`,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const sessionData = await sessionResponse.json();
|
|
532
|
+
|
|
533
|
+
// Analyze with LLM
|
|
534
|
+
const analysis = await analyzeSession(sessionData);
|
|
535
|
+
|
|
536
|
+
// Persist to database
|
|
537
|
+
analysisDb.saveAnalysis(analysis);
|
|
538
|
+
|
|
539
|
+
// Generate and save embedding
|
|
540
|
+
try {
|
|
541
|
+
const { embedAnalysis } = await import(
|
|
542
|
+
"./analysis/embeddings.js"
|
|
543
|
+
);
|
|
544
|
+
const embedding = await embedAnalysis(analysis);
|
|
545
|
+
await analysisDb.saveEmbedding(analysis.session_id, embedding);
|
|
546
|
+
} catch (error) {
|
|
547
|
+
console.error(
|
|
548
|
+
`Failed to generate embedding for ${sessionId}:`,
|
|
549
|
+
error,
|
|
550
|
+
);
|
|
551
|
+
// Continue - don't fail entire analysis
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return Response.json(analysis);
|
|
555
|
+
} catch (error) {
|
|
556
|
+
console.error("Session analysis error:", error);
|
|
557
|
+
return Response.json(
|
|
558
|
+
{
|
|
559
|
+
error:
|
|
560
|
+
error instanceof Error ? error.message : "Analysis failed",
|
|
561
|
+
},
|
|
562
|
+
{ status: 500 },
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
|
|
568
|
+
"/api/analyze-all-sessions": {
|
|
569
|
+
async POST(req) {
|
|
570
|
+
try {
|
|
571
|
+
const body = await req.json();
|
|
572
|
+
const { sessionIds } = body as { sessionIds: string[] };
|
|
573
|
+
|
|
574
|
+
if (!Array.isArray(sessionIds)) {
|
|
575
|
+
return Response.json(
|
|
576
|
+
{ error: "sessionIds must be an array" },
|
|
577
|
+
{ status: 400 },
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Import analyzer dynamically
|
|
582
|
+
const { analyzeSession } = await import("./analysis/analyzer.js");
|
|
583
|
+
|
|
584
|
+
// Process in batches of 25
|
|
585
|
+
const BATCH_SIZE = 25;
|
|
586
|
+
const results: Array<{
|
|
587
|
+
session_id: string;
|
|
588
|
+
success: boolean;
|
|
589
|
+
error?: string;
|
|
590
|
+
}> = [];
|
|
591
|
+
|
|
592
|
+
const totalBatches = Math.ceil(sessionIds.length / BATCH_SIZE);
|
|
593
|
+
console.log(
|
|
594
|
+
`✨ Starting batch analysis of ${sessionIds.length} sessions (${totalBatches} batches)...`,
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
for (let i = 0; i < sessionIds.length; i += BATCH_SIZE) {
|
|
598
|
+
const batch = sessionIds.slice(i, i + BATCH_SIZE);
|
|
599
|
+
const batchNum = Math.floor(i / BATCH_SIZE) + 1;
|
|
600
|
+
|
|
601
|
+
console.log(
|
|
602
|
+
`📊 Processing batch ${batchNum}/${totalBatches} (${batch.length} sessions)...`,
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
// Run batch in parallel
|
|
606
|
+
const batchResults = await Promise.allSettled(
|
|
607
|
+
batch.map(async (sessionId) => {
|
|
608
|
+
// Fetch session data
|
|
609
|
+
const sessionResponse = await fetch(
|
|
610
|
+
`${agentServerUrl}/sessions/${sessionId}`,
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
if (!sessionResponse.ok) {
|
|
614
|
+
throw new Error(`Failed to fetch session ${sessionId}`);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const sessionData = await sessionResponse.json();
|
|
618
|
+
|
|
619
|
+
// Analyze
|
|
620
|
+
const analysis = await analyzeSession(sessionData);
|
|
621
|
+
|
|
622
|
+
// Persist
|
|
623
|
+
analysisDb.saveAnalysis(analysis);
|
|
624
|
+
|
|
625
|
+
// Generate and save embedding
|
|
626
|
+
try {
|
|
627
|
+
const { embedAnalysis } = await import(
|
|
628
|
+
"./analysis/embeddings.js"
|
|
629
|
+
);
|
|
630
|
+
const embedding = await embedAnalysis(analysis);
|
|
631
|
+
await analysisDb.saveEmbedding(sessionId, embedding);
|
|
632
|
+
} catch (error) {
|
|
633
|
+
console.error(
|
|
634
|
+
`Failed to generate embedding for ${sessionId}:`,
|
|
635
|
+
error,
|
|
636
|
+
);
|
|
637
|
+
// Continue - batch processing continues
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return { session_id: sessionId, success: true };
|
|
641
|
+
}),
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
// Collect results
|
|
645
|
+
for (let j = 0; j < batchResults.length; j++) {
|
|
646
|
+
const result = batchResults[j];
|
|
647
|
+
const sessionId = batch[j];
|
|
648
|
+
if (!sessionId) continue;
|
|
649
|
+
|
|
650
|
+
if (result && result.status === "fulfilled") {
|
|
651
|
+
results.push(result.value);
|
|
652
|
+
} else if (result && result.status === "rejected") {
|
|
653
|
+
results.push({
|
|
654
|
+
session_id: sessionId,
|
|
655
|
+
success: false,
|
|
656
|
+
error:
|
|
657
|
+
result.reason instanceof Error
|
|
658
|
+
? result.reason.message
|
|
659
|
+
: String(result.reason || "Unknown error"),
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const batchSuccesses = batchResults.filter(
|
|
665
|
+
(r) => r.status === "fulfilled",
|
|
666
|
+
).length;
|
|
667
|
+
const batchErrors = batchResults.filter(
|
|
668
|
+
(r) => r.status === "rejected",
|
|
669
|
+
).length;
|
|
670
|
+
console.log(
|
|
671
|
+
`✅ Batch ${batchNum}/${totalBatches} complete: ${batchSuccesses} successful, ${batchErrors} failed`,
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const totalSuccesses = results.filter((r) => r.success).length;
|
|
676
|
+
const totalErrors = results.filter((r) => !r.success).length;
|
|
677
|
+
console.log(
|
|
678
|
+
`🎉 Batch analysis complete: ${totalSuccesses} successful, ${totalErrors} failed`,
|
|
679
|
+
);
|
|
680
|
+
|
|
681
|
+
return Response.json({ results });
|
|
682
|
+
} catch (error) {
|
|
683
|
+
console.error("Batch analysis error:", error);
|
|
684
|
+
return Response.json(
|
|
685
|
+
{
|
|
686
|
+
error:
|
|
687
|
+
error instanceof Error ? error.message : "Analysis failed",
|
|
688
|
+
},
|
|
689
|
+
{ status: 500 },
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
},
|
|
693
|
+
},
|
|
694
|
+
|
|
695
|
+
"/api/session-analyses": {
|
|
696
|
+
async GET(req) {
|
|
697
|
+
try {
|
|
698
|
+
const url = new URL(req.url);
|
|
699
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
700
|
+
|
|
701
|
+
if (sessionId) {
|
|
702
|
+
// Get single analysis
|
|
703
|
+
const analysis = analysisDb.getAnalysis(sessionId);
|
|
704
|
+
if (!analysis) {
|
|
705
|
+
return Response.json(
|
|
706
|
+
{ error: "Analysis not found" },
|
|
707
|
+
{ status: 404 },
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
return Response.json(analysis);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// List all analyses
|
|
714
|
+
const limit = Number.parseInt(
|
|
715
|
+
url.searchParams.get("limit") || "50",
|
|
716
|
+
);
|
|
717
|
+
const offset = Number.parseInt(
|
|
718
|
+
url.searchParams.get("offset") || "0",
|
|
719
|
+
);
|
|
720
|
+
|
|
721
|
+
const analyses = analysisDb.listAnalyses(limit, offset);
|
|
722
|
+
return Response.json({ analyses });
|
|
723
|
+
} catch (error) {
|
|
724
|
+
console.error("Error retrieving analyses:", error);
|
|
725
|
+
return Response.json(
|
|
726
|
+
{
|
|
727
|
+
error:
|
|
728
|
+
error instanceof Error
|
|
729
|
+
? error.message
|
|
730
|
+
: "Failed to retrieve analyses",
|
|
731
|
+
},
|
|
732
|
+
{ status: 500 },
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
},
|
|
736
|
+
},
|
|
737
|
+
|
|
738
|
+
"/api/session-analyses/:sessionId/similar": {
|
|
739
|
+
async GET(req) {
|
|
740
|
+
try {
|
|
741
|
+
const sessionId = req.params.sessionId;
|
|
742
|
+
const url = new URL(req.url);
|
|
743
|
+
const limit = Number.parseInt(
|
|
744
|
+
url.searchParams.get("limit") || "10",
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
// Get embedding for this session
|
|
748
|
+
const embedding = await analysisDb.getEmbedding(sessionId);
|
|
749
|
+
if (!embedding) {
|
|
750
|
+
return Response.json(
|
|
751
|
+
{ error: "No embedding found for this session" },
|
|
752
|
+
{ status: 404 },
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Search for similar sessions
|
|
757
|
+
const similar = (
|
|
758
|
+
await analysisDb.searchSimilarSessions(embedding, limit + 1)
|
|
759
|
+
)
|
|
760
|
+
.filter((s) => s.session_id !== sessionId)
|
|
761
|
+
.slice(0, limit);
|
|
762
|
+
|
|
763
|
+
return Response.json({ similar });
|
|
764
|
+
} catch (error) {
|
|
765
|
+
console.error("Error finding similar sessions:", error);
|
|
766
|
+
return Response.json(
|
|
767
|
+
{
|
|
768
|
+
error:
|
|
769
|
+
error instanceof Error
|
|
770
|
+
? error.message
|
|
771
|
+
: "Failed to find similar sessions",
|
|
772
|
+
},
|
|
773
|
+
{ status: 500 },
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
},
|
|
777
|
+
},
|
|
778
|
+
|
|
452
779
|
// Serve index.html for all unmatched routes (SPA routing)
|
|
453
780
|
"/*": index,
|
|
454
781
|
},
|
|
@@ -464,5 +791,12 @@ export function startDebuggerServer(
|
|
|
464
791
|
otlpServer.stop();
|
|
465
792
|
};
|
|
466
793
|
|
|
794
|
+
console.log(`🔍 Debugger UI: http://${server.hostname}:${server.port}`);
|
|
795
|
+
console.log(
|
|
796
|
+
`📊 OTLP endpoint: http://${otlpServer.hostname}:${otlpServer.port}`,
|
|
797
|
+
);
|
|
798
|
+
console.log(`📁 Database: ${dbPath}`);
|
|
799
|
+
console.log(`🤖 Agent server: ${agentServerUrl}`);
|
|
800
|
+
|
|
467
801
|
return { server, otlpServer, stop };
|
|
468
802
|
}
|
package/src/types.ts
CHANGED
|
@@ -85,7 +85,7 @@ export type ComparisonDimension = "model" | "system_prompt" | "tools";
|
|
|
85
85
|
|
|
86
86
|
export interface ComparisonConfig {
|
|
87
87
|
id: string;
|
|
88
|
-
|
|
88
|
+
dimensions: ComparisonDimension[]; // Now supports multiple dimensions
|
|
89
89
|
controlModel?: string | undefined; // Original model for comparison
|
|
90
90
|
variantModel?: string | undefined;
|
|
91
91
|
variantSystemPrompt?: string | undefined;
|
|
@@ -96,7 +96,7 @@ export interface ComparisonConfig {
|
|
|
96
96
|
|
|
97
97
|
export interface ComparisonConfigRow {
|
|
98
98
|
id: string;
|
|
99
|
-
|
|
99
|
+
dimensions: string; // JSON array of dimensions
|
|
100
100
|
control_model: string | null;
|
|
101
101
|
variant_model: string | null;
|
|
102
102
|
variant_system_prompt: string | null;
|
|
@@ -112,6 +112,15 @@ export interface SessionMetrics {
|
|
|
112
112
|
totalTokens: number;
|
|
113
113
|
estimatedCost: number;
|
|
114
114
|
toolCallCount: number;
|
|
115
|
+
toolCalls?: ToolCall[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface ToolCall {
|
|
119
|
+
name: string;
|
|
120
|
+
input: unknown;
|
|
121
|
+
output: unknown;
|
|
122
|
+
startTimeUnixNano?: number | undefined;
|
|
123
|
+
endTimeUnixNano?: number | undefined;
|
|
115
124
|
}
|
|
116
125
|
|
|
117
126
|
export interface ComparisonRun {
|