mcp-coordinator 0.6.0 → 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 +86 -57
- 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 +106 -40
- 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 +26 -8
- 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 +4 -1
|
@@ -3,6 +3,7 @@ import { getDb } from "../database.js";
|
|
|
3
3
|
import { runCommonAnnounceFlow } from "../announce-workflow.js";
|
|
4
4
|
import { canResetDb } from "../reset-guard.js";
|
|
5
5
|
import { parseBody, json } from "./utils.js";
|
|
6
|
+
import { normalizePath } from "../path-normalize.js";
|
|
6
7
|
export async function handleRest(req, res, ctx) {
|
|
7
8
|
const { services, httpLog, authEnabled, getRunConfig, setRunConfig } = ctx;
|
|
8
9
|
const url = req.url || "";
|
|
@@ -30,14 +31,14 @@ export async function handleRest(req, res, ctx) {
|
|
|
30
31
|
const { registry, activityTracker, consultation, fileTracker, introspection, sseEmitter, mqttBridge, quotaCache } = services;
|
|
31
32
|
if (url === "/api/register") {
|
|
32
33
|
const { agent_id, name, modules } = body;
|
|
33
|
-
const agent = registry.register(agent_id, name, modules || []);
|
|
34
|
-
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 });
|
|
35
36
|
json(res, agent);
|
|
36
37
|
}
|
|
37
38
|
else if (url === "/api/session-start") {
|
|
38
|
-
const online = registry.listOnline();
|
|
39
|
-
const openThreads = consultation.listThreads({ status: "open" });
|
|
40
|
-
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);
|
|
41
42
|
const briefing = [
|
|
42
43
|
`Agents en ligne: ${online.map((a) => a.name).join(", ") || "aucun"}`,
|
|
43
44
|
`Consultations ouvertes: ${openThreads.length}`,
|
|
@@ -47,15 +48,15 @@ export async function handleRest(req, res, ctx) {
|
|
|
47
48
|
}
|
|
48
49
|
else if (url === "/api/session-stop") {
|
|
49
50
|
const { agent_id } = body;
|
|
50
|
-
registry.setOffline(agent_id);
|
|
51
|
-
activityTracker.reportOffline(agent_id);
|
|
51
|
+
registry.setOffline(ctx.claims.org, agent_id);
|
|
52
|
+
activityTracker.reportOffline(ctx.claims.org, agent_id);
|
|
52
53
|
consultation.handleAgentDeparture(agent_id);
|
|
53
|
-
sseEmitter.emit("agent_offline", { agent_id });
|
|
54
|
+
sseEmitter.emit("agent_offline", { agent_id }, { org_id: ctx.claims.org });
|
|
54
55
|
json(res, { ok: true });
|
|
55
56
|
}
|
|
56
57
|
else if (url === "/api/check-conflict") {
|
|
57
58
|
const { file, agent_id } = body;
|
|
58
|
-
const conflict = fileTracker.checkFileConflict(file, agent_id, 30);
|
|
59
|
+
const conflict = fileTracker.checkFileConflict(ctx.claims.org, file, agent_id, 30);
|
|
59
60
|
const warnings = [];
|
|
60
61
|
if (conflict.conflict) {
|
|
61
62
|
warnings.push(`File ${file} recently edited by: ${conflict.agents.join(", ")}`);
|
|
@@ -64,20 +65,20 @@ export async function handleRest(req, res, ctx) {
|
|
|
64
65
|
}
|
|
65
66
|
else if (url === "/api/log-file") {
|
|
66
67
|
const { session_id, agent_id, agent_name, tool_name, file } = body;
|
|
67
|
-
fileTracker.log({ session_id, agent_id, agent_name, tool_name, file_path: file });
|
|
68
|
-
activityTracker.reportFileActivity(agent_id, file);
|
|
69
|
-
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 });
|
|
70
71
|
json(res, { ok: true });
|
|
71
72
|
}
|
|
72
73
|
else if (url === "/api/announce") {
|
|
73
74
|
const { agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to, target_symbols } = body;
|
|
74
|
-
const thread = consultation.announceWork({ agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to });
|
|
75
|
-
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);
|
|
76
77
|
// S2 fix: shared workflow (impact scoring, override respondents, auto-resolve,
|
|
77
78
|
// impact_scored + introspection SSE, plan-quality downgrade event). Same
|
|
78
79
|
// function used by the MCP announce_work tool path.
|
|
79
80
|
const { updated, categorized, respondents, planQuality } = runCommonAnnounceFlow(services, thread.id, {
|
|
80
|
-
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,
|
|
81
82
|
target_symbols,
|
|
82
83
|
});
|
|
83
84
|
// REST-specific thread_opened SSE shape (different field set than MCP — kept
|
|
@@ -90,7 +91,7 @@ export async function handleRest(req, res, ctx) {
|
|
|
90
91
|
mode: planQuality.mode,
|
|
91
92
|
plan: plan || null,
|
|
92
93
|
plan_quality: planQuality,
|
|
93
|
-
});
|
|
94
|
+
}, { org_id: ctx.claims.org });
|
|
94
95
|
json(res, { thread_id: thread.id, status: updated.status, impact: categorized });
|
|
95
96
|
}
|
|
96
97
|
else if (url === "/api/post-to-thread") {
|
|
@@ -98,7 +99,7 @@ export async function handleRest(req, res, ctx) {
|
|
|
98
99
|
// Pre-check the thread so we can return actionable status codes instead
|
|
99
100
|
// of always-500 on any error. The client uses the status to decide
|
|
100
101
|
// whether to warn (unexpected) or silently skip (normal race).
|
|
101
|
-
const targetThread = consultation.getThread(thread_id);
|
|
102
|
+
const targetThread = consultation.getThread(ctx.claims.org, thread_id);
|
|
102
103
|
if (!targetThread) {
|
|
103
104
|
json(res, { error: "thread_not_found", thread_id }, 404);
|
|
104
105
|
return;
|
|
@@ -107,20 +108,20 @@ export async function handleRest(req, res, ctx) {
|
|
|
107
108
|
json(res, { error: "thread_cancelled", thread_id }, 410);
|
|
108
109
|
return;
|
|
109
110
|
}
|
|
110
|
-
const msg = consultation.postToThread({ thread_id, agent_id, agent_name, type, content });
|
|
111
|
-
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);
|
|
112
113
|
sseEmitter.emit("message_posted", {
|
|
113
114
|
thread_id, agent_id, agent_name: agent_name || agent_id,
|
|
114
115
|
type, content, round: thread?.round || 1,
|
|
115
116
|
token_estimate: msg.token_estimate || 0,
|
|
116
|
-
});
|
|
117
|
+
}, { org_id: ctx.claims.org });
|
|
117
118
|
json(res, msg);
|
|
118
119
|
}
|
|
119
120
|
else if (url === "/api/token-usage") {
|
|
120
121
|
// Agent → coordinator telemetry, emitted once per LLM turn so the dashboard
|
|
121
122
|
// and reports can pinpoint where tokens are being burned.
|
|
122
123
|
const payload = body;
|
|
123
|
-
sseEmitter.emit("token_usage", payload);
|
|
124
|
+
sseEmitter.emit("token_usage", payload, { org_id: ctx.claims.org });
|
|
124
125
|
json(res, { ok: true });
|
|
125
126
|
}
|
|
126
127
|
else if (url === "/api/unclaim-task") {
|
|
@@ -135,12 +136,12 @@ export async function handleRest(req, res, ctx) {
|
|
|
135
136
|
// claim → no DONE → unclaim → re-claim loop we observed on stuck tasks.
|
|
136
137
|
// Only the claiming agent can unclaim to prevent cross-agent interference.
|
|
137
138
|
const POISON_THRESHOLD = 2;
|
|
138
|
-
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);
|
|
139
140
|
let poisoned = false;
|
|
140
141
|
if (result.changes === 1) {
|
|
141
|
-
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);
|
|
142
143
|
if (row && (row.unclaim_count ?? 0) >= POISON_THRESHOLD) {
|
|
143
|
-
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);
|
|
144
145
|
poisoned = true;
|
|
145
146
|
httpLog.warn({ thread_id, unclaim_count: row.unclaim_count }, "thread poisoned after repeated unclaims");
|
|
146
147
|
}
|
|
@@ -158,14 +159,14 @@ export async function handleRest(req, res, ctx) {
|
|
|
158
159
|
// automatically because the status filter excludes them.
|
|
159
160
|
// Directed-dispatch constraint: if assigned_to is set, only that specific
|
|
160
161
|
// agent can claim; NULL keeps the original open-pool semantics.
|
|
161
|
-
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);
|
|
162
163
|
if (result.changes === 1) {
|
|
163
164
|
mqttBridge.publishTaskClaimed(thread_id, agent_id);
|
|
164
|
-
sseEmitter.emit("task_claimed", { thread_id, agent_id });
|
|
165
|
+
sseEmitter.emit("task_claimed", { thread_id, agent_id }, { org_id: ctx.claims.org });
|
|
165
166
|
json(res, { success: true });
|
|
166
167
|
}
|
|
167
168
|
else {
|
|
168
|
-
const thread = consultation.getThread(thread_id);
|
|
169
|
+
const thread = consultation.getThread(ctx.claims.org, thread_id);
|
|
169
170
|
// Surface the assigned_to in the 'why not' response so clients can
|
|
170
171
|
// distinguish "already claimed by X" from "reserved for Y".
|
|
171
172
|
json(res, {
|
|
@@ -178,24 +179,24 @@ export async function handleRest(req, res, ctx) {
|
|
|
178
179
|
}
|
|
179
180
|
else if (url === "/api/propose-resolution") {
|
|
180
181
|
const { thread_id, agent_id, summary } = body;
|
|
181
|
-
const agentInfo = registry.get(agent_id);
|
|
182
|
-
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);
|
|
183
184
|
sseEmitter.emit("resolution_proposed", {
|
|
184
185
|
thread_id, agent_id, agent_name: agentInfo?.name || agent_id, summary,
|
|
185
|
-
});
|
|
186
|
-
json(res, consultation.getThread(thread_id));
|
|
186
|
+
}, { org_id: ctx.claims.org });
|
|
187
|
+
json(res, consultation.getThread(ctx.claims.org, thread_id));
|
|
187
188
|
mqttBridge.publishTaskCompleted(thread_id, agent_id, summary);
|
|
188
189
|
}
|
|
189
190
|
else if (url === "/api/approve-resolution") {
|
|
190
191
|
const { thread_id, agent_id } = body;
|
|
191
|
-
const agentInfo = registry.get(agent_id);
|
|
192
|
-
consultation.approveResolution(thread_id, agent_id, agentInfo?.name);
|
|
193
|
-
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);
|
|
194
195
|
json(res, t);
|
|
195
196
|
}
|
|
196
197
|
else if (url?.startsWith("/api/consultation/") && url?.endsWith("/status")) {
|
|
197
198
|
const threadId = url.split("/")[3];
|
|
198
|
-
const thread = consultation.getThreadWithMessages(threadId);
|
|
199
|
+
const thread = consultation.getThreadWithMessages(ctx.claims.org, threadId);
|
|
199
200
|
if (!thread) {
|
|
200
201
|
json(res, { error: "not found" }, 404);
|
|
201
202
|
}
|
|
@@ -209,13 +210,13 @@ export async function handleRest(req, res, ctx) {
|
|
|
209
210
|
}
|
|
210
211
|
}
|
|
211
212
|
else if (url === "/api/threads-active") {
|
|
212
|
-
const open = consultation.listThreads({ status: "open" });
|
|
213
|
-
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" });
|
|
214
215
|
json(res, [...open, ...resolving]);
|
|
215
216
|
}
|
|
216
217
|
else if (url === "/api/hot-files") {
|
|
217
218
|
const { since_minutes } = body;
|
|
218
|
-
json(res, fileTracker.getHotFiles(since_minutes || 30));
|
|
219
|
+
json(res, fileTracker.getHotFiles(ctx.claims.org, since_minutes || 30));
|
|
219
220
|
}
|
|
220
221
|
else if (url === "/api/quota") {
|
|
221
222
|
// Pre-flight + live widget endpoint. 200 with fresh QuotaInfo when the
|
|
@@ -266,38 +267,38 @@ export async function handleRest(req, res, ctx) {
|
|
|
266
267
|
}
|
|
267
268
|
else if (url === "/api/introspection-response") {
|
|
268
269
|
const { introspection_id, concerned, reason } = body;
|
|
269
|
-
const intro = introspection.respond(
|
|
270
|
+
const intro = introspection.respond(ctx.claims.org, introspection_id, reason);
|
|
270
271
|
// If concerned, add to thread's expected_respondents
|
|
271
272
|
if (concerned && intro) {
|
|
272
273
|
const db = getDb();
|
|
273
|
-
const thread = consultation.getThread(intro.thread_id);
|
|
274
|
+
const thread = consultation.getThread(ctx.claims.org, intro.thread_id);
|
|
274
275
|
if (thread && (thread.status === "open" || thread.status === "resolving")) {
|
|
275
276
|
const respondents = JSON.parse(thread.expected_respondents || "[]");
|
|
276
277
|
if (!respondents.includes(intro.agent_id)) {
|
|
277
278
|
respondents.push(intro.agent_id);
|
|
278
|
-
db.prepare("UPDATE threads SET expected_respondents = ? WHERE id = ?")
|
|
279
|
-
.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);
|
|
280
281
|
}
|
|
281
282
|
}
|
|
282
283
|
}
|
|
283
|
-
const agentInfo = registry.get(intro?.agent_id || "");
|
|
284
|
+
const agentInfo = registry.get(ctx.claims.org, intro?.agent_id || "");
|
|
284
285
|
sseEmitter.emit("introspection_completed", {
|
|
285
286
|
introspection_id, thread_id: intro?.thread_id,
|
|
286
287
|
agent_id: intro?.agent_id, agent_name: agentInfo?.name || intro?.agent_id,
|
|
287
288
|
concerned, reason,
|
|
288
|
-
});
|
|
289
|
+
}, { org_id: ctx.claims.org });
|
|
289
290
|
json(res, intro);
|
|
290
291
|
}
|
|
291
292
|
else if (url?.startsWith("/api/pending-introspections")) {
|
|
292
293
|
const urlObj = new URL(url, "http://localhost");
|
|
293
294
|
const agent_id = urlObj.searchParams.get("agent_id") || "";
|
|
294
|
-
const pending = introspection.getPending(agent_id);
|
|
295
|
+
const pending = introspection.getPending(ctx.claims.org, agent_id);
|
|
295
296
|
json(res, pending);
|
|
296
297
|
}
|
|
297
298
|
else if (url === "/api/run-config") {
|
|
298
299
|
if (req.method === "POST") {
|
|
299
300
|
setRunConfig(body);
|
|
300
|
-
sseEmitter.emit("run_config", getRunConfig());
|
|
301
|
+
sseEmitter.emit("run_config", getRunConfig(), { org_id: ctx.claims.org });
|
|
301
302
|
json(res, { ok: true });
|
|
302
303
|
}
|
|
303
304
|
else {
|
|
@@ -337,8 +338,8 @@ export async function handleRest(req, res, ctx) {
|
|
|
337
338
|
// Covers both open threads (waiting for initial response) and resolving threads
|
|
338
339
|
// (waiting for approval/contest of a proposed resolution).
|
|
339
340
|
const pendingThreads = [
|
|
340
|
-
...consultation.listThreads({ status: "open" }),
|
|
341
|
-
...consultation.listThreads({ status: "resolving" }),
|
|
341
|
+
...consultation.listThreads(ctx.claims.org, { status: "open" }),
|
|
342
|
+
...consultation.listThreads(ctx.claims.org, { status: "resolving" }),
|
|
342
343
|
].filter((t) => {
|
|
343
344
|
const respondents = JSON.parse(t.expected_respondents || "[]");
|
|
344
345
|
return respondents.includes(agent_id);
|
|
@@ -359,12 +360,12 @@ export async function handleRest(req, res, ctx) {
|
|
|
359
360
|
}
|
|
360
361
|
else if (url?.startsWith("/api/agent-status/")) {
|
|
361
362
|
const aid = url.split("/")[3];
|
|
362
|
-
const agent = registry.get(aid);
|
|
363
|
+
const agent = registry.get(ctx.claims.org, aid);
|
|
363
364
|
if (!agent) {
|
|
364
365
|
json(res, { registered: false, status: "unknown" });
|
|
365
366
|
}
|
|
366
367
|
else {
|
|
367
|
-
const activity = activityTracker.getActivity(aid, { idleAfterMinutes: 5 });
|
|
368
|
+
const activity = activityTracker.getActivity(ctx.claims.org, aid, { idleAfterMinutes: 5 });
|
|
368
369
|
json(res, { registered: true, status: agent.status, activity: activity.activity_status });
|
|
369
370
|
}
|
|
370
371
|
}
|
|
@@ -378,6 +379,15 @@ export async function handleRest(req, res, ctx) {
|
|
|
378
379
|
json(res, { error: "agent_name must be string when present" }, 400);
|
|
379
380
|
return;
|
|
380
381
|
}
|
|
382
|
+
const repoRoot = process.env.COORDINATOR_REPO_ROOT || null;
|
|
383
|
+
let filePath;
|
|
384
|
+
try {
|
|
385
|
+
filePath = normalizePath(repoRoot, body.file_path);
|
|
386
|
+
}
|
|
387
|
+
catch (err) {
|
|
388
|
+
json(res, { error: `invalid file_path: ${err.message}` }, 400);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
381
391
|
const MAX_CONTENT = 262144;
|
|
382
392
|
let symbols = null;
|
|
383
393
|
let contentHash = null;
|
|
@@ -387,14 +397,15 @@ export async function handleRest(req, res, ctx) {
|
|
|
387
397
|
return;
|
|
388
398
|
}
|
|
389
399
|
contentHash = createHash("sha256").update(body.content).digest("hex");
|
|
390
|
-
symbols = ctx.services.treeSitter.extract(
|
|
400
|
+
symbols = ctx.services.treeSitter.extract(filePath, body.content, null);
|
|
391
401
|
}
|
|
392
402
|
ctx.services.fileTracker.log({
|
|
403
|
+
org_id: ctx.claims.org,
|
|
393
404
|
session_id: body.session_id,
|
|
394
405
|
agent_id: body.agent_id,
|
|
395
406
|
agent_name: body.agent_name,
|
|
396
407
|
tool_name: body.tool_name,
|
|
397
|
-
file_path:
|
|
408
|
+
file_path: filePath,
|
|
398
409
|
content_hash: contentHash,
|
|
399
410
|
symbols_touched: symbols,
|
|
400
411
|
});
|
|
@@ -405,8 +416,17 @@ export async function handleRest(req, res, ctx) {
|
|
|
405
416
|
json(res, { error: "agent_id and file_path required" }, 400);
|
|
406
417
|
return;
|
|
407
418
|
}
|
|
419
|
+
const repoRoot = process.env.COORDINATOR_REPO_ROOT || null;
|
|
420
|
+
let filePath;
|
|
421
|
+
try {
|
|
422
|
+
filePath = normalizePath(repoRoot, body.file_path);
|
|
423
|
+
}
|
|
424
|
+
catch (err) {
|
|
425
|
+
json(res, { error: `invalid file_path: ${err.message}` }, 400);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
408
428
|
const ttl = parseInt(process.env.COORDINATOR_WORKING_FILES_TTL_MIN || "30", 10);
|
|
409
|
-
services.workingFiles.start(
|
|
429
|
+
services.workingFiles.start(ctx.claims.org, body.agent_id, filePath, ttl);
|
|
410
430
|
json(res, { ok: true });
|
|
411
431
|
}
|
|
412
432
|
else if (url === "/api/working-files/stop" && req.method === "POST") {
|
|
@@ -414,7 +434,16 @@ export async function handleRest(req, res, ctx) {
|
|
|
414
434
|
json(res, { error: "agent_id and file_path required" }, 400);
|
|
415
435
|
return;
|
|
416
436
|
}
|
|
417
|
-
|
|
437
|
+
const repoRoot = process.env.COORDINATOR_REPO_ROOT || null;
|
|
438
|
+
let filePath;
|
|
439
|
+
try {
|
|
440
|
+
filePath = normalizePath(repoRoot, body.file_path);
|
|
441
|
+
}
|
|
442
|
+
catch (err) {
|
|
443
|
+
json(res, { error: `invalid file_path: ${err.message}` }, 400);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
services.workingFiles.stop(ctx.claims.org, body.agent_id, filePath);
|
|
418
447
|
json(res, { ok: true });
|
|
419
448
|
}
|
|
420
449
|
else if (url?.startsWith("/api/scoring-stats") && req.method === "GET") {
|
|
@@ -455,12 +484,12 @@ export async function handleRest(req, res, ctx) {
|
|
|
455
484
|
});
|
|
456
485
|
}
|
|
457
486
|
else if (url === "/api/status") {
|
|
458
|
-
const online = registry.listOnline();
|
|
459
|
-
const openThreads = consultation.listThreads({ status: "open" });
|
|
487
|
+
const online = registry.listOnline(ctx.claims.org);
|
|
488
|
+
const openThreads = consultation.listThreads(ctx.claims.org, { status: "open" });
|
|
460
489
|
json(res, {
|
|
461
490
|
online: online.length,
|
|
462
491
|
open_threads: openThreads.length,
|
|
463
|
-
hot_files: fileTracker.getHotFiles(30).length,
|
|
492
|
+
hot_files: fileTracker.getHotFiles(ctx.claims.org, 30).length,
|
|
464
493
|
mqtt: services.mqttBridge.isConnected(),
|
|
465
494
|
});
|
|
466
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
|
}
|