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