opencode-swarm-plugin 0.18.0 → 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
  // ============================================================================
@@ -76,43 +111,59 @@ export async function appendEvents(
76
111
  events: AgentEvent[],
77
112
  projectPath?: string,
78
113
  ): Promise<Array<AgentEvent & { id: number; sequence: number }>> {
79
- const db = await getDatabase(projectPath);
80
- const results: 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 }> = [];
81
117
 
82
- await db.exec("BEGIN");
83
- try {
84
- for (const event of events) {
85
- const { type, project_key, timestamp, ...rest } = event;
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
+ );
86
129
 
87
- const result = await db.query<{ id: number; sequence: number }>(
88
- `INSERT INTO events (type, project_key, timestamp, data)
89
- VALUES ($1, $2, $3, $4)
90
- RETURNING id, sequence`,
91
- [type, project_key, timestamp, JSON.stringify(rest)],
92
- );
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 };
93
136
 
94
- const row = result.rows[0];
95
- if (!row) {
96
- throw new Error("Failed to insert event - no row returned");
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);
97
149
  }
98
- const { id, sequence } = row;
99
- const enrichedEvent = { ...event, id, sequence };
100
150
 
101
- await updateMaterializedViews(db, enrichedEvent);
102
- results.push(enrichedEvent);
103
- }
104
- await db.exec("COMMIT");
105
- } catch (e) {
106
- // FIX: Log rollback failures (connection lost, etc)
107
- try {
108
- await db.exec("ROLLBACK");
109
- } catch (rollbackError) {
110
- console.error("[SwarmMail] ROLLBACK failed:", rollbackError);
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;
111
163
  }
112
- throw e;
113
- }
114
164
 
115
- return results;
165
+ return results;
166
+ });
116
167
  }
117
168
 
118
169
  /**
@@ -130,76 +181,79 @@ export async function readEvents(
130
181
  } = {},
131
182
  projectPath?: string,
132
183
  ): Promise<Array<AgentEvent & { id: number; sequence: number }>> {
133
- const db = await getDatabase(projectPath);
184
+ return withTiming("readEvents", async () => {
185
+ const db = await getDatabase(projectPath);
134
186
 
135
- const conditions: string[] = [];
136
- const params: unknown[] = [];
137
- let paramIndex = 1;
187
+ const conditions: string[] = [];
188
+ const params: unknown[] = [];
189
+ let paramIndex = 1;
138
190
 
139
- if (options.projectKey) {
140
- conditions.push(`project_key = $${paramIndex++}`);
141
- params.push(options.projectKey);
142
- }
191
+ if (options.projectKey) {
192
+ conditions.push(`project_key = $${paramIndex++}`);
193
+ params.push(options.projectKey);
194
+ }
143
195
 
144
- if (options.types && options.types.length > 0) {
145
- conditions.push(`type = ANY($${paramIndex++})`);
146
- params.push(options.types);
147
- }
196
+ if (options.types && options.types.length > 0) {
197
+ conditions.push(`type = ANY($${paramIndex++})`);
198
+ params.push(options.types);
199
+ }
148
200
 
149
- if (options.since !== undefined) {
150
- conditions.push(`timestamp >= $${paramIndex++}`);
151
- params.push(options.since);
152
- }
201
+ if (options.since !== undefined) {
202
+ conditions.push(`timestamp >= $${paramIndex++}`);
203
+ params.push(options.since);
204
+ }
153
205
 
154
- if (options.until !== undefined) {
155
- conditions.push(`timestamp <= $${paramIndex++}`);
156
- params.push(options.until);
157
- }
206
+ if (options.until !== undefined) {
207
+ conditions.push(`timestamp <= $${paramIndex++}`);
208
+ params.push(options.until);
209
+ }
158
210
 
159
- if (options.afterSequence !== undefined) {
160
- conditions.push(`sequence > $${paramIndex++}`);
161
- params.push(options.afterSequence);
162
- }
211
+ if (options.afterSequence !== undefined) {
212
+ conditions.push(`sequence > $${paramIndex++}`);
213
+ params.push(options.afterSequence);
214
+ }
163
215
 
164
- const whereClause =
165
- conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
216
+ const whereClause =
217
+ conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
166
218
 
167
- let query = `
168
- SELECT id, type, project_key, timestamp, sequence, data
169
- FROM events
170
- ${whereClause}
171
- ORDER BY sequence ASC
172
- `;
219
+ let query = `
220
+ SELECT id, type, project_key, timestamp, sequence, data
221
+ FROM events
222
+ ${whereClause}
223
+ ORDER BY sequence ASC
224
+ `;
173
225
 
174
- if (options.limit) {
175
- query += ` LIMIT $${paramIndex++}`;
176
- params.push(options.limit);
177
- }
226
+ if (options.limit) {
227
+ query += ` LIMIT $${paramIndex++}`;
228
+ params.push(options.limit);
229
+ }
178
230
 
179
- if (options.offset) {
180
- query += ` OFFSET $${paramIndex++}`;
181
- params.push(options.offset);
182
- }
231
+ if (options.offset) {
232
+ query += ` OFFSET $${paramIndex++}`;
233
+ params.push(options.offset);
234
+ }
183
235
 
184
- const result = await db.query<{
185
- id: number;
186
- type: string;
187
- project_key: string;
188
- timestamp: string;
189
- sequence: number;
190
- data: string;
191
- }>(query, params);
192
-
193
- return result.rows.map((row) => {
194
- const data = typeof row.data === "string" ? JSON.parse(row.data) : row.data;
195
- return {
196
- id: row.id,
197
- type: row.type as AgentEvent["type"],
198
- project_key: row.project_key,
199
- timestamp: parseInt(row.timestamp as string),
200
- sequence: row.sequence,
201
- ...data,
202
- } 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
+ });
203
257
  });
204
258
  }
205
259
 
@@ -238,56 +292,174 @@ export async function replayEvents(
238
292
  } = {},
239
293
  projectPath?: string,
240
294
  ): Promise<{ eventsReplayed: number; duration: number }> {
241
- const startTime = Date.now();
242
- 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
+ }
243
327
 
244
- // Optionally clear materialized views
245
- if (options.clearViews) {
246
- if (options.projectKey) {
247
- // 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) {
248
397
  await db.query(
249
398
  `DELETE FROM message_recipients WHERE message_id IN (
250
399
  SELECT id FROM messages WHERE project_key = $1
251
400
  )`,
252
- [options.projectKey],
401
+ [projectKey],
253
402
  );
254
403
  await db.query(`DELETE FROM messages WHERE project_key = $1`, [
255
- options.projectKey,
404
+ projectKey,
256
405
  ]);
257
406
  await db.query(`DELETE FROM reservations WHERE project_key = $1`, [
258
- options.projectKey,
259
- ]);
260
- await db.query(`DELETE FROM agents WHERE project_key = $1`, [
261
- options.projectKey,
407
+ projectKey,
262
408
  ]);
263
- } else {
264
- await db.exec(`
265
- DELETE FROM message_recipients;
266
- DELETE FROM messages;
267
- DELETE FROM reservations;
268
- DELETE FROM agents;
269
- `);
409
+ await db.query(`DELETE FROM agents WHERE project_key = $1`, [projectKey]);
270
410
  }
271
- }
272
411
 
273
- // Read all events
274
- const events = await readEvents(
275
- {
276
- projectKey: options.projectKey,
277
- afterSequence: options.fromSequence,
278
- },
279
- projectPath,
280
- );
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");
281
418
 
282
- // Replay each event
283
- for (const event of events) {
284
- await updateMaterializedViews(db, event);
285
- }
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
+ }
286
444
 
287
- return {
288
- eventsReplayed: events.length,
289
- duration: Date.now() - startTime,
290
- };
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
+ });
291
463
  }
292
464
 
293
465
  // ============================================================================
@@ -480,6 +652,19 @@ async function handleFileReserved(
480
652
  event.expires_at, // $N+6
481
653
  ];
482
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
+
483
668
  await db.query(
484
669
  `INSERT INTO reservations (project_key, agent_name, path_pattern, exclusive, reason, created_at, expires_at)
485
670
  VALUES ${values}`,