opencode-swarm-plugin 0.17.1 → 0.19.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.
@@ -5,10 +5,11 @@
5
5
  * - append(): Add events to the log
6
6
  * - read(): Read events with filters
7
7
  * - replay(): Rebuild state from events
8
+ * - replayBatched(): Rebuild state with pagination (for large logs)
8
9
  *
9
10
  * All state changes go through events. Projections compute current state.
10
11
  */
11
- import { getDatabase } from "./index";
12
+ import { getDatabase, withTiming } from "./index";
12
13
  import {
13
14
  type AgentEvent,
14
15
  createEvent,
@@ -17,6 +18,40 @@ import {
17
18
  type FileReservedEvent,
18
19
  } from "./events";
19
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
+
20
55
  // ============================================================================
21
56
  // Event Store Operations
22
57
  // ============================================================================
@@ -35,6 +70,12 @@ export async function appendEvent(
35
70
  // Extract common fields
36
71
  const { type, project_key, timestamp, ...rest } = event;
37
72
 
73
+ console.log("[SwarmMail] Appending event", {
74
+ type,
75
+ projectKey: project_key,
76
+ timestamp,
77
+ });
78
+
38
79
  // Insert event
39
80
  const result = await db.query<{ id: number; sequence: number }>(
40
81
  `INSERT INTO events (type, project_key, timestamp, data)
@@ -49,7 +90,15 @@ export async function appendEvent(
49
90
  }
50
91
  const { id, sequence } = row;
51
92
 
93
+ console.log("[SwarmMail] Event appended", {
94
+ type,
95
+ id,
96
+ sequence,
97
+ projectKey: project_key,
98
+ });
99
+
52
100
  // Update materialized views based on event type
101
+ console.debug("[SwarmMail] Updating materialized views", { type, id });
53
102
  await updateMaterializedViews(db, { ...event, id, sequence });
54
103
 
55
104
  return { ...event, id, sequence };
@@ -62,38 +111,59 @@ export async function appendEvents(
62
111
  events: AgentEvent[],
63
112
  projectPath?: string,
64
113
  ): Promise<Array<AgentEvent & { id: number; sequence: number }>> {
65
- const db = await getDatabase(projectPath);
66
- const results: Array<AgentEvent & { id: number; sequence: number }> = [];
67
-
68
- await db.exec("BEGIN");
69
- try {
70
- for (const event of events) {
71
- const { type, project_key, timestamp, ...rest } = event;
72
-
73
- const result = await db.query<{ id: number; sequence: number }>(
74
- `INSERT INTO events (type, project_key, timestamp, data)
75
- VALUES ($1, $2, $3, $4)
76
- RETURNING id, sequence`,
77
- [type, project_key, timestamp, JSON.stringify(rest)],
78
- );
79
-
80
- const row = result.rows[0];
81
- if (!row) {
82
- throw new Error("Failed to insert event - no row returned");
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);
83
149
  }
84
- const { id, sequence } = row;
85
- const enrichedEvent = { ...event, id, sequence };
86
150
 
87
- await updateMaterializedViews(db, enrichedEvent);
88
- results.push(enrichedEvent);
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;
89
163
  }
90
- await db.exec("COMMIT");
91
- } catch (error) {
92
- await db.exec("ROLLBACK");
93
- throw error;
94
- }
95
164
 
96
- return results;
165
+ return results;
166
+ });
97
167
  }
98
168
 
99
169
  /**
@@ -111,76 +181,79 @@ export async function readEvents(
111
181
  } = {},
112
182
  projectPath?: string,
113
183
  ): Promise<Array<AgentEvent & { id: number; sequence: number }>> {
114
- const db = await getDatabase(projectPath);
184
+ return withTiming("readEvents", async () => {
185
+ const db = await getDatabase(projectPath);
115
186
 
116
- const conditions: string[] = [];
117
- const params: unknown[] = [];
118
- let paramIndex = 1;
187
+ const conditions: string[] = [];
188
+ const params: unknown[] = [];
189
+ let paramIndex = 1;
119
190
 
120
- if (options.projectKey) {
121
- conditions.push(`project_key = $${paramIndex++}`);
122
- params.push(options.projectKey);
123
- }
191
+ if (options.projectKey) {
192
+ conditions.push(`project_key = $${paramIndex++}`);
193
+ params.push(options.projectKey);
194
+ }
124
195
 
125
- if (options.types && options.types.length > 0) {
126
- conditions.push(`type = ANY($${paramIndex++})`);
127
- params.push(options.types);
128
- }
196
+ if (options.types && options.types.length > 0) {
197
+ conditions.push(`type = ANY($${paramIndex++})`);
198
+ params.push(options.types);
199
+ }
129
200
 
130
- if (options.since !== undefined) {
131
- conditions.push(`timestamp >= $${paramIndex++}`);
132
- params.push(options.since);
133
- }
201
+ if (options.since !== undefined) {
202
+ conditions.push(`timestamp >= $${paramIndex++}`);
203
+ params.push(options.since);
204
+ }
134
205
 
135
- if (options.until !== undefined) {
136
- conditions.push(`timestamp <= $${paramIndex++}`);
137
- params.push(options.until);
138
- }
206
+ if (options.until !== undefined) {
207
+ conditions.push(`timestamp <= $${paramIndex++}`);
208
+ params.push(options.until);
209
+ }
139
210
 
140
- if (options.afterSequence !== undefined) {
141
- conditions.push(`sequence > $${paramIndex++}`);
142
- params.push(options.afterSequence);
143
- }
211
+ if (options.afterSequence !== undefined) {
212
+ conditions.push(`sequence > $${paramIndex++}`);
213
+ params.push(options.afterSequence);
214
+ }
144
215
 
145
- const whereClause =
146
- conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
216
+ const whereClause =
217
+ conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
147
218
 
148
- let query = `
149
- SELECT id, type, project_key, timestamp, sequence, data
150
- FROM events
151
- ${whereClause}
152
- ORDER BY sequence ASC
153
- `;
219
+ let query = `
220
+ SELECT id, type, project_key, timestamp, sequence, data
221
+ FROM events
222
+ ${whereClause}
223
+ ORDER BY sequence ASC
224
+ `;
154
225
 
155
- if (options.limit) {
156
- query += ` LIMIT $${paramIndex++}`;
157
- params.push(options.limit);
158
- }
226
+ if (options.limit) {
227
+ query += ` LIMIT $${paramIndex++}`;
228
+ params.push(options.limit);
229
+ }
159
230
 
160
- if (options.offset) {
161
- query += ` OFFSET $${paramIndex++}`;
162
- params.push(options.offset);
163
- }
231
+ if (options.offset) {
232
+ query += ` OFFSET $${paramIndex++}`;
233
+ params.push(options.offset);
234
+ }
164
235
 
165
- const result = await db.query<{
166
- id: number;
167
- type: string;
168
- project_key: string;
169
- timestamp: string;
170
- sequence: number;
171
- data: string;
172
- }>(query, params);
173
-
174
- return result.rows.map((row) => {
175
- const data = typeof row.data === "string" ? JSON.parse(row.data) : row.data;
176
- return {
177
- id: row.id,
178
- type: row.type as AgentEvent["type"],
179
- project_key: row.project_key,
180
- timestamp: parseInt(row.timestamp as string),
181
- sequence: row.sequence,
182
- ...data,
183
- } as AgentEvent & { id: number; sequence: number };
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
+ });
184
257
  });
185
258
  }
186
259
 
@@ -219,56 +292,174 @@ export async function replayEvents(
219
292
  } = {},
220
293
  projectPath?: string,
221
294
  ): Promise<{ eventsReplayed: number; duration: number }> {
222
- const startTime = Date.now();
223
- const db = await getDatabase(projectPath);
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
+ }
224
327
 
225
- // Optionally clear materialized views
226
- if (options.clearViews) {
227
- if (options.projectKey) {
228
- // Use parameterized queries to prevent SQL injection
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) {
229
397
  await db.query(
230
398
  `DELETE FROM message_recipients WHERE message_id IN (
231
399
  SELECT id FROM messages WHERE project_key = $1
232
400
  )`,
233
- [options.projectKey],
401
+ [projectKey],
234
402
  );
235
403
  await db.query(`DELETE FROM messages WHERE project_key = $1`, [
236
- options.projectKey,
404
+ projectKey,
237
405
  ]);
238
406
  await db.query(`DELETE FROM reservations WHERE project_key = $1`, [
239
- options.projectKey,
240
- ]);
241
- await db.query(`DELETE FROM agents WHERE project_key = $1`, [
242
- options.projectKey,
407
+ projectKey,
243
408
  ]);
244
- } else {
245
- await db.exec(`
246
- DELETE FROM message_recipients;
247
- DELETE FROM messages;
248
- DELETE FROM reservations;
249
- DELETE FROM agents;
250
- `);
409
+ await db.query(`DELETE FROM agents WHERE project_key = $1`, [projectKey]);
251
410
  }
252
- }
253
411
 
254
- // Read all events
255
- const events = await readEvents(
256
- {
257
- projectKey: options.projectKey,
258
- afterSequence: options.fromSequence,
259
- },
260
- projectPath,
261
- );
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");
262
418
 
263
- // Replay each event
264
- for (const event of events) {
265
- await updateMaterializedViews(db, event);
266
- }
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
+ }
267
444
 
268
- return {
269
- eventsReplayed: events.length,
270
- duration: Date.now() - startTime,
271
- };
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
+ });
272
463
  }
273
464
 
274
465
  // ============================================================================
@@ -285,60 +476,69 @@ async function updateMaterializedViews(
285
476
  db: Awaited<ReturnType<typeof getDatabase>>,
286
477
  event: AgentEvent & { id: number; sequence: number },
287
478
  ): Promise<void> {
288
- switch (event.type) {
289
- case "agent_registered":
290
- await handleAgentRegistered(
291
- db,
292
- event as AgentRegisteredEvent & { id: number; sequence: number },
293
- );
294
- break;
295
-
296
- case "agent_active":
297
- await db.query(
298
- `UPDATE agents SET last_active_at = $1 WHERE project_key = $2 AND name = $3`,
299
- [event.timestamp, event.project_key, event.agent_name],
300
- );
301
- break;
302
-
303
- case "message_sent":
304
- await handleMessageSent(
305
- db,
306
- event as MessageSentEvent & { id: number; sequence: number },
307
- );
308
- break;
309
-
310
- case "message_read":
311
- await db.query(
312
- `UPDATE message_recipients SET read_at = $1 WHERE message_id = $2 AND agent_name = $3`,
313
- [event.timestamp, event.message_id, event.agent_name],
314
- );
315
- break;
316
-
317
- case "message_acked":
318
- await db.query(
319
- `UPDATE message_recipients SET acked_at = $1 WHERE message_id = $2 AND agent_name = $3`,
320
- [event.timestamp, event.message_id, event.agent_name],
321
- );
322
- break;
323
-
324
- case "file_reserved":
325
- await handleFileReserved(
326
- db,
327
- event as FileReservedEvent & { id: number; sequence: number },
328
- );
329
- break;
330
-
331
- case "file_released":
332
- await handleFileReleased(db, event);
333
- break;
334
-
335
- // Task events don't need materialized views (query events directly)
336
- case "task_started":
337
- case "task_progress":
338
- case "task_completed":
339
- case "task_blocked":
340
- // No-op for now - could add task tracking table later
341
- break;
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
+ } catch (error) {
536
+ console.error("[SwarmMail] Failed to update materialized views", {
537
+ eventType: event.type,
538
+ eventId: event.id,
539
+ error,
540
+ });
541
+ throw error;
342
542
  }
343
543
  }
344
544
 
@@ -369,6 +569,13 @@ async function handleMessageSent(
369
569
  db: Awaited<ReturnType<typeof getDatabase>>,
370
570
  event: MessageSentEvent & { id: number; sequence: number },
371
571
  ): Promise<void> {
572
+ console.log("[SwarmMail] Handling message sent event", {
573
+ from: event.from_agent,
574
+ to: event.to_agents,
575
+ subject: event.subject,
576
+ projectKey: event.project_key,
577
+ });
578
+
372
579
  // Insert message
373
580
  const result = await db.query<{ id: number }>(
374
581
  `INSERT INTO messages (project_key, from_agent, subject, body, thread_id, importance, ack_required, created_at)
@@ -392,14 +599,22 @@ async function handleMessageSent(
392
599
  }
393
600
  const messageId = msgRow.id;
394
601
 
395
- // Insert recipients
396
- for (const agent of event.to_agents) {
602
+ // FIX: Bulk insert recipients to avoid N+1 queries
603
+ if (event.to_agents.length > 0) {
604
+ const values = event.to_agents.map((_, i) => `($1, $${i + 2})`).join(", ");
605
+ const params = [messageId, ...event.to_agents];
606
+
397
607
  await db.query(
398
608
  `INSERT INTO message_recipients (message_id, agent_name)
399
- VALUES ($1, $2)
609
+ VALUES ${values}
400
610
  ON CONFLICT DO NOTHING`,
401
- [messageId, agent],
611
+ params,
402
612
  );
613
+
614
+ console.log("[SwarmMail] Message recipients inserted", {
615
+ messageId,
616
+ recipientCount: event.to_agents.length,
617
+ });
403
618
  }
404
619
  }
405
620
 
@@ -407,20 +622,59 @@ async function handleFileReserved(
407
622
  db: Awaited<ReturnType<typeof getDatabase>>,
408
623
  event: FileReservedEvent & { id: number; sequence: number },
409
624
  ): Promise<void> {
410
- for (const path of event.paths) {
625
+ console.log("[SwarmMail] Handling file reservation event", {
626
+ agent: event.agent_name,
627
+ paths: event.paths,
628
+ exclusive: event.exclusive,
629
+ projectKey: event.project_key,
630
+ });
631
+
632
+ // FIX: Bulk insert reservations to avoid N+1 queries
633
+ if (event.paths.length > 0) {
634
+ // Each path gets its own VALUES clause with placeholders:
635
+ // ($1=project_key, $2=agent_name, $3=path1, $4=exclusive, $5=reason, $6=created_at, $7=expires_at)
636
+ // ($1=project_key, $2=agent_name, $8=path2, $4=exclusive, $5=reason, $6=created_at, $7=expires_at)
637
+ // etc.
638
+ const values = event.paths
639
+ .map(
640
+ (_, i) =>
641
+ `($1, $2, $${i + 3}, $${event.paths.length + 3}, $${event.paths.length + 4}, $${event.paths.length + 5}, $${event.paths.length + 6})`,
642
+ )
643
+ .join(", ");
644
+
645
+ const params = [
646
+ event.project_key, // $1
647
+ event.agent_name, // $2
648
+ ...event.paths, // $3, $4, ... (one per path)
649
+ event.exclusive, // $N+3
650
+ event.reason || null, // $N+4
651
+ event.timestamp, // $N+5
652
+ event.expires_at, // $N+6
653
+ ];
654
+
655
+ // FIX: Make idempotent by deleting existing active reservations first
656
+ // This handles retry scenarios (network timeouts, etc.) without creating duplicates
657
+ if (event.paths.length > 0) {
658
+ await db.query(
659
+ `DELETE FROM reservations
660
+ WHERE project_key = $1
661
+ AND agent_name = $2
662
+ AND path_pattern = ANY($3)
663
+ AND released_at IS NULL`,
664
+ [event.project_key, event.agent_name, event.paths],
665
+ );
666
+ }
667
+
411
668
  await db.query(
412
669
  `INSERT INTO reservations (project_key, agent_name, path_pattern, exclusive, reason, created_at, expires_at)
413
- VALUES ($1, $2, $3, $4, $5, $6, $7)`,
414
- [
415
- event.project_key,
416
- event.agent_name,
417
- path,
418
- event.exclusive,
419
- event.reason || null,
420
- event.timestamp,
421
- event.expires_at,
422
- ],
670
+ VALUES ${values}`,
671
+ params,
423
672
  );
673
+
674
+ console.log("[SwarmMail] File reservations inserted", {
675
+ agent: event.agent_name,
676
+ reservationCount: event.paths.length,
677
+ });
424
678
  }
425
679
  }
426
680