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.
Files changed (36) hide show
  1. package/README.md +201 -0
  2. package/package.json +28 -0
  3. package/src/adapter.ts +306 -0
  4. package/src/index.ts +57 -0
  5. package/src/pglite.ts +189 -0
  6. package/src/streams/agent-mail.test.ts +777 -0
  7. package/src/streams/agent-mail.ts +535 -0
  8. package/src/streams/debug.test.ts +500 -0
  9. package/src/streams/debug.ts +727 -0
  10. package/src/streams/effect/ask.integration.test.ts +314 -0
  11. package/src/streams/effect/ask.ts +202 -0
  12. package/src/streams/effect/cursor.integration.test.ts +418 -0
  13. package/src/streams/effect/cursor.ts +288 -0
  14. package/src/streams/effect/deferred.test.ts +357 -0
  15. package/src/streams/effect/deferred.ts +445 -0
  16. package/src/streams/effect/index.ts +17 -0
  17. package/src/streams/effect/layers.ts +73 -0
  18. package/src/streams/effect/lock.test.ts +385 -0
  19. package/src/streams/effect/lock.ts +399 -0
  20. package/src/streams/effect/mailbox.test.ts +260 -0
  21. package/src/streams/effect/mailbox.ts +318 -0
  22. package/src/streams/events.test.ts +924 -0
  23. package/src/streams/events.ts +329 -0
  24. package/src/streams/index.test.ts +229 -0
  25. package/src/streams/index.ts +578 -0
  26. package/src/streams/migrations.test.ts +359 -0
  27. package/src/streams/migrations.ts +362 -0
  28. package/src/streams/projections.test.ts +611 -0
  29. package/src/streams/projections.ts +564 -0
  30. package/src/streams/store.integration.test.ts +658 -0
  31. package/src/streams/store.ts +1129 -0
  32. package/src/streams/swarm-mail.ts +552 -0
  33. package/src/types/adapter.ts +392 -0
  34. package/src/types/database.ts +127 -0
  35. package/src/types/index.ts +26 -0
  36. package/tsconfig.json +22 -0
@@ -0,0 +1,1129 @@
1
+ /**
2
+ * Event Store - Append-only event log with PGLite
3
+ *
4
+ * Core operations:
5
+ * - append(): Add events to the log
6
+ * - read(): Read events with filters
7
+ * - replay(): Rebuild state from events
8
+ * - replayBatched(): Rebuild state with pagination (for large logs)
9
+ *
10
+ * All state changes go through events. Projections compute current state.
11
+ */
12
+ import { getDatabase, withTiming } from "./index";
13
+ import type { DatabaseAdapter } from "../types/database";
14
+ import {
15
+ type AgentEvent,
16
+ createEvent,
17
+ type AgentRegisteredEvent,
18
+ type MessageSentEvent,
19
+ type FileReservedEvent,
20
+ } from "./events";
21
+
22
+ // ============================================================================
23
+ // Timestamp Parsing
24
+ // ============================================================================
25
+
26
+ /**
27
+ * Maximum safe timestamp before integer overflow (approximately year 2286)
28
+ * PostgreSQL BIGINT can exceed JavaScript's MAX_SAFE_INTEGER (2^53-1)
29
+ */
30
+ const TIMESTAMP_SAFE_UNTIL = new Date("2286-01-01").getTime();
31
+
32
+ /**
33
+ * Parse timestamp from database row.
34
+ *
35
+ * NOTE: Timestamps are stored as BIGINT but parsed as JavaScript number.
36
+ * This is safe for dates before year 2286 (MAX_SAFE_INTEGER = 9007199254740991).
37
+ * For timestamps beyond this range, use BigInt parsing.
38
+ *
39
+ * @param timestamp String representation of Unix timestamp in milliseconds
40
+ * @returns JavaScript number (safe for dates before 2286)
41
+ * @throws Error if timestamp is not a valid number
42
+ */
43
+ function parseTimestamp(timestamp: string): number {
44
+ const ts = parseInt(timestamp, 10);
45
+ if (Number.isNaN(ts)) {
46
+ throw new Error(`[SwarmMail] Invalid timestamp: ${timestamp}`);
47
+ }
48
+ if (ts > Number.MAX_SAFE_INTEGER) {
49
+ console.warn(
50
+ `[SwarmMail] Timestamp ${timestamp} exceeds MAX_SAFE_INTEGER (year 2286+), precision may be lost`,
51
+ );
52
+ }
53
+ return ts;
54
+ }
55
+
56
+ // ============================================================================
57
+ // Event Store Operations
58
+ // ============================================================================
59
+
60
+ /**
61
+ * Append an event to the log
62
+ *
63
+ * Also updates materialized views (agents, messages, reservations)
64
+ *
65
+ * @param event - Event to append
66
+ * @param projectPath - Optional project path for database location
67
+ * @param dbOverride - Optional database adapter for dependency injection
68
+ */
69
+ export async function appendEvent(
70
+ event: AgentEvent,
71
+ projectPath?: string,
72
+ dbOverride?: DatabaseAdapter,
73
+ ): Promise<AgentEvent & { id: number; sequence: number }> {
74
+ const db = dbOverride ?? (await getDatabase(projectPath) as unknown as DatabaseAdapter);
75
+
76
+ // Extract common fields
77
+ const { type, project_key, timestamp, ...rest } = event;
78
+
79
+ console.log("[SwarmMail] Appending event", {
80
+ type,
81
+ projectKey: project_key,
82
+ timestamp,
83
+ });
84
+
85
+ // Insert event
86
+ const result = await db.query<{ id: number; sequence: number }>(
87
+ `INSERT INTO events (type, project_key, timestamp, data)
88
+ VALUES ($1, $2, $3, $4)
89
+ RETURNING id, sequence`,
90
+ [type, project_key, timestamp, JSON.stringify(rest)],
91
+ );
92
+
93
+ const row = result.rows[0];
94
+ if (!row) {
95
+ throw new Error("Failed to insert event - no row returned");
96
+ }
97
+ const { id, sequence } = row;
98
+
99
+ console.log("[SwarmMail] Event appended", {
100
+ type,
101
+ id,
102
+ sequence,
103
+ projectKey: project_key,
104
+ });
105
+
106
+ // Update materialized views based on event type
107
+ console.debug("[SwarmMail] Updating materialized views", { type, id });
108
+ await updateMaterializedViews(db, { ...event, id, sequence });
109
+
110
+ return { ...event, id, sequence };
111
+ }
112
+
113
+ /**
114
+ * Append multiple events in a transaction
115
+ *
116
+ * @param events - Events to append
117
+ * @param projectPath - Optional project path for database location
118
+ * @param dbOverride - Optional database adapter for dependency injection
119
+ */
120
+ export async function appendEvents(
121
+ events: AgentEvent[],
122
+ projectPath?: string,
123
+ dbOverride?: DatabaseAdapter,
124
+ ): Promise<Array<AgentEvent & { id: number; sequence: number }>> {
125
+ return withTiming("appendEvents", async () => {
126
+ const db = dbOverride ?? (await getDatabase(projectPath) as unknown as DatabaseAdapter);
127
+ const results: Array<AgentEvent & { id: number; sequence: number }> = [];
128
+
129
+ await db.exec("BEGIN");
130
+ try {
131
+ for (const event of events) {
132
+ const { type, project_key, timestamp, ...rest } = event;
133
+
134
+ const result = await db.query<{ id: number; sequence: number }>(
135
+ `INSERT INTO events (type, project_key, timestamp, data)
136
+ VALUES ($1, $2, $3, $4)
137
+ RETURNING id, sequence`,
138
+ [type, project_key, timestamp, JSON.stringify(rest)],
139
+ );
140
+
141
+ const row = result.rows[0];
142
+ if (!row) {
143
+ throw new Error("Failed to insert event - no row returned");
144
+ }
145
+ const { id, sequence } = row;
146
+ const enrichedEvent = { ...event, id, sequence };
147
+
148
+ await updateMaterializedViews(db, enrichedEvent);
149
+ results.push(enrichedEvent);
150
+ }
151
+ await db.exec("COMMIT");
152
+ } catch (e) {
153
+ // FIX: Propagate rollback failures to prevent silent data corruption
154
+ let rollbackError: unknown = null;
155
+ try {
156
+ await db.exec("ROLLBACK");
157
+ } catch (rbErr) {
158
+ rollbackError = rbErr;
159
+ console.error("[SwarmMail] ROLLBACK failed:", rbErr);
160
+ }
161
+
162
+ if (rollbackError) {
163
+ // Throw composite error so caller knows both failures
164
+ const compositeError = new Error(
165
+ `Transaction failed: ${e instanceof Error ? e.message : String(e)}. ` +
166
+ `ROLLBACK also failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}. ` +
167
+ `Database may be in inconsistent state.`,
168
+ );
169
+ (compositeError as any).originalError = e;
170
+ (compositeError as any).rollbackError = rollbackError;
171
+ throw compositeError;
172
+ }
173
+ throw e;
174
+ }
175
+
176
+ return results;
177
+ });
178
+ }
179
+
180
+ /**
181
+ * Read events with optional filters
182
+ *
183
+ * @param options - Filter options
184
+ * @param projectPath - Optional project path for database location
185
+ * @param dbOverride - Optional database adapter for dependency injection
186
+ */
187
+ export async function readEvents(
188
+ options: {
189
+ projectKey?: string;
190
+ types?: AgentEvent["type"][];
191
+ since?: number; // timestamp
192
+ until?: number; // timestamp
193
+ afterSequence?: number;
194
+ limit?: number;
195
+ offset?: number;
196
+ } = {},
197
+ projectPath?: string,
198
+ dbOverride?: DatabaseAdapter,
199
+ ): Promise<Array<AgentEvent & { id: number; sequence: number }>> {
200
+ return withTiming("readEvents", async () => {
201
+ const db = dbOverride ?? (await getDatabase(projectPath) as unknown as DatabaseAdapter);
202
+
203
+ const conditions: string[] = [];
204
+ const params: unknown[] = [];
205
+ let paramIndex = 1;
206
+
207
+ if (options.projectKey) {
208
+ conditions.push(`project_key = $${paramIndex++}`);
209
+ params.push(options.projectKey);
210
+ }
211
+
212
+ if (options.types && options.types.length > 0) {
213
+ conditions.push(`type = ANY($${paramIndex++})`);
214
+ params.push(options.types);
215
+ }
216
+
217
+ if (options.since !== undefined) {
218
+ conditions.push(`timestamp >= $${paramIndex++}`);
219
+ params.push(options.since);
220
+ }
221
+
222
+ if (options.until !== undefined) {
223
+ conditions.push(`timestamp <= $${paramIndex++}`);
224
+ params.push(options.until);
225
+ }
226
+
227
+ if (options.afterSequence !== undefined) {
228
+ conditions.push(`sequence > $${paramIndex++}`);
229
+ params.push(options.afterSequence);
230
+ }
231
+
232
+ const whereClause =
233
+ conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
234
+
235
+ let query = `
236
+ SELECT id, type, project_key, timestamp, sequence, data
237
+ FROM events
238
+ ${whereClause}
239
+ ORDER BY sequence ASC
240
+ `;
241
+
242
+ if (options.limit) {
243
+ query += ` LIMIT $${paramIndex++}`;
244
+ params.push(options.limit);
245
+ }
246
+
247
+ if (options.offset) {
248
+ query += ` OFFSET $${paramIndex++}`;
249
+ params.push(options.offset);
250
+ }
251
+
252
+ const result = await db.query<{
253
+ id: number;
254
+ type: string;
255
+ project_key: string;
256
+ timestamp: string;
257
+ sequence: number;
258
+ data: string;
259
+ }>(query, params);
260
+
261
+ return result.rows.map((row) => {
262
+ const data =
263
+ typeof row.data === "string" ? JSON.parse(row.data) : row.data;
264
+ return {
265
+ id: row.id,
266
+ type: row.type as AgentEvent["type"],
267
+ project_key: row.project_key,
268
+ timestamp: parseTimestamp(row.timestamp as string),
269
+ sequence: row.sequence,
270
+ ...data,
271
+ } as AgentEvent & { id: number; sequence: number };
272
+ });
273
+ });
274
+ }
275
+
276
+ /**
277
+ * Get the latest sequence number
278
+ *
279
+ * @param projectKey - Optional project key to filter by
280
+ * @param projectPath - Optional project path for database location
281
+ * @param dbOverride - Optional database adapter for dependency injection
282
+ */
283
+ export async function getLatestSequence(
284
+ projectKey?: string,
285
+ projectPath?: string,
286
+ dbOverride?: DatabaseAdapter,
287
+ ): Promise<number> {
288
+ const db = dbOverride ?? (await getDatabase(projectPath) as unknown as DatabaseAdapter);
289
+
290
+ const query = projectKey
291
+ ? "SELECT MAX(sequence) as seq FROM events WHERE project_key = $1"
292
+ : "SELECT MAX(sequence) as seq FROM events";
293
+
294
+ const params = projectKey ? [projectKey] : [];
295
+ const result = await db.query<{ seq: number | null }>(query, params);
296
+
297
+ return result.rows[0]?.seq ?? 0;
298
+ }
299
+
300
+ /**
301
+ * Replay events to rebuild materialized views
302
+ *
303
+ * Useful for:
304
+ * - Recovering from corruption
305
+ * - Migrating to new schema
306
+ * - Debugging state issues
307
+ *
308
+ * @param options - Replay options
309
+ * @param projectPath - Optional project path for database location
310
+ * @param dbOverride - Optional database adapter for dependency injection
311
+ */
312
+ export async function replayEvents(
313
+ options: {
314
+ projectKey?: string;
315
+ fromSequence?: number;
316
+ clearViews?: boolean;
317
+ } = {},
318
+ projectPath?: string,
319
+ dbOverride?: DatabaseAdapter,
320
+ ): Promise<{ eventsReplayed: number; duration: number }> {
321
+ return withTiming("replayEvents", async () => {
322
+ const startTime = Date.now();
323
+ const db = dbOverride ?? (await getDatabase(projectPath) as unknown as DatabaseAdapter);
324
+
325
+ // Optionally clear materialized views
326
+ if (options.clearViews) {
327
+ if (options.projectKey) {
328
+ // Use parameterized queries to prevent SQL injection
329
+ await db.query(
330
+ `DELETE FROM message_recipients WHERE message_id IN (
331
+ SELECT id FROM messages WHERE project_key = $1
332
+ )`,
333
+ [options.projectKey],
334
+ );
335
+ await db.query(`DELETE FROM messages WHERE project_key = $1`, [
336
+ options.projectKey,
337
+ ]);
338
+ await db.query(`DELETE FROM reservations WHERE project_key = $1`, [
339
+ options.projectKey,
340
+ ]);
341
+ await db.query(`DELETE FROM agents WHERE project_key = $1`, [
342
+ options.projectKey,
343
+ ]);
344
+ } else {
345
+ await db.exec(`
346
+ DELETE FROM message_recipients;
347
+ DELETE FROM messages;
348
+ DELETE FROM reservations;
349
+ DELETE FROM agents;
350
+ `);
351
+ }
352
+ }
353
+
354
+ // Read all events
355
+ const events = await readEvents(
356
+ {
357
+ projectKey: options.projectKey,
358
+ afterSequence: options.fromSequence,
359
+ },
360
+ projectPath,
361
+ );
362
+
363
+ // Replay each event
364
+ for (const event of events) {
365
+ await updateMaterializedViews(db, event);
366
+ }
367
+
368
+ return {
369
+ eventsReplayed: events.length,
370
+ duration: Date.now() - startTime,
371
+ };
372
+ });
373
+ }
374
+
375
+ /**
376
+ * Replay events in batches to avoid OOM
377
+ *
378
+ * For large event logs (>100k events), use this instead of replayEvents()
379
+ * to keep memory usage constant.
380
+ *
381
+ * Example:
382
+ * ```typescript
383
+ * const result = await replayEventsBatched(
384
+ * "my-project",
385
+ * async (events, progress) => {
386
+ * console.log(`Replayed ${progress.processed}/${progress.total} (${progress.percent}%)`);
387
+ * },
388
+ * { batchSize: 1000, clearViews: true },
389
+ * "/path/to/project"
390
+ * );
391
+ * console.log(`Replayed ${result.eventsReplayed} events in ${result.duration}ms`);
392
+ * ```
393
+ *
394
+ * @param projectKey Project key to filter events
395
+ * @param onBatch Callback invoked for each batch with progress
396
+ * @param options Configuration options
397
+ * @param options.batchSize Number of events per batch (default 1000)
398
+ * @param options.fromSequence Start from this sequence number (default 0)
399
+ * @param options.clearViews Clear materialized views before replay (default false)
400
+ * @param projectPath Path to project database
401
+ */
402
+ export async function replayEventsBatched(
403
+ projectKey: string,
404
+ onBatch: (
405
+ events: Array<AgentEvent & { id: number; sequence: number }>,
406
+ progress: { processed: number; total: number; percent: number },
407
+ ) => Promise<void>,
408
+ options: {
409
+ batchSize?: number;
410
+ fromSequence?: number;
411
+ clearViews?: boolean;
412
+ } = {},
413
+ projectPath?: string,
414
+ dbOverride?: DatabaseAdapter,
415
+ ): Promise<{ eventsReplayed: number; duration: number }> {
416
+ return withTiming("replayEventsBatched", async () => {
417
+ const startTime = Date.now();
418
+ const batchSize = options.batchSize ?? 1000;
419
+ const fromSequence = options.fromSequence ?? 0;
420
+ const db =
421
+ dbOverride ??
422
+ ((await getDatabase(projectPath)) as unknown as DatabaseAdapter);
423
+
424
+ // Optionally clear materialized views
425
+ if (options.clearViews) {
426
+ await db.query(
427
+ `DELETE FROM message_recipients WHERE message_id IN (
428
+ SELECT id FROM messages WHERE project_key = $1
429
+ )`,
430
+ [projectKey],
431
+ );
432
+ await db.query(`DELETE FROM messages WHERE project_key = $1`, [
433
+ projectKey,
434
+ ]);
435
+ await db.query(`DELETE FROM reservations WHERE project_key = $1`, [
436
+ projectKey,
437
+ ]);
438
+ await db.query(`DELETE FROM agents WHERE project_key = $1`, [projectKey]);
439
+ }
440
+
441
+ // Get total count first
442
+ const countResult = await db.query<{ count: string }>(
443
+ `SELECT COUNT(*) as count FROM events WHERE project_key = $1 AND sequence > $2`,
444
+ [projectKey, fromSequence],
445
+ );
446
+ const total = parseInt(countResult.rows[0]?.count ?? "0");
447
+
448
+ if (total === 0) {
449
+ return { eventsReplayed: 0, duration: Date.now() - startTime };
450
+ }
451
+
452
+ let processed = 0;
453
+ let offset = 0;
454
+
455
+ while (processed < total) {
456
+ // Fetch batch
457
+ const events = await readEvents(
458
+ {
459
+ projectKey,
460
+ afterSequence: fromSequence,
461
+ limit: batchSize,
462
+ offset,
463
+ },
464
+ projectPath,
465
+ );
466
+
467
+ if (events.length === 0) break;
468
+
469
+ // Update materialized views for this batch
470
+ for (const event of events) {
471
+ await updateMaterializedViews(db, event);
472
+ }
473
+
474
+ processed += events.length;
475
+ const percent = Math.round((processed / total) * 100);
476
+
477
+ // Report progress
478
+ await onBatch(events, { processed, total, percent });
479
+
480
+ console.log(
481
+ `[SwarmMail] Replaying events: ${processed}/${total} (${percent}%)`,
482
+ );
483
+
484
+ offset += batchSize;
485
+ }
486
+
487
+ return {
488
+ eventsReplayed: processed,
489
+ duration: Date.now() - startTime,
490
+ };
491
+ });
492
+ }
493
+
494
+ // ============================================================================
495
+ // Materialized View Updates
496
+ // ============================================================================
497
+
498
+ /**
499
+ * Update materialized views based on event type
500
+ *
501
+ * This is called after each event is appended.
502
+ * Views are denormalized for fast reads.
503
+ */
504
+ async function updateMaterializedViews(
505
+ db: DatabaseAdapter,
506
+ event: AgentEvent & { id: number; sequence: number },
507
+ ): Promise<void> {
508
+ try {
509
+ switch (event.type) {
510
+ case "agent_registered":
511
+ await handleAgentRegistered(
512
+ db,
513
+ event as AgentRegisteredEvent & { id: number; sequence: number },
514
+ );
515
+ break;
516
+
517
+ case "agent_active":
518
+ await db.query(
519
+ `UPDATE agents SET last_active_at = $1 WHERE project_key = $2 AND name = $3`,
520
+ [event.timestamp, event.project_key, event.agent_name],
521
+ );
522
+ break;
523
+
524
+ case "message_sent":
525
+ await handleMessageSent(
526
+ db,
527
+ event as MessageSentEvent & { id: number; sequence: number },
528
+ );
529
+ break;
530
+
531
+ case "message_read":
532
+ await db.query(
533
+ `UPDATE message_recipients SET read_at = $1 WHERE message_id = $2 AND agent_name = $3`,
534
+ [event.timestamp, event.message_id, event.agent_name],
535
+ );
536
+ break;
537
+
538
+ case "message_acked":
539
+ await db.query(
540
+ `UPDATE message_recipients SET acked_at = $1 WHERE message_id = $2 AND agent_name = $3`,
541
+ [event.timestamp, event.message_id, event.agent_name],
542
+ );
543
+ break;
544
+
545
+ case "file_reserved":
546
+ await handleFileReserved(
547
+ db,
548
+ event as FileReservedEvent & { id: number; sequence: number },
549
+ );
550
+ break;
551
+
552
+ case "file_released":
553
+ await handleFileReleased(db, event);
554
+ break;
555
+
556
+ // Task events don't need materialized views (query events directly)
557
+ case "task_started":
558
+ case "task_progress":
559
+ case "task_completed":
560
+ case "task_blocked":
561
+ // No-op for now - could add task tracking table later
562
+ break;
563
+
564
+ // Eval capture events - update eval_records projection
565
+ case "decomposition_generated":
566
+ await handleDecompositionGenerated(db, event);
567
+ break;
568
+
569
+ case "subtask_outcome":
570
+ await handleSubtaskOutcome(db, event);
571
+ break;
572
+
573
+ case "human_feedback":
574
+ await handleHumanFeedback(db, event);
575
+ break;
576
+
577
+ // Swarm checkpoint events - update swarm_contexts table
578
+ case "swarm_checkpointed":
579
+ await handleSwarmCheckpointed(db, event);
580
+ break;
581
+
582
+ case "swarm_recovered":
583
+ await handleSwarmRecovered(db, event);
584
+ break;
585
+ }
586
+ } catch (error) {
587
+ console.error("[SwarmMail] Failed to update materialized views", {
588
+ eventType: event.type,
589
+ eventId: event.id,
590
+ error,
591
+ });
592
+ throw error;
593
+ }
594
+ }
595
+
596
+ async function handleAgentRegistered(
597
+ db: DatabaseAdapter,
598
+ event: AgentRegisteredEvent & { id: number; sequence: number },
599
+ ): Promise<void> {
600
+ await db.query(
601
+ `INSERT INTO agents (project_key, name, program, model, task_description, registered_at, last_active_at)
602
+ VALUES ($1, $2, $3, $4, $5, $6, $6)
603
+ ON CONFLICT (project_key, name) DO UPDATE SET
604
+ program = EXCLUDED.program,
605
+ model = EXCLUDED.model,
606
+ task_description = EXCLUDED.task_description,
607
+ last_active_at = EXCLUDED.last_active_at`,
608
+ [
609
+ event.project_key,
610
+ event.agent_name,
611
+ event.program,
612
+ event.model,
613
+ event.task_description || null,
614
+ event.timestamp,
615
+ ],
616
+ );
617
+ }
618
+
619
+ async function handleMessageSent(
620
+ db: DatabaseAdapter,
621
+ event: MessageSentEvent & { id: number; sequence: number },
622
+ ): Promise<void> {
623
+ console.log("[SwarmMail] Handling message sent event", {
624
+ from: event.from_agent,
625
+ to: event.to_agents,
626
+ subject: event.subject,
627
+ projectKey: event.project_key,
628
+ });
629
+
630
+ // Insert message
631
+ const result = await db.query<{ id: number }>(
632
+ `INSERT INTO messages (project_key, from_agent, subject, body, thread_id, importance, ack_required, created_at)
633
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
634
+ RETURNING id`,
635
+ [
636
+ event.project_key,
637
+ event.from_agent,
638
+ event.subject,
639
+ event.body,
640
+ event.thread_id || null,
641
+ event.importance,
642
+ event.ack_required,
643
+ event.timestamp,
644
+ ],
645
+ );
646
+
647
+ const msgRow = result.rows[0];
648
+ if (!msgRow) {
649
+ throw new Error("Failed to insert message - no row returned");
650
+ }
651
+ const messageId = msgRow.id;
652
+
653
+ // FIX: Bulk insert recipients to avoid N+1 queries
654
+ if (event.to_agents.length > 0) {
655
+ const values = event.to_agents.map((_, i) => `($1, $${i + 2})`).join(", ");
656
+ const params = [messageId, ...event.to_agents];
657
+
658
+ await db.query(
659
+ `INSERT INTO message_recipients (message_id, agent_name)
660
+ VALUES ${values}
661
+ ON CONFLICT DO NOTHING`,
662
+ params,
663
+ );
664
+
665
+ console.log("[SwarmMail] Message recipients inserted", {
666
+ messageId,
667
+ recipientCount: event.to_agents.length,
668
+ });
669
+ }
670
+ }
671
+
672
+ async function handleFileReserved(
673
+ db: DatabaseAdapter,
674
+ event: FileReservedEvent & { id: number; sequence: number },
675
+ ): Promise<void> {
676
+ console.log("[SwarmMail] Handling file reservation event", {
677
+ agent: event.agent_name,
678
+ paths: event.paths,
679
+ exclusive: event.exclusive,
680
+ projectKey: event.project_key,
681
+ });
682
+
683
+ // FIX: Bulk insert reservations to avoid N+1 queries
684
+ if (event.paths.length > 0) {
685
+ // Each path gets its own VALUES clause with placeholders:
686
+ // ($1=project_key, $2=agent_name, $3=path1, $4=exclusive, $5=reason, $6=created_at, $7=expires_at)
687
+ // ($1=project_key, $2=agent_name, $8=path2, $4=exclusive, $5=reason, $6=created_at, $7=expires_at)
688
+ // etc.
689
+ const values = event.paths
690
+ .map(
691
+ (_, i) =>
692
+ `($1, $2, $${i + 3}, $${event.paths.length + 3}, $${event.paths.length + 4}, $${event.paths.length + 5}, $${event.paths.length + 6})`,
693
+ )
694
+ .join(", ");
695
+
696
+ const params = [
697
+ event.project_key, // $1
698
+ event.agent_name, // $2
699
+ ...event.paths, // $3, $4, ... (one per path)
700
+ event.exclusive, // $N+3
701
+ event.reason || null, // $N+4
702
+ event.timestamp, // $N+5
703
+ event.expires_at, // $N+6
704
+ ];
705
+
706
+ // FIX: Make idempotent by deleting existing active reservations first
707
+ // This handles retry scenarios (network timeouts, etc.) without creating duplicates
708
+ if (event.paths.length > 0) {
709
+ await db.query(
710
+ `DELETE FROM reservations
711
+ WHERE project_key = $1
712
+ AND agent_name = $2
713
+ AND path_pattern = ANY($3)
714
+ AND released_at IS NULL`,
715
+ [event.project_key, event.agent_name, event.paths],
716
+ );
717
+ }
718
+
719
+ await db.query(
720
+ `INSERT INTO reservations (project_key, agent_name, path_pattern, exclusive, reason, created_at, expires_at)
721
+ VALUES ${values}`,
722
+ params,
723
+ );
724
+
725
+ console.log("[SwarmMail] File reservations inserted", {
726
+ agent: event.agent_name,
727
+ reservationCount: event.paths.length,
728
+ });
729
+ }
730
+ }
731
+
732
+ async function handleFileReleased(
733
+ db: DatabaseAdapter,
734
+ event: AgentEvent & { id: number; sequence: number },
735
+ ): Promise<void> {
736
+ if (event.type !== "file_released") return;
737
+
738
+ if (event.reservation_ids && event.reservation_ids.length > 0) {
739
+ // Release specific reservations
740
+ await db.query(
741
+ `UPDATE reservations SET released_at = $1 WHERE id = ANY($2)`,
742
+ [event.timestamp, event.reservation_ids],
743
+ );
744
+ } else if (event.paths && event.paths.length > 0) {
745
+ // Release by path
746
+ await db.query(
747
+ `UPDATE reservations SET released_at = $1
748
+ WHERE project_key = $2 AND agent_name = $3 AND path_pattern = ANY($4) AND released_at IS NULL`,
749
+ [event.timestamp, event.project_key, event.agent_name, event.paths],
750
+ );
751
+ } else {
752
+ // Release all for agent
753
+ await db.query(
754
+ `UPDATE reservations SET released_at = $1
755
+ WHERE project_key = $2 AND agent_name = $3 AND released_at IS NULL`,
756
+ [event.timestamp, event.project_key, event.agent_name],
757
+ );
758
+ }
759
+ }
760
+
761
+ async function handleDecompositionGenerated(
762
+ db: DatabaseAdapter,
763
+ event: AgentEvent & { id: number; sequence: number },
764
+ ): Promise<void> {
765
+ if (event.type !== "decomposition_generated") return;
766
+
767
+ await db.query(
768
+ `INSERT INTO eval_records (
769
+ id, project_key, task, context, strategy, epic_title, subtasks,
770
+ created_at, updated_at
771
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)
772
+ ON CONFLICT (id) DO NOTHING`,
773
+ [
774
+ event.epic_id,
775
+ event.project_key,
776
+ event.task,
777
+ event.context || null,
778
+ event.strategy,
779
+ event.epic_title,
780
+ JSON.stringify(event.subtasks),
781
+ event.timestamp,
782
+ ],
783
+ );
784
+ }
785
+
786
+ async function handleSubtaskOutcome(
787
+ db: DatabaseAdapter,
788
+ event: AgentEvent & { id: number; sequence: number },
789
+ ): Promise<void> {
790
+ if (event.type !== "subtask_outcome") return;
791
+
792
+ // Fetch current record to compute metrics
793
+ const result = await db.query<{
794
+ outcomes: string | null;
795
+ subtasks: string;
796
+ }>(`SELECT outcomes, subtasks FROM eval_records WHERE id = $1`, [
797
+ event.epic_id,
798
+ ]);
799
+
800
+ if (!result.rows[0]) {
801
+ console.warn(
802
+ `[SwarmMail] No eval_record found for epic_id ${event.epic_id}`,
803
+ );
804
+ return;
805
+ }
806
+
807
+ const row = result.rows[0];
808
+ // PGlite returns JSONB columns as already-parsed objects
809
+ const subtasks = (
810
+ typeof row.subtasks === "string" ? JSON.parse(row.subtasks) : row.subtasks
811
+ ) as Array<{
812
+ title: string;
813
+ files: string[];
814
+ }>;
815
+ const outcomes = row.outcomes
816
+ ? ((typeof row.outcomes === "string"
817
+ ? JSON.parse(row.outcomes)
818
+ : row.outcomes) as Array<{
819
+ bead_id: string;
820
+ planned_files: string[];
821
+ actual_files: string[];
822
+ duration_ms: number;
823
+ error_count: number;
824
+ retry_count: number;
825
+ success: boolean;
826
+ }>)
827
+ : [];
828
+
829
+ // Create new outcome
830
+ const newOutcome = {
831
+ bead_id: event.bead_id,
832
+ planned_files: event.planned_files,
833
+ actual_files: event.actual_files,
834
+ duration_ms: event.duration_ms,
835
+ error_count: event.error_count,
836
+ retry_count: event.retry_count,
837
+ success: event.success,
838
+ };
839
+
840
+ // Append to outcomes array
841
+ const updatedOutcomes = [...outcomes, newOutcome];
842
+
843
+ // Compute metrics
844
+ const fileOverlapCount = computeFileOverlap(subtasks);
845
+ const scopeAccuracy = computeScopeAccuracy(
846
+ event.planned_files,
847
+ event.actual_files,
848
+ );
849
+ const timeBalanceRatio = computeTimeBalanceRatio(updatedOutcomes);
850
+ const overallSuccess = updatedOutcomes.every((o) => o.success);
851
+ const totalDurationMs = updatedOutcomes.reduce(
852
+ (sum, o) => sum + o.duration_ms,
853
+ 0,
854
+ );
855
+ const totalErrors = updatedOutcomes.reduce(
856
+ (sum, o) => sum + o.error_count,
857
+ 0,
858
+ );
859
+
860
+ // Update record
861
+ await db.query(
862
+ `UPDATE eval_records SET
863
+ outcomes = $1,
864
+ file_overlap_count = $2,
865
+ scope_accuracy = $3,
866
+ time_balance_ratio = $4,
867
+ overall_success = $5,
868
+ total_duration_ms = $6,
869
+ total_errors = $7,
870
+ updated_at = $8
871
+ WHERE id = $9`,
872
+ [
873
+ JSON.stringify(updatedOutcomes),
874
+ fileOverlapCount,
875
+ scopeAccuracy,
876
+ timeBalanceRatio,
877
+ overallSuccess,
878
+ totalDurationMs,
879
+ totalErrors,
880
+ event.timestamp,
881
+ event.epic_id,
882
+ ],
883
+ );
884
+ }
885
+
886
+ async function handleHumanFeedback(
887
+ db: DatabaseAdapter,
888
+ event: AgentEvent & { id: number; sequence: number },
889
+ ): Promise<void> {
890
+ if (event.type !== "human_feedback") return;
891
+
892
+ await db.query(
893
+ `UPDATE eval_records SET
894
+ human_accepted = $1,
895
+ human_modified = $2,
896
+ human_notes = $3,
897
+ updated_at = $4
898
+ WHERE id = $5`,
899
+ [
900
+ event.accepted,
901
+ event.modified,
902
+ event.notes || null,
903
+ event.timestamp,
904
+ event.epic_id,
905
+ ],
906
+ );
907
+ }
908
+
909
+ async function handleSwarmCheckpointed(
910
+ db: DatabaseAdapter,
911
+ event: AgentEvent & { id: number; sequence: number },
912
+ ): Promise<void> {
913
+ if (event.type !== "swarm_checkpointed") return;
914
+
915
+ await db.query(
916
+ `INSERT INTO swarm_contexts (
917
+ project_key, epic_id, bead_id, strategy, files, dependencies,
918
+ directives, recovery, checkpointed_at, updated_at
919
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)
920
+ ON CONFLICT (project_key, epic_id, bead_id) DO UPDATE SET
921
+ strategy = EXCLUDED.strategy,
922
+ files = EXCLUDED.files,
923
+ dependencies = EXCLUDED.dependencies,
924
+ directives = EXCLUDED.directives,
925
+ recovery = EXCLUDED.recovery,
926
+ checkpointed_at = EXCLUDED.checkpointed_at,
927
+ updated_at = EXCLUDED.updated_at`,
928
+ [
929
+ event.project_key,
930
+ event.epic_id,
931
+ event.bead_id,
932
+ event.strategy,
933
+ JSON.stringify(event.files),
934
+ JSON.stringify(event.dependencies),
935
+ JSON.stringify(event.directives),
936
+ JSON.stringify(event.recovery),
937
+ event.timestamp,
938
+ ],
939
+ );
940
+ }
941
+
942
+ async function handleSwarmRecovered(
943
+ db: DatabaseAdapter,
944
+ event: AgentEvent & { id: number; sequence: number },
945
+ ): Promise<void> {
946
+ if (event.type !== "swarm_recovered") return;
947
+
948
+ // Update swarm_contexts to mark as recovered
949
+ await db.query(
950
+ `UPDATE swarm_contexts SET
951
+ recovered_at = $1,
952
+ recovered_from_checkpoint = $2,
953
+ updated_at = $1
954
+ WHERE project_key = $3 AND epic_id = $4 AND bead_id = $5`,
955
+ [
956
+ event.timestamp,
957
+ event.recovered_from_checkpoint,
958
+ event.project_key,
959
+ event.epic_id,
960
+ event.bead_id,
961
+ ],
962
+ );
963
+ }
964
+
965
+ // ============================================================================
966
+ // Metric Computation Helpers
967
+ // ============================================================================
968
+
969
+ /**
970
+ * Count files that appear in multiple subtasks
971
+ */
972
+ function computeFileOverlap(subtasks: Array<{ files: string[] }>): number {
973
+ const fileCount = new Map<string, number>();
974
+
975
+ for (const subtask of subtasks) {
976
+ for (const file of subtask.files) {
977
+ fileCount.set(file, (fileCount.get(file) || 0) + 1);
978
+ }
979
+ }
980
+
981
+ return Array.from(fileCount.values()).filter((count) => count > 1).length;
982
+ }
983
+
984
+ /**
985
+ * Compute scope accuracy: intersection(actual, planned) / planned.length
986
+ */
987
+ function computeScopeAccuracy(planned: string[], actual: string[]): number {
988
+ if (planned.length === 0) return 1.0;
989
+
990
+ const plannedSet = new Set(planned);
991
+ const intersection = actual.filter((file) => plannedSet.has(file));
992
+
993
+ return intersection.length / planned.length;
994
+ }
995
+
996
+ /**
997
+ * Compute time balance ratio: max(duration) / min(duration)
998
+ * Lower is better (more balanced)
999
+ */
1000
+ function computeTimeBalanceRatio(
1001
+ outcomes: Array<{ duration_ms: number }>,
1002
+ ): number | null {
1003
+ if (outcomes.length === 0) return null;
1004
+
1005
+ const durations = outcomes.map((o) => o.duration_ms);
1006
+ const max = Math.max(...durations);
1007
+ const min = Math.min(...durations);
1008
+
1009
+ if (min === 0) return null;
1010
+
1011
+ return max / min;
1012
+ }
1013
+
1014
+ // ============================================================================
1015
+ // Convenience Functions
1016
+ // ============================================================================
1017
+
1018
+ /**
1019
+ * Register an agent (creates event + updates view)
1020
+ *
1021
+ * @param projectKey - Project identifier
1022
+ * @param agentName - Agent name
1023
+ * @param options - Registration options
1024
+ * @param projectPath - Optional project path for database location
1025
+ * @param dbOverride - Optional database adapter for dependency injection
1026
+ */
1027
+ export async function registerAgent(
1028
+ projectKey: string,
1029
+ agentName: string,
1030
+ options: {
1031
+ program?: string;
1032
+ model?: string;
1033
+ taskDescription?: string;
1034
+ } = {},
1035
+ projectPath?: string,
1036
+ dbOverride?: DatabaseAdapter,
1037
+ ): Promise<AgentRegisteredEvent & { id: number; sequence: number }> {
1038
+ const event = createEvent("agent_registered", {
1039
+ project_key: projectKey,
1040
+ agent_name: agentName,
1041
+ program: options.program || "opencode",
1042
+ model: options.model || "unknown",
1043
+ task_description: options.taskDescription,
1044
+ });
1045
+
1046
+ return appendEvent(event, projectPath, dbOverride) as Promise<
1047
+ AgentRegisteredEvent & { id: number; sequence: number }
1048
+ >;
1049
+ }
1050
+
1051
+ /**
1052
+ * Send a message (creates event + updates view)
1053
+ *
1054
+ * @param projectKey - Project identifier
1055
+ * @param fromAgent - Sender agent name
1056
+ * @param toAgents - Recipient agent names
1057
+ * @param subject - Message subject
1058
+ * @param body - Message body
1059
+ * @param options - Message options
1060
+ * @param projectPath - Optional project path for database location
1061
+ * @param dbOverride - Optional database adapter for dependency injection
1062
+ */
1063
+ export async function sendMessage(
1064
+ projectKey: string,
1065
+ fromAgent: string,
1066
+ toAgents: string[],
1067
+ subject: string,
1068
+ body: string,
1069
+ options: {
1070
+ threadId?: string;
1071
+ importance?: "low" | "normal" | "high" | "urgent";
1072
+ ackRequired?: boolean;
1073
+ } = {},
1074
+ projectPath?: string,
1075
+ dbOverride?: DatabaseAdapter,
1076
+ ): Promise<MessageSentEvent & { id: number; sequence: number }> {
1077
+ const event = createEvent("message_sent", {
1078
+ project_key: projectKey,
1079
+ from_agent: fromAgent,
1080
+ to_agents: toAgents,
1081
+ subject,
1082
+ body,
1083
+ thread_id: options.threadId,
1084
+ importance: options.importance || "normal",
1085
+ ack_required: options.ackRequired || false,
1086
+ });
1087
+
1088
+ return appendEvent(event, projectPath, dbOverride) as Promise<
1089
+ MessageSentEvent & { id: number; sequence: number }
1090
+ >;
1091
+ }
1092
+
1093
+ /**
1094
+ * Reserve files (creates event + updates view)
1095
+ *
1096
+ * @param projectKey - Project identifier
1097
+ * @param agentName - Agent reserving the files
1098
+ * @param paths - File paths to reserve
1099
+ * @param options - Reservation options
1100
+ * @param projectPath - Optional project path for database location
1101
+ * @param dbOverride - Optional database adapter for dependency injection
1102
+ */
1103
+ export async function reserveFiles(
1104
+ projectKey: string,
1105
+ agentName: string,
1106
+ paths: string[],
1107
+ options: {
1108
+ reason?: string;
1109
+ exclusive?: boolean;
1110
+ ttlSeconds?: number;
1111
+ } = {},
1112
+ projectPath?: string,
1113
+ dbOverride?: DatabaseAdapter,
1114
+ ): Promise<FileReservedEvent & { id: number; sequence: number }> {
1115
+ const ttlSeconds = options.ttlSeconds || 3600;
1116
+ const event = createEvent("file_reserved", {
1117
+ project_key: projectKey,
1118
+ agent_name: agentName,
1119
+ paths,
1120
+ reason: options.reason,
1121
+ exclusive: options.exclusive ?? true,
1122
+ ttl_seconds: ttlSeconds,
1123
+ expires_at: Date.now() + ttlSeconds * 1000,
1124
+ });
1125
+
1126
+ return appendEvent(event, projectPath, dbOverride) as Promise<
1127
+ FileReservedEvent & { id: number; sequence: number }
1128
+ >;
1129
+ }