swarm-mail 0.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/README.md +201 -0
- package/package.json +28 -0
- package/src/adapter.ts +306 -0
- package/src/index.ts +57 -0
- package/src/pglite.ts +189 -0
- package/src/streams/agent-mail.test.ts +777 -0
- package/src/streams/agent-mail.ts +535 -0
- package/src/streams/debug.test.ts +500 -0
- package/src/streams/debug.ts +727 -0
- package/src/streams/effect/ask.integration.test.ts +314 -0
- package/src/streams/effect/ask.ts +202 -0
- package/src/streams/effect/cursor.integration.test.ts +418 -0
- package/src/streams/effect/cursor.ts +288 -0
- package/src/streams/effect/deferred.test.ts +357 -0
- package/src/streams/effect/deferred.ts +445 -0
- package/src/streams/effect/index.ts +17 -0
- package/src/streams/effect/layers.ts +73 -0
- package/src/streams/effect/lock.test.ts +385 -0
- package/src/streams/effect/lock.ts +399 -0
- package/src/streams/effect/mailbox.test.ts +260 -0
- package/src/streams/effect/mailbox.ts +318 -0
- package/src/streams/events.test.ts +924 -0
- package/src/streams/events.ts +329 -0
- package/src/streams/index.test.ts +229 -0
- package/src/streams/index.ts +578 -0
- package/src/streams/migrations.test.ts +359 -0
- package/src/streams/migrations.ts +362 -0
- package/src/streams/projections.test.ts +611 -0
- package/src/streams/projections.ts +564 -0
- package/src/streams/store.integration.test.ts +658 -0
- package/src/streams/store.ts +1129 -0
- package/src/streams/swarm-mail.ts +552 -0
- package/src/types/adapter.ts +392 -0
- package/src/types/database.ts +127 -0
- package/src/types/index.ts +26 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Swarm Mail Projections Layer - Query materialized views
|
|
3
|
+
*
|
|
4
|
+
* Projections are the read-side of CQRS. They query denormalized
|
|
5
|
+
* materialized views for fast reads. Views are updated by the
|
|
6
|
+
* event store when events are appended.
|
|
7
|
+
*
|
|
8
|
+
* Key projections:
|
|
9
|
+
* - getAgents: List registered agents
|
|
10
|
+
* - getInbox: Get messages for an agent
|
|
11
|
+
* - getActiveReservations: Get current file locks
|
|
12
|
+
* - checkConflicts: Detect reservation conflicts
|
|
13
|
+
*/
|
|
14
|
+
import { getDatabase } from "./index";
|
|
15
|
+
import type { DatabaseAdapter } from "../types/database";
|
|
16
|
+
import { minimatch } from "minimatch";
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// Types
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
export interface Agent {
|
|
23
|
+
id: number;
|
|
24
|
+
name: string;
|
|
25
|
+
program: string;
|
|
26
|
+
model: string;
|
|
27
|
+
task_description: string | null;
|
|
28
|
+
registered_at: number;
|
|
29
|
+
last_active_at: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Message {
|
|
33
|
+
id: number;
|
|
34
|
+
from_agent: string;
|
|
35
|
+
subject: string;
|
|
36
|
+
body?: string;
|
|
37
|
+
thread_id: string | null;
|
|
38
|
+
importance: string;
|
|
39
|
+
ack_required: boolean;
|
|
40
|
+
created_at: number;
|
|
41
|
+
read_at?: number | null;
|
|
42
|
+
acked_at?: number | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Reservation {
|
|
46
|
+
id: number;
|
|
47
|
+
agent_name: string;
|
|
48
|
+
path_pattern: string;
|
|
49
|
+
exclusive: boolean;
|
|
50
|
+
reason: string | null;
|
|
51
|
+
created_at: number;
|
|
52
|
+
expires_at: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface Conflict {
|
|
56
|
+
path: string;
|
|
57
|
+
holder: string;
|
|
58
|
+
pattern: string;
|
|
59
|
+
exclusive: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ============================================================================
|
|
63
|
+
// Agent Projections
|
|
64
|
+
// ============================================================================
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get all agents for a project
|
|
68
|
+
*
|
|
69
|
+
* @param projectKey - Project identifier
|
|
70
|
+
* @param projectPath - Optional project path for database location
|
|
71
|
+
* @param dbOverride - Optional database adapter for dependency injection
|
|
72
|
+
*/
|
|
73
|
+
export async function getAgents(
|
|
74
|
+
projectKey: string,
|
|
75
|
+
projectPath?: string,
|
|
76
|
+
dbOverride?: DatabaseAdapter,
|
|
77
|
+
): Promise<Agent[]> {
|
|
78
|
+
const db = dbOverride ?? (await getDatabase(projectPath) as unknown as DatabaseAdapter);
|
|
79
|
+
|
|
80
|
+
const result = await db.query<Agent>(
|
|
81
|
+
`SELECT id, name, program, model, task_description, registered_at, last_active_at
|
|
82
|
+
FROM agents
|
|
83
|
+
WHERE project_key = $1
|
|
84
|
+
ORDER BY registered_at ASC`,
|
|
85
|
+
[projectKey],
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
return result.rows;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get a specific agent by name
|
|
93
|
+
*
|
|
94
|
+
* @param projectKey - Project identifier
|
|
95
|
+
* @param agentName - Agent name to lookup
|
|
96
|
+
* @param projectPath - Optional project path for database location
|
|
97
|
+
* @param dbOverride - Optional database adapter for dependency injection
|
|
98
|
+
*/
|
|
99
|
+
export async function getAgent(
|
|
100
|
+
projectKey: string,
|
|
101
|
+
agentName: string,
|
|
102
|
+
projectPath?: string,
|
|
103
|
+
dbOverride?: DatabaseAdapter,
|
|
104
|
+
): Promise<Agent | null> {
|
|
105
|
+
const db = dbOverride ?? (await getDatabase(projectPath) as unknown as DatabaseAdapter);
|
|
106
|
+
|
|
107
|
+
const result = await db.query<Agent>(
|
|
108
|
+
`SELECT id, name, program, model, task_description, registered_at, last_active_at
|
|
109
|
+
FROM agents
|
|
110
|
+
WHERE project_key = $1 AND name = $2`,
|
|
111
|
+
[projectKey, agentName],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
return result.rows[0] ?? null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ============================================================================
|
|
118
|
+
// Message Projections
|
|
119
|
+
// ============================================================================
|
|
120
|
+
|
|
121
|
+
export interface InboxOptions {
|
|
122
|
+
limit?: number;
|
|
123
|
+
urgentOnly?: boolean;
|
|
124
|
+
unreadOnly?: boolean;
|
|
125
|
+
includeBodies?: boolean;
|
|
126
|
+
sinceTs?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get inbox messages for an agent
|
|
131
|
+
*
|
|
132
|
+
* @param projectKey - Project identifier
|
|
133
|
+
* @param agentName - Agent name to get inbox for
|
|
134
|
+
* @param options - Inbox query options
|
|
135
|
+
* @param projectPath - Optional project path for database location
|
|
136
|
+
* @param dbOverride - Optional database adapter for dependency injection
|
|
137
|
+
*/
|
|
138
|
+
export async function getInbox(
|
|
139
|
+
projectKey: string,
|
|
140
|
+
agentName: string,
|
|
141
|
+
options: InboxOptions = {},
|
|
142
|
+
projectPath?: string,
|
|
143
|
+
dbOverride?: DatabaseAdapter,
|
|
144
|
+
): Promise<Message[]> {
|
|
145
|
+
const db = dbOverride ?? (await getDatabase(projectPath) as unknown as DatabaseAdapter);
|
|
146
|
+
|
|
147
|
+
const {
|
|
148
|
+
limit = 50,
|
|
149
|
+
urgentOnly = false,
|
|
150
|
+
unreadOnly = false,
|
|
151
|
+
includeBodies = true,
|
|
152
|
+
} = options;
|
|
153
|
+
|
|
154
|
+
// Build query with conditions
|
|
155
|
+
const conditions = ["m.project_key = $1", "mr.agent_name = $2"];
|
|
156
|
+
const params: (string | number)[] = [projectKey, agentName];
|
|
157
|
+
let paramIndex = 3;
|
|
158
|
+
|
|
159
|
+
if (urgentOnly) {
|
|
160
|
+
conditions.push(`m.importance = 'urgent'`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (unreadOnly) {
|
|
164
|
+
conditions.push(`mr.read_at IS NULL`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const bodySelect = includeBodies ? ", m.body" : "";
|
|
168
|
+
|
|
169
|
+
const query = `
|
|
170
|
+
SELECT m.id, m.from_agent, m.subject${bodySelect}, m.thread_id,
|
|
171
|
+
m.importance, m.ack_required, m.created_at,
|
|
172
|
+
mr.read_at, mr.acked_at
|
|
173
|
+
FROM messages m
|
|
174
|
+
JOIN message_recipients mr ON m.id = mr.message_id
|
|
175
|
+
WHERE ${conditions.join(" AND ")}
|
|
176
|
+
ORDER BY m.created_at DESC
|
|
177
|
+
LIMIT $${paramIndex}
|
|
178
|
+
`;
|
|
179
|
+
params.push(limit);
|
|
180
|
+
|
|
181
|
+
const result = await db.query<Message>(query, params);
|
|
182
|
+
|
|
183
|
+
return result.rows;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Get a single message by ID with full body
|
|
188
|
+
*
|
|
189
|
+
* @param projectKey - Project identifier
|
|
190
|
+
* @param messageId - Message ID to lookup
|
|
191
|
+
* @param projectPath - Optional project path for database location
|
|
192
|
+
* @param dbOverride - Optional database adapter for dependency injection
|
|
193
|
+
*/
|
|
194
|
+
export async function getMessage(
|
|
195
|
+
projectKey: string,
|
|
196
|
+
messageId: number,
|
|
197
|
+
projectPath?: string,
|
|
198
|
+
dbOverride?: DatabaseAdapter,
|
|
199
|
+
): Promise<Message | null> {
|
|
200
|
+
const db = dbOverride ?? (await getDatabase(projectPath) as unknown as DatabaseAdapter);
|
|
201
|
+
|
|
202
|
+
const result = await db.query<Message>(
|
|
203
|
+
`SELECT id, from_agent, subject, body, thread_id, importance, ack_required, created_at
|
|
204
|
+
FROM messages
|
|
205
|
+
WHERE project_key = $1 AND id = $2`,
|
|
206
|
+
[projectKey, messageId],
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
return result.rows[0] ?? null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Get all messages in a thread
|
|
214
|
+
*
|
|
215
|
+
* @param projectKey - Project identifier
|
|
216
|
+
* @param threadId - Thread ID to lookup
|
|
217
|
+
* @param projectPath - Optional project path for database location
|
|
218
|
+
* @param dbOverride - Optional database adapter for dependency injection
|
|
219
|
+
*/
|
|
220
|
+
export async function getThreadMessages(
|
|
221
|
+
projectKey: string,
|
|
222
|
+
threadId: string,
|
|
223
|
+
projectPath?: string,
|
|
224
|
+
dbOverride?: DatabaseAdapter,
|
|
225
|
+
): Promise<Message[]> {
|
|
226
|
+
const db = dbOverride ?? (await getDatabase(projectPath) as unknown as DatabaseAdapter);
|
|
227
|
+
|
|
228
|
+
const result = await db.query<Message>(
|
|
229
|
+
`SELECT id, from_agent, subject, body, thread_id, importance, ack_required, created_at
|
|
230
|
+
FROM messages
|
|
231
|
+
WHERE project_key = $1 AND thread_id = $2
|
|
232
|
+
ORDER BY created_at ASC`,
|
|
233
|
+
[projectKey, threadId],
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
return result.rows;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ============================================================================
|
|
240
|
+
// Reservation Projections
|
|
241
|
+
// ============================================================================
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Get active (non-expired, non-released) reservations
|
|
245
|
+
*
|
|
246
|
+
* @param projectKey - Project identifier
|
|
247
|
+
* @param projectPath - Optional project path for database location
|
|
248
|
+
* @param agentName - Optional agent name to filter by
|
|
249
|
+
* @param dbOverride - Optional database adapter for dependency injection
|
|
250
|
+
*/
|
|
251
|
+
export async function getActiveReservations(
|
|
252
|
+
projectKey: string,
|
|
253
|
+
projectPath?: string,
|
|
254
|
+
agentName?: string,
|
|
255
|
+
dbOverride?: DatabaseAdapter,
|
|
256
|
+
): Promise<Reservation[]> {
|
|
257
|
+
const db = dbOverride ?? (await getDatabase(projectPath) as unknown as DatabaseAdapter);
|
|
258
|
+
|
|
259
|
+
const now = Date.now();
|
|
260
|
+
const baseQuery = `
|
|
261
|
+
SELECT id, agent_name, path_pattern, exclusive, reason, created_at, expires_at
|
|
262
|
+
FROM reservations
|
|
263
|
+
WHERE project_key = $1
|
|
264
|
+
AND released_at IS NULL
|
|
265
|
+
AND expires_at > $2
|
|
266
|
+
`;
|
|
267
|
+
const params: (string | number)[] = [projectKey, now];
|
|
268
|
+
let query = baseQuery;
|
|
269
|
+
|
|
270
|
+
if (agentName) {
|
|
271
|
+
query += ` AND agent_name = $3`;
|
|
272
|
+
params.push(agentName);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
query += ` ORDER BY created_at ASC`;
|
|
276
|
+
|
|
277
|
+
const result = await db.query<Reservation>(query, params);
|
|
278
|
+
|
|
279
|
+
return result.rows;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Check for conflicts with existing reservations
|
|
284
|
+
*
|
|
285
|
+
* Returns conflicts where:
|
|
286
|
+
* - Another agent holds an exclusive reservation
|
|
287
|
+
* - The path matches (exact or glob pattern)
|
|
288
|
+
* - The reservation is still active
|
|
289
|
+
*
|
|
290
|
+
* @param projectKey - Project identifier
|
|
291
|
+
* @param agentName - Agent attempting reservation
|
|
292
|
+
* @param paths - Paths to check for conflicts
|
|
293
|
+
* @param projectPath - Optional project path for database location
|
|
294
|
+
* @param dbOverride - Optional database adapter for dependency injection
|
|
295
|
+
*/
|
|
296
|
+
export async function checkConflicts(
|
|
297
|
+
projectKey: string,
|
|
298
|
+
agentName: string,
|
|
299
|
+
paths: string[],
|
|
300
|
+
projectPath?: string,
|
|
301
|
+
dbOverride?: DatabaseAdapter,
|
|
302
|
+
): Promise<Conflict[]> {
|
|
303
|
+
// Get all active exclusive reservations from OTHER agents
|
|
304
|
+
const reservations = await getActiveReservations(
|
|
305
|
+
projectKey,
|
|
306
|
+
projectPath,
|
|
307
|
+
undefined,
|
|
308
|
+
dbOverride,
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const conflicts: Conflict[] = [];
|
|
312
|
+
|
|
313
|
+
for (const reservation of reservations) {
|
|
314
|
+
// Skip own reservations
|
|
315
|
+
if (reservation.agent_name === agentName) {
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Skip non-exclusive reservations
|
|
320
|
+
if (!reservation.exclusive) {
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Check each requested path against the reservation pattern
|
|
325
|
+
for (const path of paths) {
|
|
326
|
+
if (pathMatches(path, reservation.path_pattern)) {
|
|
327
|
+
console.warn("[SwarmMail] Conflict detected", {
|
|
328
|
+
path,
|
|
329
|
+
holder: reservation.agent_name,
|
|
330
|
+
pattern: reservation.path_pattern,
|
|
331
|
+
requestedBy: agentName,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
conflicts.push({
|
|
335
|
+
path,
|
|
336
|
+
holder: reservation.agent_name,
|
|
337
|
+
pattern: reservation.path_pattern,
|
|
338
|
+
exclusive: reservation.exclusive,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (conflicts.length > 0) {
|
|
345
|
+
console.warn("[SwarmMail] Total conflicts detected", {
|
|
346
|
+
count: conflicts.length,
|
|
347
|
+
requestedBy: agentName,
|
|
348
|
+
paths,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return conflicts;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Check if a path matches a pattern (supports glob patterns)
|
|
357
|
+
*/
|
|
358
|
+
function pathMatches(path: string, pattern: string): boolean {
|
|
359
|
+
// Exact match
|
|
360
|
+
if (path === pattern) {
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Glob match using minimatch
|
|
365
|
+
return minimatch(path, pattern);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ============================================================================
|
|
369
|
+
// Eval Records Projections
|
|
370
|
+
// ============================================================================
|
|
371
|
+
|
|
372
|
+
export interface EvalRecord {
|
|
373
|
+
id: string;
|
|
374
|
+
project_key: string;
|
|
375
|
+
task: string;
|
|
376
|
+
context: string | null;
|
|
377
|
+
strategy: string;
|
|
378
|
+
epic_title: string;
|
|
379
|
+
subtasks: Array<{
|
|
380
|
+
title: string;
|
|
381
|
+
files: string[];
|
|
382
|
+
priority?: number;
|
|
383
|
+
}>;
|
|
384
|
+
outcomes?: Array<{
|
|
385
|
+
bead_id: string;
|
|
386
|
+
planned_files: string[];
|
|
387
|
+
actual_files: string[];
|
|
388
|
+
duration_ms: number;
|
|
389
|
+
error_count: number;
|
|
390
|
+
retry_count: number;
|
|
391
|
+
success: boolean;
|
|
392
|
+
}>;
|
|
393
|
+
overall_success: boolean | null;
|
|
394
|
+
total_duration_ms: number | null;
|
|
395
|
+
total_errors: number | null;
|
|
396
|
+
human_accepted: boolean | null;
|
|
397
|
+
human_modified: boolean | null;
|
|
398
|
+
human_notes: string | null;
|
|
399
|
+
file_overlap_count: number | null;
|
|
400
|
+
scope_accuracy: number | null;
|
|
401
|
+
time_balance_ratio: number | null;
|
|
402
|
+
created_at: number;
|
|
403
|
+
updated_at: number;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export interface EvalStats {
|
|
407
|
+
totalRecords: number;
|
|
408
|
+
successRate: number;
|
|
409
|
+
avgDurationMs: number;
|
|
410
|
+
byStrategy: Record<string, number>;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Get eval records with optional filters
|
|
415
|
+
*
|
|
416
|
+
* @param projectKey - Project identifier
|
|
417
|
+
* @param options - Query options
|
|
418
|
+
* @param projectPath - Optional project path for database location
|
|
419
|
+
* @param dbOverride - Optional database adapter for dependency injection
|
|
420
|
+
*/
|
|
421
|
+
export async function getEvalRecords(
|
|
422
|
+
projectKey: string,
|
|
423
|
+
options?: { limit?: number; strategy?: string },
|
|
424
|
+
projectPath?: string,
|
|
425
|
+
dbOverride?: DatabaseAdapter,
|
|
426
|
+
): Promise<EvalRecord[]> {
|
|
427
|
+
const db = dbOverride ?? (await getDatabase(projectPath) as unknown as DatabaseAdapter);
|
|
428
|
+
|
|
429
|
+
const conditions = ["project_key = $1"];
|
|
430
|
+
const params: (string | number)[] = [projectKey];
|
|
431
|
+
let paramIndex = 2;
|
|
432
|
+
|
|
433
|
+
if (options?.strategy) {
|
|
434
|
+
conditions.push(`strategy = $${paramIndex++}`);
|
|
435
|
+
params.push(options.strategy);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const whereClause = conditions.join(" AND ");
|
|
439
|
+
let query = `
|
|
440
|
+
SELECT id, project_key, task, context, strategy, epic_title, subtasks,
|
|
441
|
+
outcomes, overall_success, total_duration_ms, total_errors,
|
|
442
|
+
human_accepted, human_modified, human_notes,
|
|
443
|
+
file_overlap_count, scope_accuracy, time_balance_ratio,
|
|
444
|
+
created_at, updated_at
|
|
445
|
+
FROM eval_records
|
|
446
|
+
WHERE ${whereClause}
|
|
447
|
+
ORDER BY created_at DESC
|
|
448
|
+
`;
|
|
449
|
+
|
|
450
|
+
if (options?.limit) {
|
|
451
|
+
query += ` LIMIT $${paramIndex}`;
|
|
452
|
+
params.push(options.limit);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const result = await db.query<{
|
|
456
|
+
id: string;
|
|
457
|
+
project_key: string;
|
|
458
|
+
task: string;
|
|
459
|
+
context: string | null;
|
|
460
|
+
strategy: string;
|
|
461
|
+
epic_title: string;
|
|
462
|
+
subtasks: string;
|
|
463
|
+
outcomes: string | null;
|
|
464
|
+
overall_success: boolean | null;
|
|
465
|
+
total_duration_ms: number | null;
|
|
466
|
+
total_errors: number | null;
|
|
467
|
+
human_accepted: boolean | null;
|
|
468
|
+
human_modified: boolean | null;
|
|
469
|
+
human_notes: string | null;
|
|
470
|
+
file_overlap_count: number | null;
|
|
471
|
+
scope_accuracy: number | null;
|
|
472
|
+
time_balance_ratio: number | null;
|
|
473
|
+
created_at: string;
|
|
474
|
+
updated_at: string;
|
|
475
|
+
}>(query, params);
|
|
476
|
+
|
|
477
|
+
return result.rows.map((row) => ({
|
|
478
|
+
id: row.id,
|
|
479
|
+
project_key: row.project_key,
|
|
480
|
+
task: row.task,
|
|
481
|
+
context: row.context,
|
|
482
|
+
strategy: row.strategy,
|
|
483
|
+
epic_title: row.epic_title,
|
|
484
|
+
// PGlite returns JSONB columns as already-parsed objects
|
|
485
|
+
subtasks:
|
|
486
|
+
typeof row.subtasks === "string"
|
|
487
|
+
? JSON.parse(row.subtasks)
|
|
488
|
+
: row.subtasks,
|
|
489
|
+
outcomes: row.outcomes
|
|
490
|
+
? typeof row.outcomes === "string"
|
|
491
|
+
? JSON.parse(row.outcomes)
|
|
492
|
+
: row.outcomes
|
|
493
|
+
: undefined,
|
|
494
|
+
overall_success: row.overall_success,
|
|
495
|
+
total_duration_ms: row.total_duration_ms,
|
|
496
|
+
total_errors: row.total_errors,
|
|
497
|
+
human_accepted: row.human_accepted,
|
|
498
|
+
human_modified: row.human_modified,
|
|
499
|
+
human_notes: row.human_notes,
|
|
500
|
+
file_overlap_count: row.file_overlap_count,
|
|
501
|
+
scope_accuracy: row.scope_accuracy,
|
|
502
|
+
time_balance_ratio: row.time_balance_ratio,
|
|
503
|
+
created_at: parseInt(row.created_at as string),
|
|
504
|
+
updated_at: parseInt(row.updated_at as string),
|
|
505
|
+
}));
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Get eval statistics for a project
|
|
510
|
+
*
|
|
511
|
+
* @param projectKey - Project identifier
|
|
512
|
+
* @param projectPath - Optional project path for database location
|
|
513
|
+
* @param dbOverride - Optional database adapter for dependency injection
|
|
514
|
+
*/
|
|
515
|
+
export async function getEvalStats(
|
|
516
|
+
projectKey: string,
|
|
517
|
+
projectPath?: string,
|
|
518
|
+
dbOverride?: DatabaseAdapter,
|
|
519
|
+
): Promise<EvalStats> {
|
|
520
|
+
const db = dbOverride ?? (await getDatabase(projectPath) as unknown as DatabaseAdapter);
|
|
521
|
+
|
|
522
|
+
// Get overall stats
|
|
523
|
+
const overallResult = await db.query<{
|
|
524
|
+
total_records: string;
|
|
525
|
+
success_count: string;
|
|
526
|
+
avg_duration: string;
|
|
527
|
+
}>(
|
|
528
|
+
`SELECT
|
|
529
|
+
COUNT(*) as total_records,
|
|
530
|
+
COUNT(*) FILTER (WHERE overall_success = true) as success_count,
|
|
531
|
+
AVG(total_duration_ms) as avg_duration
|
|
532
|
+
FROM eval_records
|
|
533
|
+
WHERE project_key = $1`,
|
|
534
|
+
[projectKey],
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
const totalRecords = parseInt(overallResult.rows[0]?.total_records || "0");
|
|
538
|
+
const successCount = parseInt(overallResult.rows[0]?.success_count || "0");
|
|
539
|
+
const avgDurationMs = parseFloat(overallResult.rows[0]?.avg_duration || "0");
|
|
540
|
+
|
|
541
|
+
// Get by-strategy breakdown
|
|
542
|
+
const strategyResult = await db.query<{
|
|
543
|
+
strategy: string;
|
|
544
|
+
count: string;
|
|
545
|
+
}>(
|
|
546
|
+
`SELECT strategy, COUNT(*) as count
|
|
547
|
+
FROM eval_records
|
|
548
|
+
WHERE project_key = $1
|
|
549
|
+
GROUP BY strategy`,
|
|
550
|
+
[projectKey],
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
const byStrategy: Record<string, number> = {};
|
|
554
|
+
for (const row of strategyResult.rows) {
|
|
555
|
+
byStrategy[row.strategy] = parseInt(row.count);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return {
|
|
559
|
+
totalRecords,
|
|
560
|
+
successRate: totalRecords > 0 ? successCount / totalRecords : 0,
|
|
561
|
+
avgDurationMs,
|
|
562
|
+
byStrategy,
|
|
563
|
+
};
|
|
564
|
+
}
|