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
|
@@ -31,14 +31,14 @@ export async function handleRest(req, res, ctx) {
|
|
|
31
31
|
const { registry, activityTracker, consultation, fileTracker, introspection, sseEmitter, mqttBridge, quotaCache } = services;
|
|
32
32
|
if (url === "/api/register") {
|
|
33
33
|
const { agent_id, name, modules } = body;
|
|
34
|
-
const agent = registry.register(agent_id, name, modules || []);
|
|
35
|
-
sseEmitter.emit("agent_online", { agent_id, name, modules });
|
|
34
|
+
const agent = registry.register(ctx.claims.org, agent_id, name, modules || []);
|
|
35
|
+
sseEmitter.emit("agent_online", { agent_id, name, modules }, { org_id: ctx.claims.org });
|
|
36
36
|
json(res, agent);
|
|
37
37
|
}
|
|
38
38
|
else if (url === "/api/session-start") {
|
|
39
|
-
const online = registry.listOnline();
|
|
40
|
-
const openThreads = consultation.listThreads({ status: "open" });
|
|
41
|
-
const hotFiles = fileTracker.getHotFiles(30);
|
|
39
|
+
const online = registry.listOnline(ctx.claims.org);
|
|
40
|
+
const openThreads = consultation.listThreads(ctx.claims.org, { status: "open" });
|
|
41
|
+
const hotFiles = fileTracker.getHotFiles(ctx.claims.org, 30);
|
|
42
42
|
const briefing = [
|
|
43
43
|
`Agents en ligne: ${online.map((a) => a.name).join(", ") || "aucun"}`,
|
|
44
44
|
`Consultations ouvertes: ${openThreads.length}`,
|
|
@@ -48,15 +48,15 @@ export async function handleRest(req, res, ctx) {
|
|
|
48
48
|
}
|
|
49
49
|
else if (url === "/api/session-stop") {
|
|
50
50
|
const { agent_id } = body;
|
|
51
|
-
registry.setOffline(agent_id);
|
|
52
|
-
activityTracker.reportOffline(agent_id);
|
|
51
|
+
registry.setOffline(ctx.claims.org, agent_id);
|
|
52
|
+
activityTracker.reportOffline(ctx.claims.org, agent_id);
|
|
53
53
|
consultation.handleAgentDeparture(agent_id);
|
|
54
|
-
sseEmitter.emit("agent_offline", { agent_id });
|
|
54
|
+
sseEmitter.emit("agent_offline", { agent_id }, { org_id: ctx.claims.org });
|
|
55
55
|
json(res, { ok: true });
|
|
56
56
|
}
|
|
57
57
|
else if (url === "/api/check-conflict") {
|
|
58
58
|
const { file, agent_id } = body;
|
|
59
|
-
const conflict = fileTracker.checkFileConflict(file, agent_id, 30);
|
|
59
|
+
const conflict = fileTracker.checkFileConflict(ctx.claims.org, file, agent_id, 30);
|
|
60
60
|
const warnings = [];
|
|
61
61
|
if (conflict.conflict) {
|
|
62
62
|
warnings.push(`File ${file} recently edited by: ${conflict.agents.join(", ")}`);
|
|
@@ -65,20 +65,20 @@ export async function handleRest(req, res, ctx) {
|
|
|
65
65
|
}
|
|
66
66
|
else if (url === "/api/log-file") {
|
|
67
67
|
const { session_id, agent_id, agent_name, tool_name, file } = body;
|
|
68
|
-
fileTracker.log({ session_id, agent_id, agent_name, tool_name, file_path: file });
|
|
69
|
-
activityTracker.reportFileActivity(agent_id, file);
|
|
70
|
-
sseEmitter.emit("file_edited", { agent_id, agent_name: agent_name || agent_id, file, tool_name });
|
|
68
|
+
fileTracker.log({ org_id: ctx.claims.org, session_id, agent_id, agent_name, tool_name, file_path: file });
|
|
69
|
+
activityTracker.reportFileActivity(ctx.claims.org, agent_id, file);
|
|
70
|
+
sseEmitter.emit("file_edited", { agent_id, agent_name: agent_name || agent_id, file, tool_name }, { org_id: ctx.claims.org });
|
|
71
71
|
json(res, { ok: true });
|
|
72
72
|
}
|
|
73
73
|
else if (url === "/api/announce") {
|
|
74
74
|
const { agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to, target_symbols } = body;
|
|
75
|
-
const thread = consultation.announceWork({ agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to });
|
|
76
|
-
const agentInfo = registry.get(agent_id);
|
|
75
|
+
const thread = consultation.announceWork(ctx.claims.org, { agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to });
|
|
76
|
+
const agentInfo = registry.get(ctx.claims.org, agent_id);
|
|
77
77
|
// S2 fix: shared workflow (impact scoring, override respondents, auto-resolve,
|
|
78
78
|
// impact_scored + introspection SSE, plan-quality downgrade event). Same
|
|
79
79
|
// function used by the MCP announce_work tool path.
|
|
80
80
|
const { updated, categorized, respondents, planQuality } = runCommonAnnounceFlow(services, thread.id, {
|
|
81
|
-
agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open,
|
|
81
|
+
org_id: ctx.claims.org, agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open,
|
|
82
82
|
target_symbols,
|
|
83
83
|
});
|
|
84
84
|
// REST-specific thread_opened SSE shape (different field set than MCP — kept
|
|
@@ -91,7 +91,7 @@ export async function handleRest(req, res, ctx) {
|
|
|
91
91
|
mode: planQuality.mode,
|
|
92
92
|
plan: plan || null,
|
|
93
93
|
plan_quality: planQuality,
|
|
94
|
-
});
|
|
94
|
+
}, { org_id: ctx.claims.org });
|
|
95
95
|
json(res, { thread_id: thread.id, status: updated.status, impact: categorized });
|
|
96
96
|
}
|
|
97
97
|
else if (url === "/api/post-to-thread") {
|
|
@@ -99,7 +99,7 @@ export async function handleRest(req, res, ctx) {
|
|
|
99
99
|
// Pre-check the thread so we can return actionable status codes instead
|
|
100
100
|
// of always-500 on any error. The client uses the status to decide
|
|
101
101
|
// whether to warn (unexpected) or silently skip (normal race).
|
|
102
|
-
const targetThread = consultation.getThread(thread_id);
|
|
102
|
+
const targetThread = consultation.getThread(ctx.claims.org, thread_id);
|
|
103
103
|
if (!targetThread) {
|
|
104
104
|
json(res, { error: "thread_not_found", thread_id }, 404);
|
|
105
105
|
return;
|
|
@@ -108,20 +108,20 @@ export async function handleRest(req, res, ctx) {
|
|
|
108
108
|
json(res, { error: "thread_cancelled", thread_id }, 410);
|
|
109
109
|
return;
|
|
110
110
|
}
|
|
111
|
-
const msg = consultation.postToThread({ thread_id, agent_id, agent_name, type, content });
|
|
112
|
-
const thread = consultation.getThread(thread_id);
|
|
111
|
+
const msg = consultation.postToThread(ctx.claims.org, { thread_id, agent_id, agent_name, type, content });
|
|
112
|
+
const thread = consultation.getThread(ctx.claims.org, thread_id);
|
|
113
113
|
sseEmitter.emit("message_posted", {
|
|
114
114
|
thread_id, agent_id, agent_name: agent_name || agent_id,
|
|
115
115
|
type, content, round: thread?.round || 1,
|
|
116
116
|
token_estimate: msg.token_estimate || 0,
|
|
117
|
-
});
|
|
117
|
+
}, { org_id: ctx.claims.org });
|
|
118
118
|
json(res, msg);
|
|
119
119
|
}
|
|
120
120
|
else if (url === "/api/token-usage") {
|
|
121
121
|
// Agent → coordinator telemetry, emitted once per LLM turn so the dashboard
|
|
122
122
|
// and reports can pinpoint where tokens are being burned.
|
|
123
123
|
const payload = body;
|
|
124
|
-
sseEmitter.emit("token_usage", payload);
|
|
124
|
+
sseEmitter.emit("token_usage", payload, { org_id: ctx.claims.org });
|
|
125
125
|
json(res, { ok: true });
|
|
126
126
|
}
|
|
127
127
|
else if (url === "/api/unclaim-task") {
|
|
@@ -136,12 +136,12 @@ export async function handleRest(req, res, ctx) {
|
|
|
136
136
|
// claim → no DONE → unclaim → re-claim loop we observed on stuck tasks.
|
|
137
137
|
// Only the claiming agent can unclaim to prevent cross-agent interference.
|
|
138
138
|
const POISON_THRESHOLD = 2;
|
|
139
|
-
const result = db.prepare("UPDATE threads SET claimed_by = NULL, claimed_at = NULL, unclaim_count = COALESCE(unclaim_count, 0) + 1 WHERE id = ? AND claimed_by = ? AND status = 'open'").run(thread_id, agent_id);
|
|
139
|
+
const result = db.prepare("UPDATE threads SET claimed_by = NULL, claimed_at = NULL, unclaim_count = COALESCE(unclaim_count, 0) + 1 WHERE id = ? AND org_id = ? AND claimed_by = ? AND status = 'open'").run(thread_id, ctx.claims.org, agent_id);
|
|
140
140
|
let poisoned = false;
|
|
141
141
|
if (result.changes === 1) {
|
|
142
|
-
const row = db.prepare("SELECT unclaim_count FROM threads WHERE id = ?").get(thread_id);
|
|
142
|
+
const row = db.prepare("SELECT unclaim_count FROM threads WHERE id = ? AND org_id = ?").get(thread_id, ctx.claims.org);
|
|
143
143
|
if (row && (row.unclaim_count ?? 0) >= POISON_THRESHOLD) {
|
|
144
|
-
db.prepare("UPDATE threads SET status = 'poisoned' WHERE id = ? AND status = 'open'").run(thread_id);
|
|
144
|
+
db.prepare("UPDATE threads SET status = 'poisoned' WHERE id = ? AND org_id = ? AND status = 'open'").run(thread_id, ctx.claims.org);
|
|
145
145
|
poisoned = true;
|
|
146
146
|
httpLog.warn({ thread_id, unclaim_count: row.unclaim_count }, "thread poisoned after repeated unclaims");
|
|
147
147
|
}
|
|
@@ -159,14 +159,14 @@ export async function handleRest(req, res, ctx) {
|
|
|
159
159
|
// automatically because the status filter excludes them.
|
|
160
160
|
// Directed-dispatch constraint: if assigned_to is set, only that specific
|
|
161
161
|
// agent can claim; NULL keeps the original open-pool semantics.
|
|
162
|
-
const result = db.prepare("UPDATE threads SET claimed_by = ?, claimed_at = ? WHERE id = ? AND claimed_by IS NULL AND status = 'open' AND (assigned_to IS NULL OR assigned_to = ?)").run(agent_id, new Date().toISOString(), thread_id, agent_id);
|
|
162
|
+
const result = db.prepare("UPDATE threads SET claimed_by = ?, claimed_at = ? WHERE id = ? AND org_id = ? AND claimed_by IS NULL AND status = 'open' AND (assigned_to IS NULL OR assigned_to = ?)").run(agent_id, new Date().toISOString(), thread_id, ctx.claims.org, agent_id);
|
|
163
163
|
if (result.changes === 1) {
|
|
164
164
|
mqttBridge.publishTaskClaimed(thread_id, agent_id);
|
|
165
|
-
sseEmitter.emit("task_claimed", { thread_id, agent_id });
|
|
165
|
+
sseEmitter.emit("task_claimed", { thread_id, agent_id }, { org_id: ctx.claims.org });
|
|
166
166
|
json(res, { success: true });
|
|
167
167
|
}
|
|
168
168
|
else {
|
|
169
|
-
const thread = consultation.getThread(thread_id);
|
|
169
|
+
const thread = consultation.getThread(ctx.claims.org, thread_id);
|
|
170
170
|
// Surface the assigned_to in the 'why not' response so clients can
|
|
171
171
|
// distinguish "already claimed by X" from "reserved for Y".
|
|
172
172
|
json(res, {
|
|
@@ -179,24 +179,24 @@ export async function handleRest(req, res, ctx) {
|
|
|
179
179
|
}
|
|
180
180
|
else if (url === "/api/propose-resolution") {
|
|
181
181
|
const { thread_id, agent_id, summary } = body;
|
|
182
|
-
const agentInfo = registry.get(agent_id);
|
|
183
|
-
consultation.proposeResolution(thread_id, agent_id, summary);
|
|
182
|
+
const agentInfo = registry.get(ctx.claims.org, agent_id);
|
|
183
|
+
consultation.proposeResolution(ctx.claims.org, thread_id, agent_id, summary);
|
|
184
184
|
sseEmitter.emit("resolution_proposed", {
|
|
185
185
|
thread_id, agent_id, agent_name: agentInfo?.name || agent_id, summary,
|
|
186
|
-
});
|
|
187
|
-
json(res, consultation.getThread(thread_id));
|
|
186
|
+
}, { org_id: ctx.claims.org });
|
|
187
|
+
json(res, consultation.getThread(ctx.claims.org, thread_id));
|
|
188
188
|
mqttBridge.publishTaskCompleted(thread_id, agent_id, summary);
|
|
189
189
|
}
|
|
190
190
|
else if (url === "/api/approve-resolution") {
|
|
191
191
|
const { thread_id, agent_id } = body;
|
|
192
|
-
const agentInfo = registry.get(agent_id);
|
|
193
|
-
consultation.approveResolution(thread_id, agent_id, agentInfo?.name);
|
|
194
|
-
const t = consultation.getThread(thread_id);
|
|
192
|
+
const agentInfo = registry.get(ctx.claims.org, agent_id);
|
|
193
|
+
consultation.approveResolution(ctx.claims.org, thread_id, agent_id, agentInfo?.name ?? undefined);
|
|
194
|
+
const t = consultation.getThread(ctx.claims.org, thread_id);
|
|
195
195
|
json(res, t);
|
|
196
196
|
}
|
|
197
197
|
else if (url?.startsWith("/api/consultation/") && url?.endsWith("/status")) {
|
|
198
198
|
const threadId = url.split("/")[3];
|
|
199
|
-
const thread = consultation.getThreadWithMessages(threadId);
|
|
199
|
+
const thread = consultation.getThreadWithMessages(ctx.claims.org, threadId);
|
|
200
200
|
if (!thread) {
|
|
201
201
|
json(res, { error: "not found" }, 404);
|
|
202
202
|
}
|
|
@@ -210,13 +210,13 @@ export async function handleRest(req, res, ctx) {
|
|
|
210
210
|
}
|
|
211
211
|
}
|
|
212
212
|
else if (url === "/api/threads-active") {
|
|
213
|
-
const open = consultation.listThreads({ status: "open" });
|
|
214
|
-
const resolving = consultation.listThreads({ status: "resolving" });
|
|
213
|
+
const open = consultation.listThreads(ctx.claims.org, { status: "open" });
|
|
214
|
+
const resolving = consultation.listThreads(ctx.claims.org, { status: "resolving" });
|
|
215
215
|
json(res, [...open, ...resolving]);
|
|
216
216
|
}
|
|
217
217
|
else if (url === "/api/hot-files") {
|
|
218
218
|
const { since_minutes } = body;
|
|
219
|
-
json(res, fileTracker.getHotFiles(since_minutes || 30));
|
|
219
|
+
json(res, fileTracker.getHotFiles(ctx.claims.org, since_minutes || 30));
|
|
220
220
|
}
|
|
221
221
|
else if (url === "/api/quota") {
|
|
222
222
|
// Pre-flight + live widget endpoint. 200 with fresh QuotaInfo when the
|
|
@@ -267,38 +267,38 @@ export async function handleRest(req, res, ctx) {
|
|
|
267
267
|
}
|
|
268
268
|
else if (url === "/api/introspection-response") {
|
|
269
269
|
const { introspection_id, concerned, reason } = body;
|
|
270
|
-
const intro = introspection.respond(
|
|
270
|
+
const intro = introspection.respond(ctx.claims.org, introspection_id, reason);
|
|
271
271
|
// If concerned, add to thread's expected_respondents
|
|
272
272
|
if (concerned && intro) {
|
|
273
273
|
const db = getDb();
|
|
274
|
-
const thread = consultation.getThread(intro.thread_id);
|
|
274
|
+
const thread = consultation.getThread(ctx.claims.org, intro.thread_id);
|
|
275
275
|
if (thread && (thread.status === "open" || thread.status === "resolving")) {
|
|
276
276
|
const respondents = JSON.parse(thread.expected_respondents || "[]");
|
|
277
277
|
if (!respondents.includes(intro.agent_id)) {
|
|
278
278
|
respondents.push(intro.agent_id);
|
|
279
|
-
db.prepare("UPDATE threads SET expected_respondents = ? WHERE id = ?")
|
|
280
|
-
.run(JSON.stringify(respondents), thread.id);
|
|
279
|
+
db.prepare("UPDATE threads SET expected_respondents = ? WHERE id = ? AND org_id = ?")
|
|
280
|
+
.run(JSON.stringify(respondents), thread.id, ctx.claims.org);
|
|
281
281
|
}
|
|
282
282
|
}
|
|
283
283
|
}
|
|
284
|
-
const agentInfo = registry.get(intro?.agent_id || "");
|
|
284
|
+
const agentInfo = registry.get(ctx.claims.org, intro?.agent_id || "");
|
|
285
285
|
sseEmitter.emit("introspection_completed", {
|
|
286
286
|
introspection_id, thread_id: intro?.thread_id,
|
|
287
287
|
agent_id: intro?.agent_id, agent_name: agentInfo?.name || intro?.agent_id,
|
|
288
288
|
concerned, reason,
|
|
289
|
-
});
|
|
289
|
+
}, { org_id: ctx.claims.org });
|
|
290
290
|
json(res, intro);
|
|
291
291
|
}
|
|
292
292
|
else if (url?.startsWith("/api/pending-introspections")) {
|
|
293
293
|
const urlObj = new URL(url, "http://localhost");
|
|
294
294
|
const agent_id = urlObj.searchParams.get("agent_id") || "";
|
|
295
|
-
const pending = introspection.getPending(agent_id);
|
|
295
|
+
const pending = introspection.getPending(ctx.claims.org, agent_id);
|
|
296
296
|
json(res, pending);
|
|
297
297
|
}
|
|
298
298
|
else if (url === "/api/run-config") {
|
|
299
299
|
if (req.method === "POST") {
|
|
300
300
|
setRunConfig(body);
|
|
301
|
-
sseEmitter.emit("run_config", getRunConfig());
|
|
301
|
+
sseEmitter.emit("run_config", getRunConfig(), { org_id: ctx.claims.org });
|
|
302
302
|
json(res, { ok: true });
|
|
303
303
|
}
|
|
304
304
|
else {
|
|
@@ -338,8 +338,8 @@ export async function handleRest(req, res, ctx) {
|
|
|
338
338
|
// Covers both open threads (waiting for initial response) and resolving threads
|
|
339
339
|
// (waiting for approval/contest of a proposed resolution).
|
|
340
340
|
const pendingThreads = [
|
|
341
|
-
...consultation.listThreads({ status: "open" }),
|
|
342
|
-
...consultation.listThreads({ status: "resolving" }),
|
|
341
|
+
...consultation.listThreads(ctx.claims.org, { status: "open" }),
|
|
342
|
+
...consultation.listThreads(ctx.claims.org, { status: "resolving" }),
|
|
343
343
|
].filter((t) => {
|
|
344
344
|
const respondents = JSON.parse(t.expected_respondents || "[]");
|
|
345
345
|
return respondents.includes(agent_id);
|
|
@@ -360,12 +360,12 @@ export async function handleRest(req, res, ctx) {
|
|
|
360
360
|
}
|
|
361
361
|
else if (url?.startsWith("/api/agent-status/")) {
|
|
362
362
|
const aid = url.split("/")[3];
|
|
363
|
-
const agent = registry.get(aid);
|
|
363
|
+
const agent = registry.get(ctx.claims.org, aid);
|
|
364
364
|
if (!agent) {
|
|
365
365
|
json(res, { registered: false, status: "unknown" });
|
|
366
366
|
}
|
|
367
367
|
else {
|
|
368
|
-
const activity = activityTracker.getActivity(aid, { idleAfterMinutes: 5 });
|
|
368
|
+
const activity = activityTracker.getActivity(ctx.claims.org, aid, { idleAfterMinutes: 5 });
|
|
369
369
|
json(res, { registered: true, status: agent.status, activity: activity.activity_status });
|
|
370
370
|
}
|
|
371
371
|
}
|
|
@@ -400,6 +400,7 @@ export async function handleRest(req, res, ctx) {
|
|
|
400
400
|
symbols = ctx.services.treeSitter.extract(filePath, body.content, null);
|
|
401
401
|
}
|
|
402
402
|
ctx.services.fileTracker.log({
|
|
403
|
+
org_id: ctx.claims.org,
|
|
403
404
|
session_id: body.session_id,
|
|
404
405
|
agent_id: body.agent_id,
|
|
405
406
|
agent_name: body.agent_name,
|
|
@@ -425,7 +426,7 @@ export async function handleRest(req, res, ctx) {
|
|
|
425
426
|
return;
|
|
426
427
|
}
|
|
427
428
|
const ttl = parseInt(process.env.COORDINATOR_WORKING_FILES_TTL_MIN || "30", 10);
|
|
428
|
-
services.workingFiles.start(body.agent_id, filePath, ttl);
|
|
429
|
+
services.workingFiles.start(ctx.claims.org, body.agent_id, filePath, ttl);
|
|
429
430
|
json(res, { ok: true });
|
|
430
431
|
}
|
|
431
432
|
else if (url === "/api/working-files/stop" && req.method === "POST") {
|
|
@@ -442,7 +443,7 @@ export async function handleRest(req, res, ctx) {
|
|
|
442
443
|
json(res, { error: `invalid file_path: ${err.message}` }, 400);
|
|
443
444
|
return;
|
|
444
445
|
}
|
|
445
|
-
services.workingFiles.stop(body.agent_id, filePath);
|
|
446
|
+
services.workingFiles.stop(ctx.claims.org, body.agent_id, filePath);
|
|
446
447
|
json(res, { ok: true });
|
|
447
448
|
}
|
|
448
449
|
else if (url?.startsWith("/api/scoring-stats") && req.method === "GET") {
|
|
@@ -483,12 +484,12 @@ export async function handleRest(req, res, ctx) {
|
|
|
483
484
|
});
|
|
484
485
|
}
|
|
485
486
|
else if (url === "/api/status") {
|
|
486
|
-
const online = registry.listOnline();
|
|
487
|
-
const openThreads = consultation.listThreads({ status: "open" });
|
|
487
|
+
const online = registry.listOnline(ctx.claims.org);
|
|
488
|
+
const openThreads = consultation.listThreads(ctx.claims.org, { status: "open" });
|
|
488
489
|
json(res, {
|
|
489
490
|
online: online.length,
|
|
490
491
|
open_threads: openThreads.length,
|
|
491
|
-
hot_files: fileTracker.getHotFiles(30).length,
|
|
492
|
+
hot_files: fileTracker.getHotFiles(ctx.claims.org, 30).length,
|
|
492
493
|
mqtt: services.mqttBridge.isConnected(),
|
|
493
494
|
});
|
|
494
495
|
}
|
package/dist/src/http/utils.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "http";
|
|
2
|
+
import type { AuthResult } from "../auth.js";
|
|
2
3
|
export declare function parseBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
|
3
4
|
export declare function json(res: ServerResponse, data: unknown, status?: number): void;
|
|
4
5
|
/**
|
|
@@ -9,3 +10,6 @@ export declare function json(res: ServerResponse, data: unknown, status?: number
|
|
|
9
10
|
*/
|
|
10
11
|
export declare function decodeJwtPayload(token: string): Record<string, unknown>;
|
|
11
12
|
export declare function safeEqual(a: string, b: string): boolean;
|
|
13
|
+
export declare function jsonAuthError(res: ServerResponse, authResult: Exclude<AuthResult, {
|
|
14
|
+
ok: true;
|
|
15
|
+
}>): void;
|
package/dist/src/http/utils.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { timingSafeEqual } from "crypto";
|
|
2
2
|
/**
|
|
3
3
|
* S1: shared HTTP helpers extracted from serve-http.ts.
|
|
4
|
-
* parseBody, json, decodeJwtPayload, safeEqual.
|
|
4
|
+
* parseBody, json, decodeJwtPayload, safeEqual, jsonAuthError.
|
|
5
5
|
*/
|
|
6
6
|
const MAX_BODY_BYTES = parseInt(process.env.COORDINATOR_MAX_BODY_BYTES || "1048576", 10);
|
|
7
7
|
export function parseBody(req) {
|
|
@@ -51,3 +51,9 @@ export function safeEqual(a, b) {
|
|
|
51
51
|
return false;
|
|
52
52
|
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
53
53
|
}
|
|
54
|
+
export function jsonAuthError(res, authResult) {
|
|
55
|
+
if (authResult.wwwAuthenticate) {
|
|
56
|
+
res.setHeader("WWW-Authenticate", authResult.wwwAuthenticate);
|
|
57
|
+
}
|
|
58
|
+
json(res, { error: authResult.error }, authResult.status);
|
|
59
|
+
}
|
|
@@ -15,6 +15,7 @@ export interface CategorizedImpact {
|
|
|
15
15
|
pass: ImpactScore[];
|
|
16
16
|
}
|
|
17
17
|
interface AnnounceParams {
|
|
18
|
+
org_id: string;
|
|
18
19
|
agent_id: string;
|
|
19
20
|
target_modules: string[];
|
|
20
21
|
target_files: string[];
|
|
@@ -31,5 +32,7 @@ export declare class ImpactScorer {
|
|
|
31
32
|
score(params: AnnounceParams): ImpactScore[];
|
|
32
33
|
categorize(params: AnnounceParams): CategorizedImpact;
|
|
33
34
|
private getRecentSymbolsForFile;
|
|
35
|
+
private _collectSymbolsTouched;
|
|
36
|
+
private _layer4Score;
|
|
34
37
|
}
|
|
35
38
|
export {};
|
|
@@ -21,7 +21,7 @@ export class ImpactScorer {
|
|
|
21
21
|
}
|
|
22
22
|
score(params) {
|
|
23
23
|
const onlineAgents = this.registry
|
|
24
|
-
.listOnline()
|
|
24
|
+
.listOnline(params.org_id)
|
|
25
25
|
.filter((a) => a.id !== params.agent_id);
|
|
26
26
|
if (onlineAgents.length === 0)
|
|
27
27
|
return [];
|
|
@@ -43,35 +43,16 @@ export class ImpactScorer {
|
|
|
43
43
|
...(params.depends_on_files || []),
|
|
44
44
|
];
|
|
45
45
|
const fileToAgents = filesToIndex.length > 0
|
|
46
|
-
? this.fileTracker.getFileToAgentsIndex(filesToIndex, params.agent_id, FILE_ACTIVITY_WINDOW_MINUTES)
|
|
46
|
+
? this.fileTracker.getFileToAgentsIndex(params.org_id, filesToIndex, params.agent_id, FILE_ACTIVITY_WINDOW_MINUTES)
|
|
47
47
|
: new Map();
|
|
48
48
|
const inFlightToAgents = this.workingFiles
|
|
49
|
-
? this.workingFiles.getIndex(filesToIndex, params.agent_id)
|
|
49
|
+
? this.workingFiles.getIndex(params.org_id, filesToIndex, params.agent_id)
|
|
50
50
|
: new Map();
|
|
51
51
|
// Pre-load symbols_touched for the target_files × online_agents matrix once,
|
|
52
52
|
// keyed by (file_path, agent_id). Avoids N*M DB roundtrips inside the score loop.
|
|
53
53
|
let symbolsByFileAgent = null;
|
|
54
54
|
if (params.target_symbols && params.target_symbols.length > 0 && params.target_files.length > 0) {
|
|
55
|
-
|
|
56
|
-
const placeholders = params.target_files.map(() => "?").join(",");
|
|
57
|
-
const rows = db.prepare(`SELECT agent_id, file_path, symbols_touched
|
|
58
|
-
FROM file_activity
|
|
59
|
-
WHERE file_path IN (${placeholders})
|
|
60
|
-
AND symbols_touched IS NOT NULL
|
|
61
|
-
AND id IN (
|
|
62
|
-
SELECT MAX(id) FROM file_activity
|
|
63
|
-
WHERE file_path IN (${placeholders})
|
|
64
|
-
AND symbols_touched IS NOT NULL
|
|
65
|
-
GROUP BY agent_id, file_path
|
|
66
|
-
)`).all(...params.target_files, ...params.target_files);
|
|
67
|
-
symbolsByFileAgent = new Map();
|
|
68
|
-
for (const r of rows) {
|
|
69
|
-
try {
|
|
70
|
-
const arr = JSON.parse(r.symbols_touched);
|
|
71
|
-
symbolsByFileAgent.set(`${r.file_path}|${r.agent_id}`, arr);
|
|
72
|
-
}
|
|
73
|
-
catch { /* malformed JSON: ignore */ }
|
|
74
|
-
}
|
|
55
|
+
symbolsByFileAgent = this._collectSymbolsTouched(params.org_id, params.target_files);
|
|
75
56
|
}
|
|
76
57
|
// O2: bound the resolved-thread query to a recency window. Without this,
|
|
77
58
|
// listThreads({status:'resolved'}) returns ALL historical resolved threads
|
|
@@ -81,9 +62,9 @@ export class ImpactScorer {
|
|
|
81
62
|
let activeThreadsByAgent = null;
|
|
82
63
|
if (this.consultation) {
|
|
83
64
|
const allActive = [
|
|
84
|
-
...this.consultation.listThreads({ status: "open" }),
|
|
85
|
-
...this.consultation.listThreads({ status: "resolving" }),
|
|
86
|
-
...this.consultation.listThreads({ status: "resolved", since_minutes: LAYER_0_WINDOW_MINUTES }),
|
|
65
|
+
...this.consultation.listThreads(params.org_id, { status: "open" }),
|
|
66
|
+
...this.consultation.listThreads(params.org_id, { status: "resolving" }),
|
|
67
|
+
...this.consultation.listThreads(params.org_id, { status: "resolved", since_minutes: LAYER_0_WINDOW_MINUTES }),
|
|
87
68
|
];
|
|
88
69
|
// Group by initiator_id so the per-agent loop is O(threads-for-this-agent)
|
|
89
70
|
// rather than O(all-active-threads). Avoids an outer-product scan over
|
|
@@ -180,29 +161,11 @@ export class ImpactScorer {
|
|
|
180
161
|
// Layer 4: git co-change. For each target_file F, find rows in git_cochange where
|
|
181
162
|
// (LEAST(F,partner), GREATEST(F,partner)) match. If the OTHER agent recently
|
|
182
163
|
// touched the partner file, apply the co-change score.
|
|
183
|
-
const db = getDb();
|
|
184
164
|
for (const targetFile of params.target_files) {
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const ratio = r.count / Math.max(r.total_commits, 1);
|
|
190
|
-
let layer4Score = 0;
|
|
191
|
-
if (ratio > 0.5)
|
|
192
|
-
layer4Score = 60;
|
|
193
|
-
else if (ratio > 0.2)
|
|
194
|
-
layer4Score = 40;
|
|
195
|
-
if (layer4Score === 0)
|
|
196
|
-
continue;
|
|
197
|
-
// Did the OTHER agent touch the partner file recently?
|
|
198
|
-
const partnerActivity = db.prepare(`SELECT 1 FROM file_activity
|
|
199
|
-
WHERE file_path = ? AND agent_id = ?
|
|
200
|
-
AND created_at > datetime('now', '-60 minutes')
|
|
201
|
-
LIMIT 1`).get(partner, agent.id);
|
|
202
|
-
if (partnerActivity) {
|
|
203
|
-
maxScore = Math.max(maxScore, layer4Score);
|
|
204
|
-
reasons.push(`co-change: ${targetFile} ↔ ${partner} (ratio ${ratio.toFixed(2)})`);
|
|
205
|
-
}
|
|
165
|
+
const layer4Results = this._layer4Score(params.org_id, targetFile, agent.id);
|
|
166
|
+
for (const result of layer4Results) {
|
|
167
|
+
maxScore = Math.max(maxScore, result.score);
|
|
168
|
+
reasons.push(result.reason);
|
|
206
169
|
}
|
|
207
170
|
}
|
|
208
171
|
return {
|
|
@@ -222,11 +185,11 @@ export class ImpactScorer {
|
|
|
222
185
|
pass: scores.filter((s) => s.score < 30),
|
|
223
186
|
};
|
|
224
187
|
}
|
|
225
|
-
getRecentSymbolsForFile(filePath, agentId) {
|
|
188
|
+
getRecentSymbolsForFile(orgId, filePath, agentId) {
|
|
226
189
|
const db = getDb();
|
|
227
190
|
const row = db.prepare(`SELECT symbols_touched FROM file_activity
|
|
228
|
-
WHERE agent_id = ? AND file_path = ? AND symbols_touched IS NOT NULL
|
|
229
|
-
ORDER BY id DESC LIMIT 1`).get(agentId, filePath);
|
|
191
|
+
WHERE org_id = ? AND agent_id = ? AND file_path = ? AND symbols_touched IS NOT NULL
|
|
192
|
+
ORDER BY id DESC LIMIT 1`).get(orgId, agentId, filePath);
|
|
230
193
|
if (!row || !row.symbols_touched)
|
|
231
194
|
return null;
|
|
232
195
|
try {
|
|
@@ -236,4 +199,55 @@ export class ImpactScorer {
|
|
|
236
199
|
return null;
|
|
237
200
|
}
|
|
238
201
|
}
|
|
202
|
+
_collectSymbolsTouched(orgId, files) {
|
|
203
|
+
const db = getDb();
|
|
204
|
+
const placeholders = files.map(() => "?").join(",");
|
|
205
|
+
const rows = db.prepare(`SELECT agent_id, file_path, symbols_touched
|
|
206
|
+
FROM file_activity
|
|
207
|
+
WHERE org_id = ?
|
|
208
|
+
AND file_path IN (${placeholders})
|
|
209
|
+
AND symbols_touched IS NOT NULL
|
|
210
|
+
AND id IN (
|
|
211
|
+
SELECT MAX(id) FROM file_activity
|
|
212
|
+
WHERE org_id = ?
|
|
213
|
+
AND file_path IN (${placeholders})
|
|
214
|
+
AND symbols_touched IS NOT NULL
|
|
215
|
+
GROUP BY agent_id, file_path
|
|
216
|
+
)`).all(orgId, ...files, orgId, ...files);
|
|
217
|
+
const result = new Map();
|
|
218
|
+
for (const r of rows) {
|
|
219
|
+
try {
|
|
220
|
+
const arr = JSON.parse(r.symbols_touched);
|
|
221
|
+
result.set(`${r.file_path}|${r.agent_id}`, arr);
|
|
222
|
+
}
|
|
223
|
+
catch { /* malformed JSON: ignore */ }
|
|
224
|
+
}
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
227
|
+
_layer4Score(orgId, targetFile, agentId) {
|
|
228
|
+
const db = getDb();
|
|
229
|
+
const rows = db.prepare(`SELECT file_a, file_b, count, total_commits FROM git_cochange
|
|
230
|
+
WHERE org_id = ? AND (file_a = ? OR file_b = ?)`).all(orgId, targetFile, targetFile);
|
|
231
|
+
const results = [];
|
|
232
|
+
for (const r of rows) {
|
|
233
|
+
const partner = r.file_a === targetFile ? r.file_b : r.file_a;
|
|
234
|
+
const ratio = r.count / Math.max(r.total_commits, 1);
|
|
235
|
+
let layer4Score = 0;
|
|
236
|
+
if (ratio > 0.5)
|
|
237
|
+
layer4Score = 60;
|
|
238
|
+
else if (ratio > 0.2)
|
|
239
|
+
layer4Score = 40;
|
|
240
|
+
if (layer4Score === 0)
|
|
241
|
+
continue;
|
|
242
|
+
// Did the OTHER agent touch the partner file recently?
|
|
243
|
+
const partnerActivity = db.prepare(`SELECT 1 FROM file_activity
|
|
244
|
+
WHERE org_id = ? AND file_path = ? AND agent_id = ?
|
|
245
|
+
AND created_at > datetime('now', '-60 minutes')
|
|
246
|
+
LIMIT 1`).get(orgId, partner, agentId);
|
|
247
|
+
if (partnerActivity) {
|
|
248
|
+
results.push({ score: layer4Score, reason: `co-change: ${targetFile} ↔ ${partner} (ratio ${ratio.toFixed(2)})` });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return results;
|
|
252
|
+
}
|
|
239
253
|
}
|
|
@@ -1,24 +1,30 @@
|
|
|
1
1
|
export interface IntrospectionRecord {
|
|
2
2
|
id: string;
|
|
3
|
+
org_id: string;
|
|
3
4
|
thread_id: string;
|
|
4
5
|
agent_id: string;
|
|
5
6
|
score: number;
|
|
6
7
|
reasons: string | null;
|
|
7
|
-
status: "pending" | "concerned" | "not_concerned";
|
|
8
|
+
status: "pending" | "concerned" | "not_concerned" | "responded";
|
|
8
9
|
response: string | null;
|
|
9
10
|
concerned: number;
|
|
10
11
|
created_at: string;
|
|
11
12
|
responded_at: string | null;
|
|
12
13
|
}
|
|
13
14
|
export declare class IntrospectionManager {
|
|
14
|
-
create(params: {
|
|
15
|
+
create(orgId: string, params: {
|
|
15
16
|
thread_id: string;
|
|
16
17
|
agent_id: string;
|
|
17
18
|
score: number;
|
|
18
|
-
reasons
|
|
19
|
+
reasons?: string | string[];
|
|
19
20
|
}): IntrospectionRecord;
|
|
20
|
-
respond(
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
respond(orgId: string, id: string, response: string): IntrospectionRecord | null;
|
|
22
|
+
/** Retrieve a single record by id (unscoped — internal helper only). */
|
|
23
|
+
private get;
|
|
24
|
+
/** Retrieve a single record scoped to an org. */
|
|
25
|
+
private getScoped;
|
|
26
|
+
getPending(orgId: string, agentId: string): IntrospectionRecord[];
|
|
27
|
+
list(orgId: string, threadId: string): IntrospectionRecord[];
|
|
28
|
+
/** @deprecated Use list(orgId, threadId) instead. Kept for backward compat. */
|
|
29
|
+
getByThread(orgId: string, threadId: string): IntrospectionRecord[];
|
|
24
30
|
}
|
|
@@ -1,28 +1,51 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
2
|
import { getDb } from "./database.js";
|
|
3
3
|
export class IntrospectionManager {
|
|
4
|
-
create(params) {
|
|
4
|
+
create(orgId, params) {
|
|
5
5
|
const db = getDb();
|
|
6
6
|
const id = randomUUID();
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
const reasons = params.reasons == null
|
|
8
|
+
? null
|
|
9
|
+
: Array.isArray(params.reasons)
|
|
10
|
+
? JSON.stringify(params.reasons)
|
|
11
|
+
: params.reasons;
|
|
12
|
+
db.prepare(`INSERT INTO introspections (id, org_id, thread_id, agent_id, score, reasons)
|
|
13
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(id, orgId, params.thread_id, params.agent_id, params.score, reasons);
|
|
9
14
|
return this.get(id);
|
|
10
15
|
}
|
|
11
|
-
respond(
|
|
16
|
+
respond(orgId, id, response) {
|
|
12
17
|
const db = getDb();
|
|
13
|
-
db.prepare(`UPDATE introspections SET
|
|
14
|
-
return this.
|
|
18
|
+
db.prepare(`UPDATE introspections SET response = ?, status = 'responded', responded_at = ? WHERE org_id = ? AND id = ?`).run(response, new Date().toISOString(), orgId, id);
|
|
19
|
+
return this.getScoped(orgId, id);
|
|
15
20
|
}
|
|
21
|
+
/** Retrieve a single record by id (unscoped — internal helper only). */
|
|
16
22
|
get(id) {
|
|
17
23
|
const db = getDb();
|
|
18
|
-
return db
|
|
24
|
+
return (db
|
|
25
|
+
.prepare("SELECT * FROM introspections WHERE id = ?")
|
|
26
|
+
.get(id) || null);
|
|
27
|
+
}
|
|
28
|
+
/** Retrieve a single record scoped to an org. */
|
|
29
|
+
getScoped(orgId, id) {
|
|
30
|
+
const db = getDb();
|
|
31
|
+
return (db
|
|
32
|
+
.prepare("SELECT * FROM introspections WHERE org_id = ? AND id = ?")
|
|
33
|
+
.get(orgId, id) || null);
|
|
19
34
|
}
|
|
20
|
-
getPending(agentId) {
|
|
35
|
+
getPending(orgId, agentId) {
|
|
21
36
|
const db = getDb();
|
|
22
|
-
return db
|
|
37
|
+
return db
|
|
38
|
+
.prepare("SELECT * FROM introspections WHERE org_id = ? AND agent_id = ? AND status = 'pending' ORDER BY created_at")
|
|
39
|
+
.all(orgId, agentId);
|
|
23
40
|
}
|
|
24
|
-
|
|
41
|
+
list(orgId, threadId) {
|
|
25
42
|
const db = getDb();
|
|
26
|
-
return db
|
|
43
|
+
return db
|
|
44
|
+
.prepare("SELECT * FROM introspections WHERE org_id = ? AND thread_id = ? ORDER BY created_at")
|
|
45
|
+
.all(orgId, threadId);
|
|
46
|
+
}
|
|
47
|
+
/** @deprecated Use list(orgId, threadId) instead. Kept for backward compat. */
|
|
48
|
+
getByThread(orgId, threadId) {
|
|
49
|
+
return this.list(orgId, threadId);
|
|
27
50
|
}
|
|
28
51
|
}
|
package/dist/src/metrics.js
CHANGED
|
@@ -145,7 +145,8 @@ export class Metrics {
|
|
|
145
145
|
*/
|
|
146
146
|
gaugeSnapshot(services) {
|
|
147
147
|
try {
|
|
148
|
-
|
|
148
|
+
// TODO(Task 23.5): thread real org_id from MCP session claims; for now MCP uses 'default' (cross-org leak window — single-tenant only)
|
|
149
|
+
this.agentsOnline.set(services.registry.listOnline("default").length);
|
|
149
150
|
}
|
|
150
151
|
catch {
|
|
151
152
|
// Registry not initialised yet (test bootstrap race) — leave gauge at 0.
|