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.
- package/.beads/issues.jsonl +95 -85
- package/.github/workflows/ci.yml +5 -1
- package/README.md +48 -4
- package/dist/index.js +2652 -2080
- package/dist/plugin.js +2302 -1725
- package/package.json +1 -1
- package/src/agent-mail.ts +13 -0
- package/src/anti-patterns.test.ts +1167 -0
- package/src/anti-patterns.ts +29 -11
- package/src/learning.ts +106 -0
- package/src/pattern-maturity.ts +51 -13
- package/src/plugin.ts +15 -3
- package/src/rate-limiter.ts +48 -4
- package/src/schemas/bead.ts +35 -4
- package/src/schemas/evaluation.ts +18 -6
- package/src/schemas/index.ts +25 -2
- package/src/schemas/task.ts +49 -21
- package/src/streams/debug.ts +101 -3
- package/src/streams/index.ts +87 -1
- package/src/streams/migrations.ts +46 -4
- package/src/streams/projections.ts +15 -0
- package/src/streams/store.integration.test.ts +110 -0
- package/src/streams/store.ts +447 -193
- package/src/structured.test.ts +1046 -0
- package/src/structured.ts +74 -27
- package/src/swarm-decompose.ts +912 -0
- package/src/swarm-orchestrate.ts +1869 -0
- package/src/swarm-prompts.ts +756 -0
- package/src/swarm-strategies.ts +407 -0
- package/src/swarm.ts +23 -3639
- package/src/tool-availability.ts +29 -6
- package/test-bug-fixes.ts +86 -0
package/src/streams/store.ts
CHANGED
|
@@ -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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
+
return withTiming("readEvents", async () => {
|
|
185
|
+
const db = await getDatabase(projectPath);
|
|
115
186
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
187
|
+
const conditions: string[] = [];
|
|
188
|
+
const params: unknown[] = [];
|
|
189
|
+
let paramIndex = 1;
|
|
119
190
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
191
|
+
if (options.projectKey) {
|
|
192
|
+
conditions.push(`project_key = $${paramIndex++}`);
|
|
193
|
+
params.push(options.projectKey);
|
|
194
|
+
}
|
|
124
195
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
196
|
+
if (options.types && options.types.length > 0) {
|
|
197
|
+
conditions.push(`type = ANY($${paramIndex++})`);
|
|
198
|
+
params.push(options.types);
|
|
199
|
+
}
|
|
129
200
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
201
|
+
if (options.since !== undefined) {
|
|
202
|
+
conditions.push(`timestamp >= $${paramIndex++}`);
|
|
203
|
+
params.push(options.since);
|
|
204
|
+
}
|
|
134
205
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
206
|
+
if (options.until !== undefined) {
|
|
207
|
+
conditions.push(`timestamp <= $${paramIndex++}`);
|
|
208
|
+
params.push(options.until);
|
|
209
|
+
}
|
|
139
210
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
211
|
+
if (options.afterSequence !== undefined) {
|
|
212
|
+
conditions.push(`sequence > $${paramIndex++}`);
|
|
213
|
+
params.push(options.afterSequence);
|
|
214
|
+
}
|
|
144
215
|
|
|
145
|
-
|
|
146
|
-
|
|
216
|
+
const whereClause =
|
|
217
|
+
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
147
218
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
226
|
+
if (options.limit) {
|
|
227
|
+
query += ` LIMIT $${paramIndex++}`;
|
|
228
|
+
params.push(options.limit);
|
|
229
|
+
}
|
|
159
230
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
231
|
+
if (options.offset) {
|
|
232
|
+
query += ` OFFSET $${paramIndex++}`;
|
|
233
|
+
params.push(options.offset);
|
|
234
|
+
}
|
|
164
235
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
[
|
|
401
|
+
[projectKey],
|
|
234
402
|
);
|
|
235
403
|
await db.query(`DELETE FROM messages WHERE project_key = $1`, [
|
|
236
|
-
|
|
404
|
+
projectKey,
|
|
237
405
|
]);
|
|
238
406
|
await db.query(`DELETE FROM reservations WHERE project_key = $1`, [
|
|
239
|
-
|
|
240
|
-
]);
|
|
241
|
-
await db.query(`DELETE FROM agents WHERE project_key = $1`, [
|
|
242
|
-
options.projectKey,
|
|
407
|
+
projectKey,
|
|
243
408
|
]);
|
|
244
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
projectKey
|
|
258
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
//
|
|
396
|
-
|
|
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
|
|
609
|
+
VALUES ${values}
|
|
400
610
|
ON CONFLICT DO NOTHING`,
|
|
401
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|