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