mcp-coordinator 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +846 -846
  2. package/dashboard/Dockerfile +19 -19
  3. package/dashboard/public/index.html +1178 -1178
  4. package/dist/cli/dashboard.js +9 -5
  5. package/dist/cli/server/backup.d.ts +7 -0
  6. package/dist/cli/server/backup.js +162 -0
  7. package/dist/cli/server/index.js +5 -0
  8. package/dist/cli/server/restore.d.ts +2 -0
  9. package/dist/cli/server/restore.js +117 -0
  10. package/dist/cli/server/start.js +24 -1
  11. package/dist/cli/server/status.js +16 -23
  12. package/dist/src/agent-activity.js +6 -6
  13. package/dist/src/agent-registry.js +6 -6
  14. package/dist/src/announce-workflow.d.ts +52 -0
  15. package/dist/src/announce-workflow.js +91 -0
  16. package/dist/src/consultation.d.ts +22 -0
  17. package/dist/src/consultation.js +118 -45
  18. package/dist/src/database.js +126 -126
  19. package/dist/src/db-adapter.d.ts +30 -0
  20. package/dist/src/db-adapter.js +32 -1
  21. package/dist/src/dependency-map.js +5 -5
  22. package/dist/src/file-tracker.d.ts +10 -0
  23. package/dist/src/file-tracker.js +40 -8
  24. package/dist/src/http/handle-health.d.ts +23 -0
  25. package/dist/src/http/handle-health.js +86 -0
  26. package/dist/src/http/handle-rest.d.ts +23 -0
  27. package/dist/src/http/handle-rest.js +374 -0
  28. package/dist/src/http/utils.d.ts +15 -0
  29. package/dist/src/http/utils.js +39 -0
  30. package/dist/src/impact-scorer.js +87 -50
  31. package/dist/src/introspection.js +1 -1
  32. package/dist/src/metrics.d.ts +83 -0
  33. package/dist/src/metrics.js +162 -0
  34. package/dist/src/mqtt-bridge.d.ts +21 -0
  35. package/dist/src/mqtt-bridge.js +55 -5
  36. package/dist/src/mqtt-broker.d.ts +16 -0
  37. package/dist/src/mqtt-broker.js +16 -1
  38. package/dist/src/path-guard.d.ts +14 -0
  39. package/dist/src/path-guard.js +44 -0
  40. package/dist/src/reset-guard.d.ts +16 -0
  41. package/dist/src/reset-guard.js +24 -0
  42. package/dist/src/serve-http.d.ts +31 -1
  43. package/dist/src/serve-http.js +189 -446
  44. package/dist/src/server-setup.d.ts +2 -0
  45. package/dist/src/server-setup.js +25 -366
  46. package/dist/src/sse-emitter.d.ts +6 -0
  47. package/dist/src/sse-emitter.js +50 -2
  48. package/dist/src/tools/agents-tools.d.ts +8 -0
  49. package/dist/src/tools/agents-tools.js +46 -0
  50. package/dist/src/tools/consultation-tools.d.ts +21 -0
  51. package/dist/src/tools/consultation-tools.js +170 -0
  52. package/dist/src/tools/dependencies-tools.d.ts +8 -0
  53. package/dist/src/tools/dependencies-tools.js +27 -0
  54. package/dist/src/tools/files-tools.d.ts +8 -0
  55. package/dist/src/tools/files-tools.js +28 -0
  56. package/dist/src/tools/mqtt-tools.d.ts +9 -0
  57. package/dist/src/tools/mqtt-tools.js +33 -0
  58. package/dist/src/tools/status-tools.d.ts +8 -0
  59. package/dist/src/tools/status-tools.js +63 -0
  60. package/package.json +83 -80
@@ -4,12 +4,45 @@ import { silentLogger } from "./logger.js";
4
4
  export class Consultation {
5
5
  onResolveCallback = null;
6
6
  log;
7
+ timeoutSweeperHandle = null;
7
8
  constructor(logger) {
8
9
  this.log = logger || silentLogger;
9
10
  }
10
11
  onResolve(callback) {
11
12
  this.onResolveCallback = callback;
12
13
  }
14
+ /**
15
+ * B2 fix: replace the side-effect-on-read timeout check with an explicit
16
+ * background sweeper. Each tick atomically claims and resolves any thread
17
+ * past its deadline, then emits resolution events outside the transaction.
18
+ *
19
+ * Default tick interval: 30 seconds. Tests can pass a shorter interval to
20
+ * exercise the sweeper, or call checkTimeouts() explicitly.
21
+ *
22
+ * Safe to call multiple times — second call is a no-op until the previous
23
+ * sweeper is stopped.
24
+ */
25
+ startTimeoutSweeper(intervalMs = 30000) {
26
+ if (this.timeoutSweeperHandle)
27
+ return;
28
+ this.timeoutSweeperHandle = setInterval(() => {
29
+ try {
30
+ this.checkTimeouts();
31
+ }
32
+ catch (err) {
33
+ this.log.warn({ err }, "Timeout sweeper iteration failed");
34
+ }
35
+ }, intervalMs);
36
+ // Don't keep the event loop alive just for the sweeper.
37
+ if (typeof this.timeoutSweeperHandle.unref === "function")
38
+ this.timeoutSweeperHandle.unref();
39
+ }
40
+ stopTimeoutSweeper() {
41
+ if (this.timeoutSweeperHandle) {
42
+ clearInterval(this.timeoutSweeperHandle);
43
+ this.timeoutSweeperHandle = null;
44
+ }
45
+ }
13
46
  emitResolution(threadId, type, approvedBy, approvedByName) {
14
47
  const db = getDb();
15
48
  const thread = this.getThread(threadId);
@@ -39,23 +72,31 @@ export class Consultation {
39
72
  announceWork(params) {
40
73
  const db = getDb();
41
74
  const id = randomUUID();
42
- // Find expected respondents: online agents (not initiator) whose modules overlap
43
- const onlineAgents = db
44
- .prepare("SELECT id, modules FROM agents WHERE status = 'online' AND id != ?")
45
- .all(params.agent_id);
46
- const respondents = onlineAgents.filter((agent) => {
47
- const agentModules = JSON.parse(agent.modules);
48
- return params.target_modules.some((tm) => agentModules.some((am) => am === tm || am.startsWith(tm + "/") || tm.startsWith(am + "/")));
75
+ // B1 fix: SELECT respondents + INSERT thread must be atomic w.r.t. agent
76
+ // registry mutations (registerAgent / setOffline). Without a transaction,
77
+ // a race between announce and a concurrent setOffline produces a thread
78
+ // whose expected_respondents contains an agent that just went away — the
79
+ // thread then stays open forever waiting for an absent voter.
80
+ const tx = db.transaction(() => {
81
+ const onlineAgents = db
82
+ .prepare("SELECT id, modules FROM agents WHERE status = 'online' AND id != ?")
83
+ .all(params.agent_id);
84
+ const respondents = onlineAgents.filter((agent) => {
85
+ const agentModules = JSON.parse(agent.modules);
86
+ return params.target_modules.some((tm) => agentModules.some((am) => am === tm || am.startsWith(tm + "/") || tm.startsWith(am + "/")));
87
+ });
88
+ const respondentIds = respondents.map((r) => r.id);
89
+ // Directed dispatch skips module-based auto-resolve: if the thread is
90
+ // explicitly aimed at an agent, we keep it open for them regardless of
91
+ // what the module scorer finds.
92
+ const assignedTo = params.assigned_to ?? null;
93
+ const keepOpen = params.keep_open || assignedTo !== null;
94
+ const autoResolve = respondentIds.length === 0 && !keepOpen;
95
+ db.prepare(`INSERT INTO threads (id, initiator_id, subject, plan, target_modules, target_files, status, expected_respondents, resolved_at, depends_on_files, exports_affected, timeout_seconds, assigned_to)
96
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, params.agent_id, params.subject, params.plan || null, JSON.stringify(params.target_modules), JSON.stringify(params.target_files), autoResolve ? "resolved" : "open", JSON.stringify(respondentIds), autoResolve ? new Date().toISOString() : null, JSON.stringify(params.depends_on_files || []), JSON.stringify(params.exports_affected || []), keepOpen ? 0 : 600, assignedTo);
97
+ return { autoResolve, respondentIds, assignedTo };
49
98
  });
50
- const respondentIds = respondents.map((r) => r.id);
51
- // Directed dispatch skips module-based auto-resolve: if the thread is
52
- // explicitly aimed at an agent, we keep it open for them regardless of
53
- // what the module scorer finds.
54
- const assignedTo = params.assigned_to ?? null;
55
- const keepOpen = params.keep_open || assignedTo !== null;
56
- const autoResolve = respondentIds.length === 0 && !keepOpen;
57
- db.prepare(`INSERT INTO threads (id, initiator_id, subject, plan, target_modules, target_files, status, expected_respondents, resolved_at, depends_on_files, exports_affected, timeout_seconds, assigned_to)
58
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, params.agent_id, params.subject, params.plan || null, JSON.stringify(params.target_modules), JSON.stringify(params.target_files), autoResolve ? "resolved" : "open", JSON.stringify(respondentIds), autoResolve ? new Date().toISOString() : null, JSON.stringify(params.depends_on_files || []), JSON.stringify(params.exports_affected || []), keepOpen ? 0 : 600, assignedTo);
99
+ const { autoResolve, respondentIds, assignedTo } = tx();
59
100
  this.log.info({
60
101
  thread_id: id,
61
102
  agent_id: params.agent_id,
@@ -81,7 +122,7 @@ export class Consultation {
81
122
  const id = randomUUID();
82
123
  // Simple token estimate: ~4 chars per token for English/French
83
124
  const tokenEstimate = Math.ceil(params.content.length / 4);
84
- db.prepare(`INSERT INTO thread_messages (id, thread_id, agent_id, agent_name, type, content, context_snapshot, in_reply_to, round, token_estimate)
125
+ db.prepare(`INSERT INTO thread_messages (id, thread_id, agent_id, agent_name, type, content, context_snapshot, in_reply_to, round, token_estimate)
85
126
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, params.thread_id, params.agent_id, params.agent_name || null, params.type, params.content, params.context_snapshot || null, params.in_reply_to || null, thread.round, tokenEstimate);
86
127
  this.log.debug({
87
128
  thread_id: params.thread_id,
@@ -109,12 +150,23 @@ export class Consultation {
109
150
  throw new Error(`Thread ${threadId} not found`);
110
151
  if (thread.status !== "resolving")
111
152
  throw new Error(`Thread is ${thread.status}, not resolving`);
112
- // Post approve message
113
- this.postResolutionMessage(threadId, agentId, "approve", "Approved");
153
+ // B1 fix: post + check + transition must be atomic, else two concurrent
154
+ // approvals each see "all approved" and fire emitResolution twice. Using
155
+ // a transaction + UPDATE ... WHERE status='resolving' (CAS) ensures only
156
+ // the first transaction wins the consensus race; the loser's UPDATE
157
+ // affects 0 rows and emit is suppressed.
158
+ const tx = db.transaction(() => {
159
+ this.postResolutionMessage(threadId, agentId, "approve", "Approved");
160
+ if (!this.allRespondentsApproved(threadId))
161
+ return false;
162
+ const res = db
163
+ .prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ? AND status = 'resolving'")
164
+ .run(new Date().toISOString(), threadId);
165
+ return res.changes > 0;
166
+ });
167
+ const wonRace = tx();
114
168
  this.log.debug({ thread_id: threadId, agent_id: agentId }, "Resolution approved");
115
- // Check if all expected respondents have approved
116
- if (this.allRespondentsApproved(threadId)) {
117
- db.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ?").run(new Date().toISOString(), threadId);
169
+ if (wonRace) {
118
170
  this.emitResolution(threadId, "consensus", agentId, agentName || agentId);
119
171
  }
120
172
  }
@@ -194,30 +246,43 @@ export class Consultation {
194
246
  }
195
247
  checkTimeouts() {
196
248
  const db = getDb();
197
- // Get threads that will be timed out (before updating them)
198
- const timedOut = db.prepare(`
199
- SELECT id FROM threads
200
- WHERE status IN ('open', 'resolving')
201
- AND timeout_seconds > 0
202
- AND datetime(created_at, '+' || (timeout_seconds * round) || ' seconds') < CURRENT_TIMESTAMP
203
- `).all();
249
+ // B2 fix: SELECT-then-UPDATE wrapped in a transaction so two concurrent
250
+ // sweepers can't both claim the same thread. The UPDATE returns
251
+ // db.changes (rows affected) — we use the SELECT only to know which
252
+ // thread IDs to emit for. The transaction makes both observe the same
253
+ // snapshot.
254
+ const tx = db.transaction(() => {
255
+ const timedOut = db.prepare(`
256
+ SELECT id FROM threads
257
+ WHERE status IN ('open', 'resolving')
258
+ AND timeout_seconds > 0
259
+ AND datetime(created_at, '+' || (timeout_seconds * round) || ' seconds') < CURRENT_TIMESTAMP
260
+ `).all();
261
+ if (timedOut.length === 0)
262
+ return [];
263
+ db.prepare(`
264
+ UPDATE threads SET status = 'resolved',
265
+ resolution_summary = 'Résolu par timeout — pas de réponse dans le délai',
266
+ resolved_at = CURRENT_TIMESTAMP
267
+ WHERE status IN ('open', 'resolving')
268
+ AND timeout_seconds > 0
269
+ AND datetime(created_at, '+' || (timeout_seconds * round) || ' seconds') < CURRENT_TIMESTAMP
270
+ `).run();
271
+ return timedOut;
272
+ });
273
+ const timedOut = tx();
204
274
  if (timedOut.length === 0)
205
275
  return;
206
- db.prepare(`
207
- UPDATE threads SET status = 'resolved',
208
- resolution_summary = 'Résolu par timeout — pas de réponse dans le délai',
209
- resolved_at = CURRENT_TIMESTAMP
210
- WHERE status IN ('open', 'resolving')
211
- AND timeout_seconds > 0
212
- AND datetime(created_at, '+' || (timeout_seconds * round) || ' seconds') < CURRENT_TIMESTAMP
213
- `).run();
214
276
  this.log.info({ count: timedOut.length, thread_ids: timedOut.map(t => t.id) }, "Threads timed out");
277
+ // Emit OUTSIDE the transaction so listeners can re-enter the DB safely.
215
278
  for (const t of timedOut) {
216
279
  this.emitResolution(t.id, "timeout");
217
280
  }
218
281
  }
219
282
  getThread(threadId) {
220
- this.checkTimeouts();
283
+ // B2 fix: timeout sweeping moved to startTimeoutSweeper() background timer.
284
+ // Reads no longer mutate state. Tests that need synchronous timeout
285
+ // resolution should call checkTimeouts() explicitly.
221
286
  const db = getDb();
222
287
  return (db.prepare("SELECT * FROM threads WHERE id = ?").get(threadId) || null);
223
288
  }
@@ -232,7 +297,7 @@ export class Consultation {
232
297
  return { thread, messages };
233
298
  }
234
299
  listThreads(filters) {
235
- this.checkTimeouts();
300
+ // B2 fix: removed checkTimeouts() side-effect; sweeper handles it.
236
301
  const db = getDb();
237
302
  let sql = "SELECT * FROM threads WHERE 1=1";
238
303
  const params = [];
@@ -255,14 +320,22 @@ export class Consultation {
255
320
  sql += " AND (assigned_to IS NULL OR assigned_to = ?)";
256
321
  params.push(filters.assigned_to_me);
257
322
  }
323
+ if (typeof filters.since_minutes === "number") {
324
+ // For resolved threads, gate on resolved_at (the moment that matters
325
+ // for "recent enough to still influence scoring"). For open/resolving
326
+ // threads, gate on created_at since they have no resolved_at yet.
327
+ // COALESCE picks the right column per row.
328
+ sql += " AND COALESCE(resolved_at, created_at) > datetime('now', '-' || ? || ' minutes')";
329
+ params.push(filters.since_minutes);
330
+ }
258
331
  sql += " ORDER BY created_at DESC";
259
332
  return db.prepare(sql).all(...params);
260
333
  }
261
334
  getThreadUpdates(agentId, since) {
262
335
  const db = getDb();
263
- let sql = `SELECT tm.* FROM thread_messages tm
264
- JOIN threads t ON tm.thread_id = t.id
265
- WHERE t.status IN ('open', 'resolving')
336
+ let sql = `SELECT tm.* FROM thread_messages tm
337
+ JOIN threads t ON tm.thread_id = t.id
338
+ WHERE t.status IN ('open', 'resolving')
266
339
  AND tm.agent_id != ?`;
267
340
  const params = [agentId];
268
341
  if (since) {
@@ -284,7 +357,7 @@ export class Consultation {
284
357
  logActionSummary(params) {
285
358
  const db = getDb();
286
359
  const id = randomUUID();
287
- db.prepare(`INSERT INTO action_summaries (id, session_id, agent_id, file_path, summary)
360
+ db.prepare(`INSERT INTO action_summaries (id, session_id, agent_id, file_path, summary)
288
361
  VALUES (?, ?, ?, ?, ?)`).run(id, params.session_id, params.agent_id, params.file_path || null, params.summary);
289
362
  return db.prepare("SELECT * FROM action_summaries WHERE id = ?").get(id);
290
363
  }
@@ -310,7 +383,7 @@ export class Consultation {
310
383
  const db = getDb();
311
384
  const thread = this.getThread(threadId);
312
385
  const id = randomUUID();
313
- db.prepare(`INSERT INTO thread_messages (id, thread_id, agent_id, type, content, round)
386
+ db.prepare(`INSERT INTO thread_messages (id, thread_id, agent_id, type, content, round)
314
387
  VALUES (?, ?, ?, ?, ?, ?)`).run(id, threadId, agentId, type, content, thread.round);
315
388
  }
316
389
  allRespondentsApproved(threadId) {
@@ -323,7 +396,7 @@ export class Consultation {
323
396
  // increments the round, and prior-round approvals must be re-collected
324
397
  // for the new proposal.
325
398
  const approvals = db
326
- .prepare(`SELECT DISTINCT agent_id FROM thread_messages
399
+ .prepare(`SELECT DISTINCT agent_id FROM thread_messages
327
400
  WHERE thread_id = ? AND type = 'approve' AND round = ?`)
328
401
  .all(threadId, thread.round);
329
402
  const approvedIds = new Set(approvals.map((a) => a.agent_id));
@@ -3,132 +3,132 @@ import { mkdirSync } from "fs";
3
3
  import { createRequire } from "module";
4
4
  const require = createRequire(import.meta.url);
5
5
  let db;
6
- const SCHEMA = `
7
- CREATE TABLE IF NOT EXISTS agents (
8
- id TEXT PRIMARY KEY,
9
- name TEXT NOT NULL,
10
- modules TEXT DEFAULT '[]',
11
- status TEXT DEFAULT 'offline',
12
- registered_at TEXT DEFAULT CURRENT_TIMESTAMP,
13
- last_seen_at TEXT DEFAULT CURRENT_TIMESTAMP
14
- );
15
-
16
- CREATE TABLE IF NOT EXISTS threads (
17
- id TEXT PRIMARY KEY,
18
- initiator_id TEXT NOT NULL,
19
- subject TEXT NOT NULL,
20
- plan TEXT,
21
- target_modules TEXT DEFAULT '[]',
22
- target_files TEXT DEFAULT '[]',
23
- status TEXT DEFAULT 'open',
24
- resolution_summary TEXT,
25
- conflicts TEXT,
26
- round INTEGER DEFAULT 1,
27
- max_rounds INTEGER DEFAULT 4,
28
- timeout_seconds INTEGER DEFAULT 600,
29
- created_at TEXT DEFAULT CURRENT_TIMESTAMP,
30
- resolved_at TEXT,
31
- expected_respondents TEXT,
32
- depends_on_files TEXT,
33
- exports_affected TEXT,
34
- claimed_by TEXT,
35
- claimed_at TEXT,
36
- FOREIGN KEY (initiator_id) REFERENCES agents(id)
37
- );
38
-
39
- CREATE TABLE IF NOT EXISTS thread_messages (
40
- id TEXT PRIMARY KEY,
41
- thread_id TEXT NOT NULL,
42
- agent_id TEXT NOT NULL,
43
- agent_name TEXT,
44
- type TEXT NOT NULL,
45
- content TEXT NOT NULL,
46
- context_snapshot TEXT,
47
- in_reply_to TEXT,
48
- round INTEGER NOT NULL,
49
- token_estimate INTEGER DEFAULT 0,
50
- created_at TEXT DEFAULT CURRENT_TIMESTAMP,
51
- FOREIGN KEY (thread_id) REFERENCES threads(id),
52
- FOREIGN KEY (agent_id) REFERENCES agents(id)
53
- );
54
-
55
- CREATE TABLE IF NOT EXISTS action_summaries (
56
- id TEXT PRIMARY KEY,
57
- session_id TEXT NOT NULL,
58
- agent_id TEXT NOT NULL,
59
- file_path TEXT,
60
- summary TEXT NOT NULL,
61
- created_at TEXT DEFAULT CURRENT_TIMESTAMP,
62
- FOREIGN KEY (agent_id) REFERENCES agents(id)
63
- );
64
-
65
- CREATE TABLE IF NOT EXISTS events (
66
- id INTEGER PRIMARY KEY AUTOINCREMENT,
67
- type TEXT NOT NULL,
68
- payload TEXT NOT NULL,
69
- created_at TEXT DEFAULT CURRENT_TIMESTAMP
70
- );
71
-
72
- CREATE TABLE IF NOT EXISTS dependency_map (
73
- module_id TEXT PRIMARY KEY,
74
- depends_on TEXT DEFAULT '[]',
75
- exports TEXT DEFAULT '[]',
76
- owners TEXT DEFAULT '[]'
77
- );
78
-
79
- CREATE TABLE IF NOT EXISTS file_activity (
80
- id INTEGER PRIMARY KEY AUTOINCREMENT,
81
- session_id TEXT NOT NULL,
82
- agent_id TEXT NOT NULL,
83
- agent_name TEXT,
84
- tool_name TEXT NOT NULL,
85
- file_path TEXT NOT NULL,
86
- module TEXT,
87
- created_at TEXT DEFAULT CURRENT_TIMESTAMP
88
- );
89
-
90
- CREATE INDEX IF NOT EXISTS idx_threads_status ON threads(status);
91
- CREATE INDEX IF NOT EXISTS idx_threads_initiator ON threads(initiator_id);
92
- CREATE INDEX IF NOT EXISTS idx_messages_thread ON thread_messages(thread_id);
93
- CREATE INDEX IF NOT EXISTS idx_messages_agent ON thread_messages(agent_id);
94
- CREATE INDEX IF NOT EXISTS idx_summaries_agent ON action_summaries(agent_id);
95
- CREATE INDEX IF NOT EXISTS idx_summaries_session ON action_summaries(session_id);
96
- CREATE INDEX IF NOT EXISTS idx_events_type ON events(type);
97
- CREATE INDEX IF NOT EXISTS idx_file_activity_agent ON file_activity(agent_id);
98
- CREATE INDEX IF NOT EXISTS idx_file_activity_path ON file_activity(file_path);
99
-
100
- CREATE TABLE IF NOT EXISTS introspections (
101
- id TEXT PRIMARY KEY,
102
- thread_id TEXT NOT NULL,
103
- agent_id TEXT NOT NULL,
104
- score INTEGER NOT NULL,
105
- reasons TEXT,
106
- status TEXT DEFAULT 'pending',
107
- response TEXT,
108
- concerned INTEGER DEFAULT 0,
109
- created_at TEXT DEFAULT CURRENT_TIMESTAMP,
110
- responded_at TEXT,
111
- FOREIGN KEY (thread_id) REFERENCES threads(id),
112
- FOREIGN KEY (agent_id) REFERENCES agents(id)
113
- );
114
-
115
- CREATE INDEX IF NOT EXISTS idx_introspections_agent ON introspections(agent_id);
116
- CREATE INDEX IF NOT EXISTS idx_introspections_status ON introspections(status);
117
-
118
- CREATE TABLE IF NOT EXISTS agent_activity_status (
119
- agent_id TEXT PRIMARY KEY,
120
- activity_status TEXT DEFAULT 'idle',
121
- current_file TEXT,
122
- current_thread TEXT,
123
- last_activity_at TEXT DEFAULT CURRENT_TIMESTAMP,
124
- FOREIGN KEY (agent_id) REFERENCES agents(id)
125
- );
126
-
127
- CREATE TABLE IF NOT EXISTS revoked_agents (
128
- agent_id TEXT PRIMARY KEY,
129
- revoked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
130
- revoked_by TEXT NOT NULL
131
- );
6
+ const SCHEMA = `
7
+ CREATE TABLE IF NOT EXISTS agents (
8
+ id TEXT PRIMARY KEY,
9
+ name TEXT NOT NULL,
10
+ modules TEXT DEFAULT '[]',
11
+ status TEXT DEFAULT 'offline',
12
+ registered_at TEXT DEFAULT CURRENT_TIMESTAMP,
13
+ last_seen_at TEXT DEFAULT CURRENT_TIMESTAMP
14
+ );
15
+
16
+ CREATE TABLE IF NOT EXISTS threads (
17
+ id TEXT PRIMARY KEY,
18
+ initiator_id TEXT NOT NULL,
19
+ subject TEXT NOT NULL,
20
+ plan TEXT,
21
+ target_modules TEXT DEFAULT '[]',
22
+ target_files TEXT DEFAULT '[]',
23
+ status TEXT DEFAULT 'open',
24
+ resolution_summary TEXT,
25
+ conflicts TEXT,
26
+ round INTEGER DEFAULT 1,
27
+ max_rounds INTEGER DEFAULT 4,
28
+ timeout_seconds INTEGER DEFAULT 600,
29
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
30
+ resolved_at TEXT,
31
+ expected_respondents TEXT,
32
+ depends_on_files TEXT,
33
+ exports_affected TEXT,
34
+ claimed_by TEXT,
35
+ claimed_at TEXT,
36
+ FOREIGN KEY (initiator_id) REFERENCES agents(id)
37
+ );
38
+
39
+ CREATE TABLE IF NOT EXISTS thread_messages (
40
+ id TEXT PRIMARY KEY,
41
+ thread_id TEXT NOT NULL,
42
+ agent_id TEXT NOT NULL,
43
+ agent_name TEXT,
44
+ type TEXT NOT NULL,
45
+ content TEXT NOT NULL,
46
+ context_snapshot TEXT,
47
+ in_reply_to TEXT,
48
+ round INTEGER NOT NULL,
49
+ token_estimate INTEGER DEFAULT 0,
50
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
51
+ FOREIGN KEY (thread_id) REFERENCES threads(id),
52
+ FOREIGN KEY (agent_id) REFERENCES agents(id)
53
+ );
54
+
55
+ CREATE TABLE IF NOT EXISTS action_summaries (
56
+ id TEXT PRIMARY KEY,
57
+ session_id TEXT NOT NULL,
58
+ agent_id TEXT NOT NULL,
59
+ file_path TEXT,
60
+ summary TEXT NOT NULL,
61
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
62
+ FOREIGN KEY (agent_id) REFERENCES agents(id)
63
+ );
64
+
65
+ CREATE TABLE IF NOT EXISTS events (
66
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
67
+ type TEXT NOT NULL,
68
+ payload TEXT NOT NULL,
69
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
70
+ );
71
+
72
+ CREATE TABLE IF NOT EXISTS dependency_map (
73
+ module_id TEXT PRIMARY KEY,
74
+ depends_on TEXT DEFAULT '[]',
75
+ exports TEXT DEFAULT '[]',
76
+ owners TEXT DEFAULT '[]'
77
+ );
78
+
79
+ CREATE TABLE IF NOT EXISTS file_activity (
80
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
81
+ session_id TEXT NOT NULL,
82
+ agent_id TEXT NOT NULL,
83
+ agent_name TEXT,
84
+ tool_name TEXT NOT NULL,
85
+ file_path TEXT NOT NULL,
86
+ module TEXT,
87
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
88
+ );
89
+
90
+ CREATE INDEX IF NOT EXISTS idx_threads_status ON threads(status);
91
+ CREATE INDEX IF NOT EXISTS idx_threads_initiator ON threads(initiator_id);
92
+ CREATE INDEX IF NOT EXISTS idx_messages_thread ON thread_messages(thread_id);
93
+ CREATE INDEX IF NOT EXISTS idx_messages_agent ON thread_messages(agent_id);
94
+ CREATE INDEX IF NOT EXISTS idx_summaries_agent ON action_summaries(agent_id);
95
+ CREATE INDEX IF NOT EXISTS idx_summaries_session ON action_summaries(session_id);
96
+ CREATE INDEX IF NOT EXISTS idx_events_type ON events(type);
97
+ CREATE INDEX IF NOT EXISTS idx_file_activity_agent ON file_activity(agent_id);
98
+ CREATE INDEX IF NOT EXISTS idx_file_activity_path ON file_activity(file_path);
99
+
100
+ CREATE TABLE IF NOT EXISTS introspections (
101
+ id TEXT PRIMARY KEY,
102
+ thread_id TEXT NOT NULL,
103
+ agent_id TEXT NOT NULL,
104
+ score INTEGER NOT NULL,
105
+ reasons TEXT,
106
+ status TEXT DEFAULT 'pending',
107
+ response TEXT,
108
+ concerned INTEGER DEFAULT 0,
109
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
110
+ responded_at TEXT,
111
+ FOREIGN KEY (thread_id) REFERENCES threads(id),
112
+ FOREIGN KEY (agent_id) REFERENCES agents(id)
113
+ );
114
+
115
+ CREATE INDEX IF NOT EXISTS idx_introspections_agent ON introspections(agent_id);
116
+ CREATE INDEX IF NOT EXISTS idx_introspections_status ON introspections(status);
117
+
118
+ CREATE TABLE IF NOT EXISTS agent_activity_status (
119
+ agent_id TEXT PRIMARY KEY,
120
+ activity_status TEXT DEFAULT 'idle',
121
+ current_file TEXT,
122
+ current_thread TEXT,
123
+ last_activity_at TEXT DEFAULT CURRENT_TIMESTAMP,
124
+ FOREIGN KEY (agent_id) REFERENCES agents(id)
125
+ );
126
+
127
+ CREATE TABLE IF NOT EXISTS revoked_agents (
128
+ agent_id TEXT PRIMARY KEY,
129
+ revoked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
130
+ revoked_by TEXT NOT NULL
131
+ );
132
132
  `;
133
133
  function createBetterSqlite3(dataDir) {
134
134
  mkdirSync(dataDir, { recursive: true });
@@ -1,3 +1,15 @@
1
+ /**
2
+ * Database adapter surface.
3
+ *
4
+ * Design intent: this file is the *contract* both `createBetterSqlite3` and
5
+ * `createBunSqlite` (in `database.ts`) implement. The interfaces are a strict
6
+ * subset of better-sqlite3's API that Bun:sqlite also satisfies, so callers
7
+ * stay portable across both runtimes.
8
+ *
9
+ * Helpers (e.g. `withTransaction`) live here so portable code paths can use
10
+ * one canonical entry point without each call site re-deriving the
11
+ * `db.transaction(fn)()` two-step pattern.
12
+ */
1
13
  export interface RunResult {
2
14
  changes: number;
3
15
  lastInsertRowid: number;
@@ -13,3 +25,21 @@ export interface DatabaseAdapter {
13
25
  close(): void;
14
26
  transaction<T>(fn: () => T): () => T;
15
27
  }
28
+ /**
29
+ * Run `fn` inside a single SQLite transaction and return its result.
30
+ *
31
+ * Replaces the verbose two-step pattern:
32
+ *
33
+ * const tx = db.transaction(() => { ...; return value; });
34
+ * const value = tx();
35
+ *
36
+ * with:
37
+ *
38
+ * const value = withTransaction(db, () => { ...; return value; });
39
+ *
40
+ * Errors thrown inside `fn` propagate to the caller and the transaction is
41
+ * rolled back by the underlying driver (better-sqlite3 / bun:sqlite both do
42
+ * this). Use this for any read-modify-write block where multiple statements
43
+ * must be atomic.
44
+ */
45
+ export declare function withTransaction<T>(db: DatabaseAdapter, fn: () => T): T;
@@ -1 +1,32 @@
1
- export {};
1
+ /**
2
+ * Database adapter surface.
3
+ *
4
+ * Design intent: this file is the *contract* both `createBetterSqlite3` and
5
+ * `createBunSqlite` (in `database.ts`) implement. The interfaces are a strict
6
+ * subset of better-sqlite3's API that Bun:sqlite also satisfies, so callers
7
+ * stay portable across both runtimes.
8
+ *
9
+ * Helpers (e.g. `withTransaction`) live here so portable code paths can use
10
+ * one canonical entry point without each call site re-deriving the
11
+ * `db.transaction(fn)()` two-step pattern.
12
+ */
13
+ /**
14
+ * Run `fn` inside a single SQLite transaction and return its result.
15
+ *
16
+ * Replaces the verbose two-step pattern:
17
+ *
18
+ * const tx = db.transaction(() => { ...; return value; });
19
+ * const value = tx();
20
+ *
21
+ * with:
22
+ *
23
+ * const value = withTransaction(db, () => { ...; return value; });
24
+ *
25
+ * Errors thrown inside `fn` propagate to the caller and the transaction is
26
+ * rolled back by the underlying driver (better-sqlite3 / bun:sqlite both do
27
+ * this). Use this for any read-modify-write block where multiple statements
28
+ * must be atomic.
29
+ */
30
+ export function withTransaction(db, fn) {
31
+ return db.transaction(fn)();
32
+ }
@@ -1,4 +1,5 @@
1
1
  import { getDb } from "./database.js";
2
+ import { withTransaction } from "./db-adapter.js";
2
3
  export class DependencyMapper {
3
4
  getMap() {
4
5
  const db = getDb();
@@ -16,16 +17,15 @@ export class DependencyMapper {
16
17
  }
17
18
  setMap(map) {
18
19
  const db = getDb();
19
- const stmt = db.prepare(`INSERT INTO dependency_map (module_id, depends_on, exports, owners)
20
- VALUES (?, ?, ?, ?)
21
- ON CONFLICT(module_id) DO UPDATE SET
20
+ const stmt = db.prepare(`INSERT INTO dependency_map (module_id, depends_on, exports, owners)
21
+ VALUES (?, ?, ?, ?)
22
+ ON CONFLICT(module_id) DO UPDATE SET
22
23
  depends_on = excluded.depends_on, exports = excluded.exports, owners = excluded.owners`);
23
- const tx = db.transaction(() => {
24
+ withTransaction(db, () => {
24
25
  for (const [id, info] of Object.entries(map)) {
25
26
  stmt.run(id, JSON.stringify(info.depends_on), JSON.stringify(info.exports), JSON.stringify(info.owners));
26
27
  }
27
28
  });
28
- tx();
29
29
  }
30
30
  getModuleInfo(moduleId) {
31
31
  const db = getDb();
@@ -17,5 +17,15 @@ export declare class FileTracker {
17
17
  conflict: boolean;
18
18
  agents: string[];
19
19
  };
20
+ /**
21
+ * P2 perf: batch lookup of recent file→agents activity. Replaces N
22
+ * `checkFileConflict` calls (one per file) with a single SQL query, then
23
+ * builds an in-memory reverse index. The impact scorer uses this so its
24
+ * per-file inner loop is O(1) Map.get() rather than O(F) SQL round-trips.
25
+ *
26
+ * Excludes the calling agent (so the scorer doesn't flag the announcer
27
+ * against themselves). Returns Map<file_path, Set<agent_id>>.
28
+ */
29
+ getFileToAgentsIndex(filePaths: string[], excludeAgentId: string, withinMinutes?: number): Map<string, Set<string>>;
20
30
  fileToModule(filePath: string): string;
21
31
  }