mcp-coordinator 0.6.1 → 0.7.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 +24 -0
- package/dist/src/agent-activity.d.ts +13 -9
- package/dist/src/agent-activity.js +45 -24
- package/dist/src/agent-registry.d.ts +7 -7
- package/dist/src/agent-registry.js +19 -18
- package/dist/src/announce-workflow.d.ts +1 -0
- package/dist/src/announce-workflow.js +13 -12
- package/dist/src/auth/providers/registry.d.ts +4 -0
- package/dist/src/auth/providers/registry.js +7 -0
- package/dist/src/auth/providers/types.d.ts +11 -0
- package/dist/src/auth/providers/types.js +1 -0
- package/dist/src/auth.d.ts +24 -5
- package/dist/src/auth.js +172 -23
- package/dist/src/conflict-detector.d.ts +1 -0
- package/dist/src/conflict-detector.js +4 -4
- package/dist/src/consultation.d.ts +28 -14
- package/dist/src/consultation.js +101 -68
- package/dist/src/context-provider.d.ts +2 -2
- package/dist/src/context-provider.js +3 -4
- package/dist/src/database.js +203 -4
- package/dist/src/dependency-map.d.ts +25 -4
- package/dist/src/dependency-map.js +49 -11
- package/dist/src/file-tracker.d.ts +5 -4
- package/dist/src/file-tracker.js +16 -14
- package/dist/src/git-cochange-builder.d.ts +11 -2
- package/dist/src/git-cochange-builder.js +15 -7
- package/dist/src/http/handle-health.d.ts +9 -5
- package/dist/src/http/handle-health.js +22 -8
- package/dist/src/http/handle-rest.d.ts +3 -0
- package/dist/src/http/handle-rest.js +56 -55
- package/dist/src/http/utils.d.ts +4 -0
- package/dist/src/http/utils.js +7 -1
- package/dist/src/impact-scorer.d.ts +3 -0
- package/dist/src/impact-scorer.js +65 -51
- package/dist/src/introspection.d.ts +13 -7
- package/dist/src/introspection.js +34 -11
- package/dist/src/metrics.js +2 -1
- package/dist/src/mqtt-bridge.d.ts +3 -2
- package/dist/src/mqtt-bridge.js +33 -23
- package/dist/src/mqtt-broker.d.ts +16 -7
- package/dist/src/mqtt-broker.js +57 -15
- package/dist/src/security/audit.d.ts +11 -0
- package/dist/src/security/audit.js +7 -0
- package/dist/src/security/encryption.d.ts +17 -0
- package/dist/src/security/encryption.js +5 -0
- package/dist/src/serve-http.js +136 -57
- package/dist/src/server-setup.d.ts +12 -2
- package/dist/src/server-setup.js +33 -15
- package/dist/src/sse-emitter.d.ts +7 -4
- package/dist/src/sse-emitter.js +27 -21
- package/dist/src/tools/agents-tools.d.ts +2 -1
- package/dist/src/tools/agents-tools.js +36 -12
- package/dist/src/tools/consultation-tools.d.ts +2 -1
- package/dist/src/tools/consultation-tools.js +102 -36
- package/dist/src/tools/dependencies-tools.d.ts +2 -1
- package/dist/src/tools/dependencies-tools.js +25 -7
- package/dist/src/tools/files-tools.d.ts +2 -1
- package/dist/src/tools/files-tools.js +25 -7
- package/dist/src/tools/mqtt-tools.d.ts +7 -1
- package/dist/src/tools/mqtt-tools.js +27 -4
- package/dist/src/tools/status-tools.d.ts +7 -1
- package/dist/src/tools/status-tools.js +26 -9
- package/dist/src/types.d.ts +2 -0
- package/dist/src/working-files-tracker.d.ts +21 -11
- package/dist/src/working-files-tracker.js +32 -21
- package/package.json +1 -1
package/dist/src/consultation.js
CHANGED
|
@@ -45,7 +45,10 @@ export class Consultation {
|
|
|
45
45
|
}
|
|
46
46
|
emitResolution(threadId, type, approvedBy, approvedByName) {
|
|
47
47
|
const db = getDb();
|
|
48
|
-
|
|
48
|
+
// emitResolution is called internally after we already know the thread belongs
|
|
49
|
+
// to the right org. We look it up cross-org here intentionally so that
|
|
50
|
+
// handleAgentDeparture (which is cross-org) can still emit resolution events.
|
|
51
|
+
const thread = this.getThreadCrossOrg(threadId);
|
|
49
52
|
if (!thread)
|
|
50
53
|
return;
|
|
51
54
|
const messageCount = db.prepare("SELECT COUNT(*) as count FROM thread_messages WHERE thread_id = ?").get(threadId).count;
|
|
@@ -59,6 +62,7 @@ export class Consultation {
|
|
|
59
62
|
if (this.onResolveCallback) {
|
|
60
63
|
this.onResolveCallback({
|
|
61
64
|
thread_id: threadId,
|
|
65
|
+
org_id: thread.org_id,
|
|
62
66
|
resolution_type: type,
|
|
63
67
|
resolution_summary: thread.resolution_summary,
|
|
64
68
|
created_at: thread.created_at,
|
|
@@ -69,7 +73,7 @@ export class Consultation {
|
|
|
69
73
|
});
|
|
70
74
|
}
|
|
71
75
|
}
|
|
72
|
-
announceWork(params) {
|
|
76
|
+
announceWork(orgId, params) {
|
|
73
77
|
const db = getDb();
|
|
74
78
|
const id = randomUUID();
|
|
75
79
|
// B1 fix: SELECT respondents + INSERT thread must be atomic w.r.t. agent
|
|
@@ -79,8 +83,8 @@ export class Consultation {
|
|
|
79
83
|
// thread then stays open forever waiting for an absent voter.
|
|
80
84
|
const tx = db.transaction(() => {
|
|
81
85
|
const onlineAgents = db
|
|
82
|
-
.prepare("SELECT id, modules FROM agents WHERE status = 'online' AND id != ?")
|
|
83
|
-
.all(params.agent_id);
|
|
86
|
+
.prepare("SELECT id, modules FROM agents WHERE status = 'online' AND id != ? AND org_id = ?")
|
|
87
|
+
.all(params.agent_id, orgId);
|
|
84
88
|
const respondents = onlineAgents.filter((agent) => {
|
|
85
89
|
const agentModules = JSON.parse(agent.modules);
|
|
86
90
|
return params.target_modules.some((tm) => agentModules.some((am) => am === tm || am.startsWith(tm + "/") || tm.startsWith(am + "/")));
|
|
@@ -92,13 +96,14 @@ export class Consultation {
|
|
|
92
96
|
const assignedTo = params.assigned_to ?? null;
|
|
93
97
|
const keepOpen = params.keep_open || assignedTo !== null;
|
|
94
98
|
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);
|
|
99
|
+
db.prepare(`INSERT INTO threads (id, org_id, initiator_id, subject, plan, target_modules, target_files, status, expected_respondents, resolved_at, depends_on_files, exports_affected, timeout_seconds, assigned_to)
|
|
100
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, orgId, 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
101
|
return { autoResolve, respondentIds, assignedTo };
|
|
98
102
|
});
|
|
99
103
|
const { autoResolve, respondentIds, assignedTo } = tx();
|
|
100
104
|
this.log.info({
|
|
101
105
|
thread_id: id,
|
|
106
|
+
org_id: orgId,
|
|
102
107
|
agent_id: params.agent_id,
|
|
103
108
|
subject: params.subject,
|
|
104
109
|
target_modules: params.target_modules,
|
|
@@ -106,11 +111,11 @@ export class Consultation {
|
|
|
106
111
|
respondent_count: respondentIds.length,
|
|
107
112
|
assigned_to: assignedTo,
|
|
108
113
|
}, "Thread opened");
|
|
109
|
-
return this.getThread(id);
|
|
114
|
+
return this.getThread(orgId, id);
|
|
110
115
|
}
|
|
111
|
-
postToThread(params) {
|
|
116
|
+
postToThread(orgId, params) {
|
|
112
117
|
const db = getDb();
|
|
113
|
-
const thread = this.getThread(params.thread_id);
|
|
118
|
+
const thread = this.getThread(orgId, params.thread_id);
|
|
114
119
|
if (!thread)
|
|
115
120
|
throw new Error(`Thread ${params.thread_id} not found`);
|
|
116
121
|
// Cancelled threads are explicit aborts — reject posts.
|
|
@@ -122,8 +127,8 @@ export class Consultation {
|
|
|
122
127
|
const id = randomUUID();
|
|
123
128
|
// Simple token estimate: ~4 chars per token for English/French
|
|
124
129
|
const tokenEstimate = Math.ceil(params.content.length / 4);
|
|
125
|
-
db.prepare(`INSERT INTO thread_messages (id, thread_id, agent_id, agent_name, type, content, context_snapshot, in_reply_to, round, token_estimate)
|
|
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);
|
|
130
|
+
db.prepare(`INSERT INTO thread_messages (id, org_id, thread_id, agent_id, agent_name, type, content, context_snapshot, in_reply_to, round, token_estimate)
|
|
131
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, orgId, 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);
|
|
127
132
|
this.log.debug({
|
|
128
133
|
thread_id: params.thread_id,
|
|
129
134
|
agent_id: params.agent_id,
|
|
@@ -132,20 +137,20 @@ export class Consultation {
|
|
|
132
137
|
}, "Message posted to thread");
|
|
133
138
|
return db.prepare("SELECT * FROM thread_messages WHERE id = ?").get(id);
|
|
134
139
|
}
|
|
135
|
-
proposeResolution(threadId, agentId, summary) {
|
|
140
|
+
proposeResolution(orgId, threadId, agentId, summary) {
|
|
136
141
|
const db = getDb();
|
|
137
|
-
const thread = this.getThread(threadId);
|
|
142
|
+
const thread = this.getThread(orgId, threadId);
|
|
138
143
|
if (!thread)
|
|
139
144
|
throw new Error(`Thread ${threadId} not found`);
|
|
140
145
|
if (thread.initiator_id !== agentId && thread.claimed_by !== agentId)
|
|
141
146
|
throw new Error("Only the initiator or the claimant can propose a resolution");
|
|
142
|
-
db.prepare("UPDATE threads SET status = 'resolving', resolution_summary = ? WHERE id = ?").run(summary, threadId);
|
|
147
|
+
db.prepare("UPDATE threads SET status = 'resolving', resolution_summary = ? WHERE id = ? AND org_id = ?").run(summary, threadId, orgId);
|
|
143
148
|
// Post resolution message
|
|
144
|
-
this.postResolutionMessage(threadId, agentId, "resolution", summary);
|
|
149
|
+
this.postResolutionMessage(orgId, threadId, agentId, "resolution", summary);
|
|
145
150
|
}
|
|
146
|
-
approveResolution(threadId, agentId, agentName) {
|
|
151
|
+
approveResolution(orgId, threadId, agentId, agentName) {
|
|
147
152
|
const db = getDb();
|
|
148
|
-
const thread = this.getThread(threadId);
|
|
153
|
+
const thread = this.getThread(orgId, threadId);
|
|
149
154
|
if (!thread)
|
|
150
155
|
throw new Error(`Thread ${threadId} not found`);
|
|
151
156
|
if (thread.status !== "resolving")
|
|
@@ -156,12 +161,12 @@ export class Consultation {
|
|
|
156
161
|
// the first transaction wins the consensus race; the loser's UPDATE
|
|
157
162
|
// affects 0 rows and emit is suppressed.
|
|
158
163
|
const tx = db.transaction(() => {
|
|
159
|
-
this.postResolutionMessage(threadId, agentId, "approve", "Approved");
|
|
160
|
-
if (!this.allRespondentsApproved(threadId))
|
|
164
|
+
this.postResolutionMessage(orgId, threadId, agentId, "approve", "Approved");
|
|
165
|
+
if (!this.allRespondentsApproved(orgId, threadId))
|
|
161
166
|
return false;
|
|
162
167
|
const res = db
|
|
163
|
-
.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ? AND status = 'resolving'")
|
|
164
|
-
.run(new Date().toISOString(), threadId);
|
|
168
|
+
.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ? AND org_id = ? AND status = 'resolving'")
|
|
169
|
+
.run(new Date().toISOString(), threadId, orgId);
|
|
165
170
|
return res.changes > 0;
|
|
166
171
|
});
|
|
167
172
|
const wonRace = tx();
|
|
@@ -170,42 +175,42 @@ export class Consultation {
|
|
|
170
175
|
this.emitResolution(threadId, "consensus", agentId, agentName || agentId);
|
|
171
176
|
}
|
|
172
177
|
}
|
|
173
|
-
contestResolution(threadId, agentId, reason) {
|
|
178
|
+
contestResolution(orgId, threadId, agentId, reason) {
|
|
174
179
|
const db = getDb();
|
|
175
|
-
const thread = this.getThread(threadId);
|
|
180
|
+
const thread = this.getThread(orgId, threadId);
|
|
176
181
|
if (!thread)
|
|
177
182
|
throw new Error(`Thread ${threadId} not found`);
|
|
178
183
|
if (thread.status !== "resolving")
|
|
179
184
|
throw new Error(`Thread is ${thread.status}, not resolving`);
|
|
180
185
|
// Post contest message
|
|
181
|
-
this.postResolutionMessage(threadId, agentId, "contest", reason);
|
|
186
|
+
this.postResolutionMessage(orgId, threadId, agentId, "contest", reason);
|
|
182
187
|
this.log.debug({ thread_id: threadId, agent_id: agentId, reason }, "Resolution contested");
|
|
183
188
|
// Return to open with next round
|
|
184
189
|
const nextRound = thread.round + 1;
|
|
185
190
|
if (nextRound > thread.max_rounds) {
|
|
186
191
|
// Max rounds reached — force resolve
|
|
187
|
-
db.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ?").run(new Date().toISOString(), threadId);
|
|
192
|
+
db.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ? AND org_id = ?").run(new Date().toISOString(), threadId, orgId);
|
|
188
193
|
this.emitResolution(threadId, "max_rounds");
|
|
189
194
|
}
|
|
190
195
|
else {
|
|
191
|
-
db.prepare("UPDATE threads SET status = 'open', round = ?, resolution_summary = NULL WHERE id = ?").run(nextRound, threadId);
|
|
196
|
+
db.prepare("UPDATE threads SET status = 'open', round = ?, resolution_summary = NULL WHERE id = ? AND org_id = ?").run(nextRound, threadId, orgId);
|
|
192
197
|
}
|
|
193
198
|
}
|
|
194
|
-
cancelThread(threadId, agentId, reason) {
|
|
199
|
+
cancelThread(orgId, threadId, agentId, reason) {
|
|
195
200
|
const db = getDb();
|
|
196
|
-
const thread = this.getThread(threadId);
|
|
201
|
+
const thread = this.getThread(orgId, threadId);
|
|
197
202
|
if (!thread)
|
|
198
203
|
throw new Error(`Thread ${threadId} not found`);
|
|
199
204
|
if (thread.initiator_id !== agentId)
|
|
200
205
|
throw new Error("Only the initiator can cancel");
|
|
201
|
-
db.prepare("UPDATE threads SET status = 'cancelled', resolved_at = ? WHERE id = ?").run(new Date().toISOString(), threadId);
|
|
206
|
+
db.prepare("UPDATE threads SET status = 'cancelled', resolved_at = ? WHERE id = ? AND org_id = ?").run(new Date().toISOString(), threadId, orgId);
|
|
202
207
|
if (reason) {
|
|
203
|
-
this.postResolutionMessage(threadId, agentId, "context", `Cancelled: ${reason}`);
|
|
208
|
+
this.postResolutionMessage(orgId, threadId, agentId, "context", `Cancelled: ${reason}`);
|
|
204
209
|
}
|
|
205
210
|
}
|
|
206
|
-
closeThread(threadId, agentId, summary) {
|
|
211
|
+
closeThread(orgId, threadId, agentId, summary) {
|
|
207
212
|
const db = getDb();
|
|
208
|
-
const thread = this.getThread(threadId);
|
|
213
|
+
const thread = this.getThread(orgId, threadId);
|
|
209
214
|
if (!thread) {
|
|
210
215
|
throw new Error(`Thread ${threadId} not found`);
|
|
211
216
|
}
|
|
@@ -215,35 +220,54 @@ export class Consultation {
|
|
|
215
220
|
if (thread.status !== "open" && thread.status !== "resolving") {
|
|
216
221
|
throw new Error(`Cannot close thread ${threadId} in status '${thread.status}'`);
|
|
217
222
|
}
|
|
218
|
-
db.prepare("UPDATE threads SET status = 'resolved', resolution_summary = ?, resolved_at = ? WHERE id = ?").run(summary, new Date().toISOString(), threadId);
|
|
223
|
+
db.prepare("UPDATE threads SET status = 'resolved', resolution_summary = ?, resolved_at = ? WHERE id = ? AND org_id = ?").run(summary, new Date().toISOString(), threadId, orgId);
|
|
219
224
|
this.emitResolution(threadId, "closed");
|
|
220
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* Cross-org maintenance sweep — stays at v0.6 signature per Phase 1 plan.
|
|
228
|
+
* handleAgentDeparture iterates ALL orgs (internal maintenance only).
|
|
229
|
+
*/
|
|
221
230
|
handleAgentDeparture(agentId) {
|
|
222
231
|
const db = getDb();
|
|
223
|
-
// Unclaim any tasks claimed by the departing agent
|
|
232
|
+
// Unclaim any tasks claimed by the departing agent.
|
|
233
|
+
//
|
|
234
|
+
// INTENTIONALLY cross-org: this method is invoked from the MQTT-bridge
|
|
235
|
+
// disconnect handler, which has no org context (Phase 1: MQTT topics carry
|
|
236
|
+
// no org_id yet — see Task 22 follow-up). If two orgs ever happen to
|
|
237
|
+
// register agents under the same agent_id string, an MQTT disconnect for
|
|
238
|
+
// that string will release claims in BOTH orgs. Phase 2 multi-org rollout
|
|
239
|
+
// must thread org from the MQTT topic before this becomes load-bearing.
|
|
224
240
|
db.prepare("UPDATE threads SET claimed_by = NULL, claimed_at = NULL WHERE claimed_by = ? AND status = 'open'")
|
|
225
241
|
.run(agentId);
|
|
226
|
-
// Remove departed agent from expected_respondents of all open/resolving threads
|
|
242
|
+
// Remove departed agent from expected_respondents of all open/resolving threads.
|
|
243
|
+
// We iterate cross-org (no WHERE org_id filter on the SELECT) for the same
|
|
244
|
+
// reason — agentId is the only key we have from the MQTT bridge — but every
|
|
245
|
+
// point-update below is scoped by the row's own org_id (read from the SELECT)
|
|
246
|
+
// as defense in depth against bare id collisions.
|
|
227
247
|
const threads = db
|
|
228
|
-
.prepare("SELECT id, expected_respondents FROM threads WHERE status IN ('open', 'resolving')")
|
|
248
|
+
.prepare("SELECT id, org_id, expected_respondents FROM threads WHERE status IN ('open', 'resolving')")
|
|
229
249
|
.all();
|
|
230
250
|
for (const thread of threads) {
|
|
231
251
|
const respondents = JSON.parse(thread.expected_respondents || "[]");
|
|
232
252
|
const updated = respondents.filter((r) => r !== agentId);
|
|
233
|
-
db.prepare("UPDATE threads SET expected_respondents = ? WHERE id = ?").run(JSON.stringify(updated), thread.id);
|
|
253
|
+
db.prepare("UPDATE threads SET expected_respondents = ? WHERE id = ? AND org_id = ?").run(JSON.stringify(updated), thread.id, thread.org_id);
|
|
234
254
|
// If resolving and all remaining approved, resolve
|
|
235
|
-
const t = this.
|
|
236
|
-
if (t.status === "resolving" && this.allRespondentsApproved(thread.id)) {
|
|
237
|
-
db.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ?").run(new Date().toISOString(), thread.id);
|
|
255
|
+
const t = this.getThreadCrossOrg(thread.id);
|
|
256
|
+
if (t.status === "resolving" && this.allRespondentsApproved(thread.org_id, thread.id)) {
|
|
257
|
+
db.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ? AND org_id = ?").run(new Date().toISOString(), thread.id, thread.org_id);
|
|
238
258
|
this.emitResolution(thread.id, "agent_departure");
|
|
239
259
|
}
|
|
240
260
|
// If open and no respondents left, auto-resolve
|
|
241
261
|
if (t.status === "open" && updated.length === 0) {
|
|
242
|
-
db.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ?").run(new Date().toISOString(), thread.id);
|
|
262
|
+
db.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ? AND org_id = ?").run(new Date().toISOString(), thread.id, thread.org_id);
|
|
243
263
|
this.emitResolution(thread.id, "agent_departure");
|
|
244
264
|
}
|
|
245
265
|
}
|
|
246
266
|
}
|
|
267
|
+
/**
|
|
268
|
+
* Cross-org sweeper — stays at v0.6 signature per Phase 1 plan.
|
|
269
|
+
* checkTimeouts scans ALL orgs (internal maintenance only).
|
|
270
|
+
*/
|
|
247
271
|
checkTimeouts() {
|
|
248
272
|
const db = getDb();
|
|
249
273
|
// B2 fix: SELECT-then-UPDATE wrapped in a transaction so two concurrent
|
|
@@ -279,28 +303,28 @@ export class Consultation {
|
|
|
279
303
|
this.emitResolution(t.id, "timeout");
|
|
280
304
|
}
|
|
281
305
|
}
|
|
282
|
-
getThread(threadId) {
|
|
306
|
+
getThread(orgId, threadId) {
|
|
283
307
|
// B2 fix: timeout sweeping moved to startTimeoutSweeper() background timer.
|
|
284
308
|
// Reads no longer mutate state. Tests that need synchronous timeout
|
|
285
309
|
// resolution should call checkTimeouts() explicitly.
|
|
286
310
|
const db = getDb();
|
|
287
|
-
return (db.prepare("SELECT * FROM threads WHERE id = ?").get(threadId) || null);
|
|
311
|
+
return (db.prepare("SELECT * FROM threads WHERE id = ? AND org_id = ?").get(threadId, orgId) || null);
|
|
288
312
|
}
|
|
289
|
-
getThreadWithMessages(threadId) {
|
|
290
|
-
const thread = this.getThread(threadId);
|
|
313
|
+
getThreadWithMessages(orgId, threadId) {
|
|
314
|
+
const thread = this.getThread(orgId, threadId);
|
|
291
315
|
if (!thread)
|
|
292
316
|
return null;
|
|
293
317
|
const db = getDb();
|
|
294
318
|
const messages = db
|
|
295
|
-
.prepare("SELECT
|
|
296
|
-
.all(threadId);
|
|
319
|
+
.prepare("SELECT tm.* FROM thread_messages tm JOIN threads t ON tm.thread_id = t.id WHERE tm.thread_id = ? AND t.org_id = ? ORDER BY tm.created_at")
|
|
320
|
+
.all(threadId, orgId);
|
|
297
321
|
return { thread, messages };
|
|
298
322
|
}
|
|
299
|
-
listThreads(filters) {
|
|
323
|
+
listThreads(orgId, filters) {
|
|
300
324
|
// B2 fix: removed checkTimeouts() side-effect; sweeper handles it.
|
|
301
325
|
const db = getDb();
|
|
302
|
-
let sql = "SELECT * FROM threads WHERE
|
|
303
|
-
const params = [];
|
|
326
|
+
let sql = "SELECT * FROM threads WHERE org_id = ?";
|
|
327
|
+
const params = [orgId];
|
|
304
328
|
if (filters.status) {
|
|
305
329
|
sql += " AND status = ?";
|
|
306
330
|
params.push(filters.status);
|
|
@@ -331,13 +355,14 @@ export class Consultation {
|
|
|
331
355
|
sql += " ORDER BY created_at DESC";
|
|
332
356
|
return db.prepare(sql).all(...params);
|
|
333
357
|
}
|
|
334
|
-
getThreadUpdates(agentId, since) {
|
|
358
|
+
getThreadUpdates(orgId, agentId, since) {
|
|
335
359
|
const db = getDb();
|
|
336
360
|
let sql = `SELECT tm.* FROM thread_messages tm
|
|
337
361
|
JOIN threads t ON tm.thread_id = t.id
|
|
338
|
-
WHERE t.
|
|
362
|
+
WHERE t.org_id = ?
|
|
363
|
+
AND t.status IN ('open', 'resolving')
|
|
339
364
|
AND tm.agent_id != ?`;
|
|
340
|
-
const params = [agentId];
|
|
365
|
+
const params = [orgId, agentId];
|
|
341
366
|
if (since) {
|
|
342
367
|
sql += " AND tm.created_at >= ?";
|
|
343
368
|
// Normalize ANY parseable ISO/date string (including timezone offsets
|
|
@@ -354,17 +379,17 @@ export class Consultation {
|
|
|
354
379
|
sql += " ORDER BY tm.created_at";
|
|
355
380
|
return db.prepare(sql).all(...params);
|
|
356
381
|
}
|
|
357
|
-
logActionSummary(params) {
|
|
382
|
+
logActionSummary(orgId, params) {
|
|
358
383
|
const db = getDb();
|
|
359
384
|
const id = randomUUID();
|
|
360
|
-
db.prepare(`INSERT INTO action_summaries (id, session_id, agent_id, file_path, summary)
|
|
361
|
-
VALUES (?, ?, ?, ?, ?)`).run(id, params.session_id, params.agent_id, params.file_path || null, params.summary);
|
|
385
|
+
db.prepare(`INSERT INTO action_summaries (id, org_id, session_id, agent_id, file_path, summary)
|
|
386
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(id, orgId, params.session_id, params.agent_id, params.file_path || null, params.summary);
|
|
362
387
|
return db.prepare("SELECT * FROM action_summaries WHERE id = ?").get(id);
|
|
363
388
|
}
|
|
364
|
-
getActionSummaries(agentId, since) {
|
|
389
|
+
getActionSummaries(orgId, agentId, since) {
|
|
365
390
|
const db = getDb();
|
|
366
|
-
let sql = "SELECT * FROM action_summaries WHERE agent_id = ?";
|
|
367
|
-
const params = [agentId];
|
|
391
|
+
let sql = "SELECT * FROM action_summaries WHERE org_id = ? AND agent_id = ?";
|
|
392
|
+
const params = [orgId, agentId];
|
|
368
393
|
if (since) {
|
|
369
394
|
sql += " AND created_at > ?";
|
|
370
395
|
params.push(since);
|
|
@@ -372,23 +397,31 @@ export class Consultation {
|
|
|
372
397
|
sql += " ORDER BY created_at DESC";
|
|
373
398
|
return db.prepare(sql).all(...params);
|
|
374
399
|
}
|
|
375
|
-
getActionSummariesBySession(sessionId) {
|
|
400
|
+
getActionSummariesBySession(orgId, sessionId) {
|
|
376
401
|
const db = getDb();
|
|
377
402
|
return db
|
|
378
|
-
.prepare("SELECT * FROM action_summaries WHERE session_id = ? ORDER BY created_at")
|
|
379
|
-
.all(sessionId);
|
|
403
|
+
.prepare("SELECT * FROM action_summaries WHERE org_id = ? AND session_id = ? ORDER BY created_at")
|
|
404
|
+
.all(orgId, sessionId);
|
|
380
405
|
}
|
|
381
406
|
// ── Private helpers ──
|
|
382
|
-
|
|
407
|
+
/**
|
|
408
|
+
* Cross-org thread lookup for internal sweepers/departure handlers.
|
|
409
|
+
* Do NOT call from public methods — use getThread(orgId, id) instead.
|
|
410
|
+
*/
|
|
411
|
+
getThreadCrossOrg(threadId) {
|
|
412
|
+
const db = getDb();
|
|
413
|
+
return (db.prepare("SELECT * FROM threads WHERE id = ?").get(threadId) || null);
|
|
414
|
+
}
|
|
415
|
+
postResolutionMessage(orgId, threadId, agentId, type, content) {
|
|
383
416
|
const db = getDb();
|
|
384
|
-
const thread = this.getThread(threadId);
|
|
417
|
+
const thread = this.getThread(orgId, threadId);
|
|
385
418
|
const id = randomUUID();
|
|
386
|
-
db.prepare(`INSERT INTO thread_messages (id, thread_id, agent_id, type, content, round)
|
|
387
|
-
VALUES (?, ?, ?, ?, ?, ?)`).run(id, threadId, agentId, type, content, thread.round);
|
|
419
|
+
db.prepare(`INSERT INTO thread_messages (id, org_id, thread_id, agent_id, type, content, round)
|
|
420
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(id, orgId, threadId, agentId, type, content, thread.round);
|
|
388
421
|
}
|
|
389
|
-
allRespondentsApproved(threadId) {
|
|
422
|
+
allRespondentsApproved(orgId, threadId) {
|
|
390
423
|
const db = getDb();
|
|
391
|
-
const thread = this.getThread(threadId);
|
|
424
|
+
const thread = this.getThread(orgId, threadId);
|
|
392
425
|
const expected = JSON.parse(thread.expected_respondents || "[]");
|
|
393
426
|
if (expected.length === 0)
|
|
394
427
|
return true;
|
|
@@ -3,12 +3,12 @@ import type { AgentRegistry } from "./agent-registry.js";
|
|
|
3
3
|
import type { Consultation } from "./consultation.js";
|
|
4
4
|
import type { FileTracker } from "./file-tracker.js";
|
|
5
5
|
export interface ContextProvider {
|
|
6
|
-
getRelevantContext(agentId: string, query: ConsultationAnnounce): AgentContext;
|
|
6
|
+
getRelevantContext(orgId: string, agentId: string, query: ConsultationAnnounce): AgentContext;
|
|
7
7
|
}
|
|
8
8
|
export declare class SummaryContextProvider implements ContextProvider {
|
|
9
9
|
private registry;
|
|
10
10
|
private consultation;
|
|
11
11
|
private fileTracker;
|
|
12
12
|
constructor(registry: AgentRegistry, consultation: Consultation, fileTracker: FileTracker);
|
|
13
|
-
getRelevantContext(agentId: string, query: ConsultationAnnounce): AgentContext;
|
|
13
|
+
getRelevantContext(orgId: string, agentId: string, query: ConsultationAnnounce): AgentContext;
|
|
14
14
|
}
|
|
@@ -7,8 +7,8 @@ export class SummaryContextProvider {
|
|
|
7
7
|
this.consultation = consultation;
|
|
8
8
|
this.fileTracker = fileTracker;
|
|
9
9
|
}
|
|
10
|
-
getRelevantContext(agentId, query) {
|
|
11
|
-
const agent = this.registry.get(agentId);
|
|
10
|
+
getRelevantContext(orgId, agentId, query) {
|
|
11
|
+
const agent = this.registry.get(orgId, agentId);
|
|
12
12
|
if (!agent) {
|
|
13
13
|
return { agent_id: agentId, modules: [], recent_files: [], action_summaries: [] };
|
|
14
14
|
}
|
|
@@ -18,8 +18,7 @@ export class SummaryContextProvider {
|
|
|
18
18
|
if (overlapping.length === 0) {
|
|
19
19
|
return { agent_id: agentId, modules: [], recent_files: [], action_summaries: [] };
|
|
20
20
|
}
|
|
21
|
-
|
|
22
|
-
const summaries = this.consultation.getActionSummaries(agentId);
|
|
21
|
+
const summaries = this.consultation.getActionSummaries(orgId, agentId);
|
|
23
22
|
// Get recent files from action summaries (agent writes these via MCP tool)
|
|
24
23
|
const recentFiles = summaries
|
|
25
24
|
.filter((s) => s.file_path)
|
package/dist/src/database.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import path from "path";
|
|
2
|
-
import { mkdirSync } from "fs";
|
|
2
|
+
import { mkdirSync, chmodSync } from "fs";
|
|
3
3
|
import { createRequire } from "module";
|
|
4
4
|
const require = createRequire(import.meta.url);
|
|
5
5
|
let db;
|
|
6
|
-
const CURRENT_USER_VERSION =
|
|
6
|
+
const CURRENT_USER_VERSION = 7;
|
|
7
7
|
const SCHEMA = `
|
|
8
8
|
CREATE TABLE IF NOT EXISTS agents (
|
|
9
9
|
id TEXT PRIMARY KEY,
|
|
@@ -169,6 +169,71 @@ const SCHEMA = `
|
|
|
169
169
|
);
|
|
170
170
|
CREATE INDEX IF NOT EXISTS idx_firings_layer ON layer_firings(layer, fired_at);
|
|
171
171
|
CREATE INDEX IF NOT EXISTS idx_firings_thread ON layer_firings(thread_id);
|
|
172
|
+
|
|
173
|
+
CREATE TABLE IF NOT EXISTS orgs (
|
|
174
|
+
id TEXT PRIMARY KEY,
|
|
175
|
+
name TEXT NOT NULL,
|
|
176
|
+
idp_provider TEXT,
|
|
177
|
+
idp_org_id TEXT,
|
|
178
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
182
|
+
id TEXT PRIMARY KEY,
|
|
183
|
+
org_id TEXT NOT NULL REFERENCES orgs(id),
|
|
184
|
+
email TEXT NOT NULL,
|
|
185
|
+
name TEXT,
|
|
186
|
+
idp_provider TEXT NOT NULL,
|
|
187
|
+
idp_user_id TEXT NOT NULL,
|
|
188
|
+
role TEXT NOT NULL DEFAULT 'member',
|
|
189
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
190
|
+
last_login_at TEXT,
|
|
191
|
+
UNIQUE(idp_provider, idp_user_id)
|
|
192
|
+
);
|
|
193
|
+
CREATE INDEX IF NOT EXISTS idx_users_org ON users(org_id);
|
|
194
|
+
|
|
195
|
+
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
|
196
|
+
id TEXT PRIMARY KEY,
|
|
197
|
+
org_id TEXT NOT NULL REFERENCES orgs(id),
|
|
198
|
+
user_id TEXT NOT NULL REFERENCES users(id),
|
|
199
|
+
jti TEXT NOT NULL UNIQUE,
|
|
200
|
+
device_label TEXT,
|
|
201
|
+
expires_at TEXT NOT NULL,
|
|
202
|
+
revoked_at TEXT,
|
|
203
|
+
last_used_at TEXT,
|
|
204
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
205
|
+
);
|
|
206
|
+
CREATE INDEX IF NOT EXISTS idx_refresh_user ON refresh_tokens(user_id, revoked_at);
|
|
207
|
+
CREATE INDEX IF NOT EXISTS idx_refresh_org_user ON refresh_tokens(org_id, user_id, revoked_at);
|
|
208
|
+
|
|
209
|
+
CREATE TABLE IF NOT EXISTS device_auth_requests (
|
|
210
|
+
device_code TEXT PRIMARY KEY,
|
|
211
|
+
user_code TEXT NOT NULL UNIQUE,
|
|
212
|
+
nonce TEXT NOT NULL UNIQUE,
|
|
213
|
+
approved_user_id TEXT REFERENCES users(id),
|
|
214
|
+
org_id TEXT,
|
|
215
|
+
expires_at TEXT NOT NULL,
|
|
216
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
217
|
+
);
|
|
218
|
+
CREATE INDEX IF NOT EXISTS idx_device_user_code ON device_auth_requests(user_code);
|
|
219
|
+
CREATE INDEX IF NOT EXISTS idx_device_nonce ON device_auth_requests(nonce);
|
|
220
|
+
|
|
221
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
222
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
223
|
+
user_id TEXT,
|
|
224
|
+
org_id TEXT,
|
|
225
|
+
action TEXT NOT NULL,
|
|
226
|
+
target TEXT,
|
|
227
|
+
ip TEXT,
|
|
228
|
+
user_agent TEXT,
|
|
229
|
+
metadata TEXT,
|
|
230
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
231
|
+
);
|
|
232
|
+
CREATE INDEX IF NOT EXISTS idx_audit_org_time ON audit_log(org_id, created_at);
|
|
233
|
+
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id, created_at);
|
|
234
|
+
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action, created_at);
|
|
235
|
+
|
|
236
|
+
INSERT OR IGNORE INTO orgs (id, name) VALUES ('default', 'Default Organization');
|
|
172
237
|
`;
|
|
173
238
|
function createBetterSqlite3(dataDir) {
|
|
174
239
|
mkdirSync(dataDir, { recursive: true });
|
|
@@ -212,6 +277,13 @@ export function initDatabase(dataDir) {
|
|
|
212
277
|
throw new Error(`Database schema is from a newer version (${foundVersion}) than this binary supports (${CURRENT_USER_VERSION}). Downgrade not supported.`);
|
|
213
278
|
}
|
|
214
279
|
db.exec(SCHEMA);
|
|
280
|
+
// v0.7 security baseline: only owner can read/write the DB file.
|
|
281
|
+
// Idempotent re-chmod on every boot so existing v0.6 DBs are tightened too.
|
|
282
|
+
// POSIX-only: chmod is a no-op on Windows (NTFS permissions don't map).
|
|
283
|
+
try {
|
|
284
|
+
chmodSync(path.join(dataDir, "coordinator.db"), 0o600);
|
|
285
|
+
}
|
|
286
|
+
catch { /* non-POSIX or permission error — log-only would be too noisy */ }
|
|
215
287
|
// Migrations for existing databases — columns may already exist
|
|
216
288
|
try {
|
|
217
289
|
db.exec("ALTER TABLE threads ADD COLUMN claimed_by TEXT");
|
|
@@ -245,8 +317,135 @@ export function initDatabase(dataDir) {
|
|
|
245
317
|
db.exec("ALTER TABLE file_activity ADD COLUMN content_hash TEXT");
|
|
246
318
|
}
|
|
247
319
|
catch { /* already exists */ }
|
|
248
|
-
// v0.
|
|
249
|
-
|
|
320
|
+
// v0.7: multi-tenant org_id column on every table that participates in scoping.
|
|
321
|
+
// SQLite lacks online DDL — each ALTER briefly blocks writes. Migration is
|
|
322
|
+
// idempotent (already-exists is caught silently).
|
|
323
|
+
// SECURITY: `t` is from a compile-time constant array only — never user input.
|
|
324
|
+
const TABLES_NEEDING_ORG = [
|
|
325
|
+
"agents", "threads", "thread_messages", "action_summaries",
|
|
326
|
+
"file_activity", "events", "dependency_map", "introspections",
|
|
327
|
+
"agent_activity_status", "revoked_agents", "working_files",
|
|
328
|
+
"git_cochange", "git_cochange_meta", "layer_firings",
|
|
329
|
+
];
|
|
330
|
+
for (const t of TABLES_NEEDING_ORG) {
|
|
331
|
+
try {
|
|
332
|
+
db.exec(`ALTER TABLE ${t} ADD COLUMN org_id TEXT NOT NULL DEFAULT 'default'`);
|
|
333
|
+
}
|
|
334
|
+
catch { /* already exists */ }
|
|
335
|
+
}
|
|
336
|
+
// v0.7: scan index for events table — getEventsSince queries by (org_id, id)
|
|
337
|
+
try {
|
|
338
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_events_org_id ON events(org_id, id)");
|
|
339
|
+
}
|
|
340
|
+
catch { /* already exists */ }
|
|
341
|
+
// v0.7: SQLite lacks ALTER PRIMARY KEY. Pattern per table:
|
|
342
|
+
// 1. Create new table with composite PK.
|
|
343
|
+
// 2. Copy all rows from old to new.
|
|
344
|
+
// 3. Drop old.
|
|
345
|
+
// 4. Rename new to old.
|
|
346
|
+
// 5. Recreate indexes.
|
|
347
|
+
// Idempotent: skip if the table's PK already includes org_id.
|
|
348
|
+
function migrateToCompositePK(targetDb, tableName, newCreateSql, columnList, indexCreateSqls) {
|
|
349
|
+
// Check if migration already happened by inspecting PK columns
|
|
350
|
+
const cols = targetDb.prepare(`PRAGMA table_info(${tableName})`).all();
|
|
351
|
+
const pkCols = cols.filter((c) => c.pk > 0).map((c) => c.name);
|
|
352
|
+
if (pkCols.includes("org_id"))
|
|
353
|
+
return; // already migrated
|
|
354
|
+
// SQLite: FK enforcement blocks DROP TABLE when other tables hold FK refs.
|
|
355
|
+
// PRAGMA foreign_keys must be toggled OUTSIDE the transaction (it's a no-op
|
|
356
|
+
// inside an open transaction). The finally block guarantees FKs are re-enabled
|
|
357
|
+
// even on error to avoid permanent corruption.
|
|
358
|
+
targetDb.exec("PRAGMA foreign_keys = OFF");
|
|
359
|
+
try {
|
|
360
|
+
targetDb.exec("BEGIN");
|
|
361
|
+
try {
|
|
362
|
+
targetDb.exec(newCreateSql.replace(tableName, `${tableName}_new`));
|
|
363
|
+
targetDb.exec(`INSERT INTO ${tableName}_new (${columnList}) SELECT ${columnList} FROM ${tableName}`);
|
|
364
|
+
targetDb.exec(`DROP TABLE ${tableName}`);
|
|
365
|
+
targetDb.exec(`ALTER TABLE ${tableName}_new RENAME TO ${tableName}`);
|
|
366
|
+
for (const idxSql of indexCreateSqls)
|
|
367
|
+
targetDb.exec(idxSql);
|
|
368
|
+
targetDb.exec("COMMIT");
|
|
369
|
+
}
|
|
370
|
+
catch (e) {
|
|
371
|
+
targetDb.exec("ROLLBACK");
|
|
372
|
+
throw e;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
finally {
|
|
376
|
+
targetDb.exec("PRAGMA foreign_keys = ON");
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
migrateToCompositePK(db, "agents", `CREATE TABLE agents (
|
|
380
|
+
id TEXT NOT NULL,
|
|
381
|
+
org_id TEXT NOT NULL DEFAULT 'default',
|
|
382
|
+
name TEXT NOT NULL,
|
|
383
|
+
modules TEXT DEFAULT '[]',
|
|
384
|
+
status TEXT DEFAULT 'offline',
|
|
385
|
+
registered_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
386
|
+
last_seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
387
|
+
PRIMARY KEY (org_id, id)
|
|
388
|
+
)`, "id, org_id, name, modules, status, registered_at, last_seen_at", ["CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_id ON agents(id)"]);
|
|
389
|
+
migrateToCompositePK(db, "agent_activity_status", `CREATE TABLE agent_activity_status (
|
|
390
|
+
agent_id TEXT NOT NULL,
|
|
391
|
+
org_id TEXT NOT NULL DEFAULT 'default',
|
|
392
|
+
activity_status TEXT DEFAULT 'idle',
|
|
393
|
+
current_file TEXT,
|
|
394
|
+
current_thread TEXT,
|
|
395
|
+
last_activity_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
396
|
+
FOREIGN KEY (agent_id) REFERENCES agents(id),
|
|
397
|
+
PRIMARY KEY (org_id, agent_id)
|
|
398
|
+
)`, "agent_id, org_id, activity_status, current_file, current_thread, last_activity_at", []);
|
|
399
|
+
migrateToCompositePK(db, "revoked_agents", `CREATE TABLE revoked_agents (
|
|
400
|
+
agent_id TEXT NOT NULL,
|
|
401
|
+
org_id TEXT NOT NULL DEFAULT 'default',
|
|
402
|
+
revoked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
403
|
+
revoked_by TEXT NOT NULL,
|
|
404
|
+
PRIMARY KEY (org_id, agent_id)
|
|
405
|
+
)`, "agent_id, org_id, revoked_at, revoked_by", []);
|
|
406
|
+
migrateToCompositePK(db, "working_files", `CREATE TABLE working_files (
|
|
407
|
+
agent_id TEXT NOT NULL,
|
|
408
|
+
file_path TEXT NOT NULL,
|
|
409
|
+
org_id TEXT NOT NULL DEFAULT 'default',
|
|
410
|
+
started_at TEXT NOT NULL,
|
|
411
|
+
last_activity_at TEXT NOT NULL,
|
|
412
|
+
claim_until TEXT NOT NULL,
|
|
413
|
+
PRIMARY KEY (org_id, agent_id, file_path)
|
|
414
|
+
)`, "agent_id, file_path, org_id, started_at, last_activity_at, claim_until", [
|
|
415
|
+
"CREATE INDEX IF NOT EXISTS idx_working_files_path ON working_files(file_path)",
|
|
416
|
+
"CREATE INDEX IF NOT EXISTS idx_working_files_until ON working_files(claim_until)",
|
|
417
|
+
]);
|
|
418
|
+
migrateToCompositePK(db, "dependency_map", `CREATE TABLE dependency_map (
|
|
419
|
+
module_id TEXT NOT NULL,
|
|
420
|
+
org_id TEXT NOT NULL DEFAULT 'default',
|
|
421
|
+
depends_on TEXT DEFAULT '[]',
|
|
422
|
+
exports TEXT DEFAULT '[]',
|
|
423
|
+
owners TEXT DEFAULT '[]',
|
|
424
|
+
PRIMARY KEY (org_id, module_id)
|
|
425
|
+
)`, "module_id, org_id, depends_on, exports, owners", []);
|
|
426
|
+
migrateToCompositePK(db, "git_cochange", `CREATE TABLE git_cochange (
|
|
427
|
+
file_a TEXT NOT NULL,
|
|
428
|
+
file_b TEXT NOT NULL,
|
|
429
|
+
org_id TEXT NOT NULL DEFAULT 'default',
|
|
430
|
+
count INTEGER NOT NULL,
|
|
431
|
+
total_commits INTEGER NOT NULL,
|
|
432
|
+
computed_at TEXT NOT NULL,
|
|
433
|
+
PRIMARY KEY (org_id, file_a, file_b),
|
|
434
|
+
CHECK (file_a < file_b)
|
|
435
|
+
)`, "file_a, file_b, org_id, count, total_commits, computed_at", [
|
|
436
|
+
"CREATE INDEX IF NOT EXISTS idx_cochange_a ON git_cochange(file_a)",
|
|
437
|
+
"CREATE INDEX IF NOT EXISTS idx_cochange_b ON git_cochange(file_b)",
|
|
438
|
+
]);
|
|
439
|
+
migrateToCompositePK(db, "git_cochange_meta", `CREATE TABLE git_cochange_meta (
|
|
440
|
+
k TEXT NOT NULL,
|
|
441
|
+
org_id TEXT NOT NULL DEFAULT 'default',
|
|
442
|
+
v TEXT,
|
|
443
|
+
PRIMARY KEY (org_id, k)
|
|
444
|
+
)`, "k, org_id, v", []);
|
|
445
|
+
// v0.7: bump version marker LAST — after every CREATE TABLE and ALTER above succeeded.
|
|
446
|
+
// A crash before this line leaves user_version=6 and the next boot retries the migration
|
|
447
|
+
// (idempotent). Bumping earlier would make a partial migration look complete.
|
|
448
|
+
db.exec("PRAGMA user_version = 7");
|
|
250
449
|
}
|
|
251
450
|
export function getDb() {
|
|
252
451
|
if (!db)
|