mcp-coordinator 0.4.0 → 0.5.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 +14 -0
- package/dashboard/public/index.html +23 -0
- package/dist/cli/server/start.js +33 -0
- package/dist/src/announce-workflow.d.ts +1 -0
- package/dist/src/announce-workflow.js +28 -0
- package/dist/src/database.js +65 -0
- package/dist/src/file-tracker.d.ts +2 -0
- package/dist/src/file-tracker.js +3 -2
- package/dist/src/git-cochange-builder.d.ts +32 -0
- package/dist/src/git-cochange-builder.js +238 -0
- package/dist/src/http/handle-health.d.ts +1 -1
- package/dist/src/http/handle-health.js +26 -0
- package/dist/src/http/handle-rest.js +83 -2
- package/dist/src/http/utils.d.ts +0 -4
- package/dist/src/http/utils.js +16 -2
- package/dist/src/impact-scorer.d.ts +5 -1
- package/dist/src/impact-scorer.js +98 -8
- package/dist/src/metrics.d.ts +5 -0
- package/dist/src/metrics.js +33 -0
- package/dist/src/path-normalize.d.ts +17 -0
- package/dist/src/path-normalize.js +38 -0
- package/dist/src/serve-http.js +41 -2
- package/dist/src/server-setup.d.ts +6 -0
- package/dist/src/server-setup.js +23 -3
- package/dist/src/tools/consultation-tools.js +4 -2
- package/dist/src/tree-sitter-extractor.d.ts +36 -0
- package/dist/src/tree-sitter-extractor.js +354 -0
- package/dist/src/working-files-tracker.d.ts +42 -0
- package/dist/src/working-files-tracker.js +111 -0
- package/package.json +18 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
1
2
|
import { getDb } from "../database.js";
|
|
2
3
|
import { runCommonAnnounceFlow } from "../announce-workflow.js";
|
|
3
4
|
import { canResetDb } from "../reset-guard.js";
|
|
@@ -5,7 +6,15 @@ import { parseBody, json } from "./utils.js";
|
|
|
5
6
|
export async function handleRest(req, res, ctx) {
|
|
6
7
|
const { services, httpLog, authEnabled, getRunConfig, setRunConfig } = ctx;
|
|
7
8
|
const url = req.url || "";
|
|
8
|
-
|
|
9
|
+
let body;
|
|
10
|
+
try {
|
|
11
|
+
body = await parseBody(req);
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
const e = err;
|
|
15
|
+
json(res, { error: e.message || "Invalid request" }, e.statusCode || 400);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
9
18
|
const agentId = body.agent_id;
|
|
10
19
|
// Dashboard/work-stealing polls these endpoints every few seconds — demote to debug
|
|
11
20
|
// to keep the info log focused on coordination events (announce, claim, resolve, etc).
|
|
@@ -61,7 +70,7 @@ export async function handleRest(req, res, ctx) {
|
|
|
61
70
|
json(res, { ok: true });
|
|
62
71
|
}
|
|
63
72
|
else if (url === "/api/announce") {
|
|
64
|
-
const { agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to } = body;
|
|
73
|
+
const { agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to, target_symbols } = body;
|
|
65
74
|
const thread = consultation.announceWork({ agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to });
|
|
66
75
|
const agentInfo = registry.get(agent_id);
|
|
67
76
|
// S2 fix: shared workflow (impact scoring, override respondents, auto-resolve,
|
|
@@ -69,6 +78,7 @@ export async function handleRest(req, res, ctx) {
|
|
|
69
78
|
// function used by the MCP announce_work tool path.
|
|
70
79
|
const { updated, categorized, respondents, planQuality } = runCommonAnnounceFlow(services, thread.id, {
|
|
71
80
|
agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open,
|
|
81
|
+
target_symbols,
|
|
72
82
|
});
|
|
73
83
|
// REST-specific thread_opened SSE shape (different field set than MCP — kept
|
|
74
84
|
// divergent because consumers may depend on this exact contract).
|
|
@@ -358,6 +368,77 @@ export async function handleRest(req, res, ctx) {
|
|
|
358
368
|
json(res, { registered: true, status: agent.status, activity: activity.activity_status });
|
|
359
369
|
}
|
|
360
370
|
}
|
|
371
|
+
else if (url === "/api/file-activity" && req.method === "POST") {
|
|
372
|
+
if (typeof body.session_id !== "string" || typeof body.agent_id !== "string"
|
|
373
|
+
|| typeof body.tool_name !== "string" || typeof body.file_path !== "string") {
|
|
374
|
+
json(res, { error: "missing required fields" }, 400);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (body.agent_name !== undefined && typeof body.agent_name !== "string") {
|
|
378
|
+
json(res, { error: "agent_name must be string when present" }, 400);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const MAX_CONTENT = 262144;
|
|
382
|
+
let symbols = null;
|
|
383
|
+
let contentHash = null;
|
|
384
|
+
if (typeof body.content === "string") {
|
|
385
|
+
if (body.content.length > MAX_CONTENT) {
|
|
386
|
+
json(res, { error: "content exceeds 256 KB" }, 400);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
contentHash = createHash("sha256").update(body.content).digest("hex");
|
|
390
|
+
symbols = ctx.services.treeSitter.extract(body.file_path, body.content, null);
|
|
391
|
+
}
|
|
392
|
+
ctx.services.fileTracker.log({
|
|
393
|
+
session_id: body.session_id,
|
|
394
|
+
agent_id: body.agent_id,
|
|
395
|
+
agent_name: body.agent_name,
|
|
396
|
+
tool_name: body.tool_name,
|
|
397
|
+
file_path: body.file_path,
|
|
398
|
+
content_hash: contentHash,
|
|
399
|
+
symbols_touched: symbols,
|
|
400
|
+
});
|
|
401
|
+
json(res, { ok: true });
|
|
402
|
+
}
|
|
403
|
+
else if (url === "/api/working-files/start" && req.method === "POST") {
|
|
404
|
+
if (typeof body.agent_id !== "string" || typeof body.file_path !== "string") {
|
|
405
|
+
json(res, { error: "agent_id and file_path required" }, 400);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const ttl = parseInt(process.env.COORDINATOR_WORKING_FILES_TTL_MIN || "30", 10);
|
|
409
|
+
services.workingFiles.start(body.agent_id, body.file_path, ttl);
|
|
410
|
+
json(res, { ok: true });
|
|
411
|
+
}
|
|
412
|
+
else if (url === "/api/working-files/stop" && req.method === "POST") {
|
|
413
|
+
if (typeof body.agent_id !== "string" || typeof body.file_path !== "string") {
|
|
414
|
+
json(res, { error: "agent_id and file_path required" }, 400);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
services.workingFiles.stop(body.agent_id, body.file_path);
|
|
418
|
+
json(res, { ok: true });
|
|
419
|
+
}
|
|
420
|
+
else if (url?.startsWith("/api/scoring-stats") && req.method === "GET") {
|
|
421
|
+
const u = new URL(url, "http://localhost");
|
|
422
|
+
const sinceParam = u.searchParams.get("since") || "24h";
|
|
423
|
+
const sinceMin = sinceParam.endsWith("h") ? parseInt(sinceParam) * 60
|
|
424
|
+
: sinceParam.endsWith("d") ? parseInt(sinceParam) * 60 * 24
|
|
425
|
+
: 60 * 24;
|
|
426
|
+
const db = getDb();
|
|
427
|
+
const layers = db.prepare(`SELECT layer, COUNT(*) AS fire_count, AVG(score) AS avg_score
|
|
428
|
+
FROM layer_firings
|
|
429
|
+
WHERE fired_at > datetime('now', '-' || ? || ' minutes')
|
|
430
|
+
GROUP BY layer
|
|
431
|
+
ORDER BY fire_count DESC`).all(sinceMin);
|
|
432
|
+
json(res, {
|
|
433
|
+
window: { since: sinceParam, now: new Date().toISOString() },
|
|
434
|
+
layers: layers.map(l => ({
|
|
435
|
+
layer: l.layer,
|
|
436
|
+
fire_count: l.fire_count,
|
|
437
|
+
avg_score: l.avg_score,
|
|
438
|
+
outcomes: { auto_resolved: 0, consensus: 0, timeout: 0, cancelled: 0 },
|
|
439
|
+
})),
|
|
440
|
+
});
|
|
441
|
+
}
|
|
361
442
|
else if (url === "/api/status") {
|
|
362
443
|
const online = registry.listOnline();
|
|
363
444
|
const openThreads = consultation.listThreads({ status: "open" });
|
package/dist/src/http/utils.d.ts
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "http";
|
|
2
|
-
/**
|
|
3
|
-
* S1: shared HTTP helpers extracted from serve-http.ts.
|
|
4
|
-
* parseBody, json, decodeJwtPayload, safeEqual.
|
|
5
|
-
*/
|
|
6
2
|
export declare function parseBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
|
7
3
|
export declare function json(res: ServerResponse, data: unknown, status?: number): void;
|
|
8
4
|
/**
|
package/dist/src/http/utils.js
CHANGED
|
@@ -3,11 +3,25 @@ import { timingSafeEqual } from "crypto";
|
|
|
3
3
|
* S1: shared HTTP helpers extracted from serve-http.ts.
|
|
4
4
|
* parseBody, json, decodeJwtPayload, safeEqual.
|
|
5
5
|
*/
|
|
6
|
+
const MAX_BODY_BYTES = parseInt(process.env.COORDINATOR_MAX_BODY_BYTES || "1048576", 10);
|
|
6
7
|
export function parseBody(req) {
|
|
7
8
|
return new Promise((resolve, reject) => {
|
|
8
|
-
let
|
|
9
|
-
|
|
9
|
+
let bytes = 0;
|
|
10
|
+
const chunks = [];
|
|
11
|
+
req.on("data", (chunk) => {
|
|
12
|
+
bytes += chunk.length;
|
|
13
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
14
|
+
const err = new Error("Payload too large");
|
|
15
|
+
err.statusCode = 413;
|
|
16
|
+
// destroy() may not exist on every IncomingMessage-like input (test stub).
|
|
17
|
+
req.destroy?.(err);
|
|
18
|
+
reject(err);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
chunks.push(chunk);
|
|
22
|
+
});
|
|
10
23
|
req.on("end", () => {
|
|
24
|
+
const body = Buffer.concat(chunks).toString("utf-8");
|
|
11
25
|
try {
|
|
12
26
|
resolve(body ? JSON.parse(body) : {});
|
|
13
27
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AgentRegistry } from "./agent-registry.js";
|
|
2
2
|
import type { FileTracker } from "./file-tracker.js";
|
|
3
3
|
import type { Consultation } from "./consultation.js";
|
|
4
|
+
import type { WorkingFilesTracker } from "./working-files-tracker.js";
|
|
4
5
|
export interface ImpactScore {
|
|
5
6
|
agent_id: string;
|
|
6
7
|
agent_name: string;
|
|
@@ -19,13 +20,16 @@ interface AnnounceParams {
|
|
|
19
20
|
target_files: string[];
|
|
20
21
|
depends_on_files?: string[];
|
|
21
22
|
exports_affected?: string[];
|
|
23
|
+
target_symbols?: string[];
|
|
22
24
|
}
|
|
23
25
|
export declare class ImpactScorer {
|
|
24
26
|
private registry;
|
|
25
27
|
private fileTracker;
|
|
26
28
|
private consultation?;
|
|
27
|
-
|
|
29
|
+
private workingFiles?;
|
|
30
|
+
constructor(registry: AgentRegistry, fileTracker: FileTracker, consultation?: Consultation | undefined, workingFiles?: WorkingFilesTracker | undefined);
|
|
28
31
|
score(params: AnnounceParams): ImpactScore[];
|
|
29
32
|
categorize(params: AnnounceParams): CategorizedImpact;
|
|
33
|
+
private getRecentSymbolsForFile;
|
|
30
34
|
}
|
|
31
35
|
export {};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getDb } from "./database.js";
|
|
1
2
|
// Layer 0 (announced-intent) recency window. Resolved threads older than this
|
|
2
3
|
// are excluded — yesterday's resolved work shouldn't trigger today's scoring.
|
|
3
4
|
// Aligned with file-tracker's default conflict window per the audit guidance.
|
|
@@ -11,10 +12,12 @@ export class ImpactScorer {
|
|
|
11
12
|
registry;
|
|
12
13
|
fileTracker;
|
|
13
14
|
consultation;
|
|
14
|
-
|
|
15
|
+
workingFiles;
|
|
16
|
+
constructor(registry, fileTracker, consultation, workingFiles) {
|
|
15
17
|
this.registry = registry;
|
|
16
18
|
this.fileTracker = fileTracker;
|
|
17
19
|
this.consultation = consultation;
|
|
20
|
+
this.workingFiles = workingFiles;
|
|
18
21
|
}
|
|
19
22
|
score(params) {
|
|
20
23
|
const onlineAgents = this.registry
|
|
@@ -42,6 +45,34 @@ export class ImpactScorer {
|
|
|
42
45
|
const fileToAgents = filesToIndex.length > 0
|
|
43
46
|
? this.fileTracker.getFileToAgentsIndex(filesToIndex, params.agent_id, FILE_ACTIVITY_WINDOW_MINUTES)
|
|
44
47
|
: new Map();
|
|
48
|
+
const inFlightToAgents = this.workingFiles
|
|
49
|
+
? this.workingFiles.getIndex(filesToIndex, params.agent_id)
|
|
50
|
+
: new Map();
|
|
51
|
+
// Pre-load symbols_touched for the target_files × online_agents matrix once,
|
|
52
|
+
// keyed by (file_path, agent_id). Avoids N*M DB roundtrips inside the score loop.
|
|
53
|
+
let symbolsByFileAgent = null;
|
|
54
|
+
if (params.target_symbols && params.target_symbols.length > 0 && params.target_files.length > 0) {
|
|
55
|
+
const db = getDb();
|
|
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
|
+
}
|
|
75
|
+
}
|
|
45
76
|
// O2: bound the resolved-thread query to a recency window. Without this,
|
|
46
77
|
// listThreads({status:'resolved'}) returns ALL historical resolved threads
|
|
47
78
|
// (unbounded growth). The Layer 0 filter only keeps threads where the
|
|
@@ -102,12 +133,32 @@ export class ImpactScorer {
|
|
|
102
133
|
}
|
|
103
134
|
}
|
|
104
135
|
}
|
|
105
|
-
// Layer 1: Same file recently modified (
|
|
136
|
+
// Layer 1: Same file recently modified (file_activity) OR currently in flight (working_files).
|
|
106
137
|
for (const targetFile of params.target_files) {
|
|
107
|
-
const
|
|
108
|
-
|
|
138
|
+
const recentAgents = fileToAgents.get(targetFile);
|
|
139
|
+
const inFlightAgents = inFlightToAgents.get(targetFile);
|
|
140
|
+
if (recentAgents && recentAgents.has(agent.id)) {
|
|
109
141
|
maxScore = Math.max(maxScore, 100);
|
|
110
|
-
|
|
142
|
+
let annotated = false;
|
|
143
|
+
if (params.target_symbols && params.target_symbols.length > 0) {
|
|
144
|
+
const theirSymbols = symbolsByFileAgent?.get(`${targetFile}|${agent.id}`) || null;
|
|
145
|
+
if (theirSymbols && theirSymbols.length > 0) {
|
|
146
|
+
const mine = new Set(params.target_symbols);
|
|
147
|
+
const theirs = new Set(theirSymbols);
|
|
148
|
+
const overlap = [...mine].some(s => theirs.has(s));
|
|
149
|
+
if (!overlap) {
|
|
150
|
+
reasons.push(`same file: ${targetFile}; disjoint symbols: you=[${[...mine].join(",")}], them=[${[...theirs].join(",")}] — verify shared module state`);
|
|
151
|
+
annotated = true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (!annotated) {
|
|
156
|
+
reasons.push(`same file (recent): ${targetFile}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (inFlightAgents && inFlightAgents.has(agent.id)) {
|
|
160
|
+
maxScore = Math.max(maxScore, 100);
|
|
161
|
+
reasons.push(`same file (in flight): ${targetFile}`);
|
|
111
162
|
}
|
|
112
163
|
}
|
|
113
164
|
// Layer 2: Depends-on file recently modified (score 80)
|
|
@@ -126,9 +177,34 @@ export class ImpactScorer {
|
|
|
126
177
|
maxScore = Math.max(maxScore, 30);
|
|
127
178
|
reasons.push(`module overlap: ${overlapping.join(", ")}`);
|
|
128
179
|
}
|
|
129
|
-
// Layer 4
|
|
130
|
-
//
|
|
131
|
-
//
|
|
180
|
+
// Layer 4: git co-change. For each target_file F, find rows in git_cochange where
|
|
181
|
+
// (LEAST(F,partner), GREATEST(F,partner)) match. If the OTHER agent recently
|
|
182
|
+
// touched the partner file, apply the co-change score.
|
|
183
|
+
const db = getDb();
|
|
184
|
+
for (const targetFile of params.target_files) {
|
|
185
|
+
const rows = db.prepare(`SELECT file_a, file_b, count, total_commits FROM git_cochange
|
|
186
|
+
WHERE file_a = ? OR file_b = ?`).all(targetFile, targetFile);
|
|
187
|
+
for (const r of rows) {
|
|
188
|
+
const partner = r.file_a === targetFile ? r.file_b : r.file_a;
|
|
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
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
132
208
|
return {
|
|
133
209
|
agent_id: agent.id,
|
|
134
210
|
agent_name: agent.name,
|
|
@@ -146,4 +222,18 @@ export class ImpactScorer {
|
|
|
146
222
|
pass: scores.filter((s) => s.score < 30),
|
|
147
223
|
};
|
|
148
224
|
}
|
|
225
|
+
getRecentSymbolsForFile(filePath, agentId) {
|
|
226
|
+
const db = getDb();
|
|
227
|
+
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);
|
|
230
|
+
if (!row || !row.symbols_touched)
|
|
231
|
+
return null;
|
|
232
|
+
try {
|
|
233
|
+
return JSON.parse(row.symbols_touched);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
149
239
|
}
|
package/dist/src/metrics.d.ts
CHANGED
|
@@ -42,6 +42,11 @@ export declare class Metrics {
|
|
|
42
42
|
readonly threadsResolving: Gauge<string>;
|
|
43
43
|
readonly mqttListenersActive: Gauge<string>;
|
|
44
44
|
readonly sseClientsActive: Gauge<string>;
|
|
45
|
+
readonly workingFilesActive: Gauge<string>;
|
|
46
|
+
readonly gitCochangePairs: Gauge<string>;
|
|
47
|
+
readonly workingFilesStarts: Counter<"result">;
|
|
48
|
+
readonly treeSitterParseFailures: Counter<string>;
|
|
49
|
+
readonly gitCochangeBuilds: Counter<"outcome">;
|
|
45
50
|
constructor(opts?: MetricsOptions);
|
|
46
51
|
recordAnnounce(result: AnnounceResult): void;
|
|
47
52
|
recordThreadResolved(type: ResolutionType): void;
|
package/dist/src/metrics.js
CHANGED
|
@@ -14,6 +14,12 @@ export class Metrics {
|
|
|
14
14
|
threadsResolving;
|
|
15
15
|
mqttListenersActive;
|
|
16
16
|
sseClientsActive;
|
|
17
|
+
workingFilesActive;
|
|
18
|
+
gitCochangePairs;
|
|
19
|
+
// v0.6 counters
|
|
20
|
+
workingFilesStarts;
|
|
21
|
+
treeSitterParseFailures;
|
|
22
|
+
gitCochangeBuilds;
|
|
17
23
|
constructor(opts = {}) {
|
|
18
24
|
this.registry = new Registry();
|
|
19
25
|
if (opts.collectDefault !== false) {
|
|
@@ -72,6 +78,33 @@ export class Metrics {
|
|
|
72
78
|
help: "Current number of connected SSE clients",
|
|
73
79
|
registers: [this.registry],
|
|
74
80
|
});
|
|
81
|
+
this.workingFilesActive = new Gauge({
|
|
82
|
+
name: "mcp_coordinator_working_files_active",
|
|
83
|
+
help: "Current working_files row count",
|
|
84
|
+
registers: [this.registry],
|
|
85
|
+
});
|
|
86
|
+
this.workingFilesStarts = new Counter({
|
|
87
|
+
name: "mcp_coordinator_working_files_starts_total",
|
|
88
|
+
help: "Total working_files start calls",
|
|
89
|
+
labelNames: ["result"],
|
|
90
|
+
registers: [this.registry],
|
|
91
|
+
});
|
|
92
|
+
this.treeSitterParseFailures = new Counter({
|
|
93
|
+
name: "mcp_coordinator_tree_sitter_parse_failures_total",
|
|
94
|
+
help: "Tree-sitter parse failures",
|
|
95
|
+
registers: [this.registry],
|
|
96
|
+
});
|
|
97
|
+
this.gitCochangeBuilds = new Counter({
|
|
98
|
+
name: "mcp_coordinator_git_cochange_builds_total",
|
|
99
|
+
help: "git_cochange build attempts",
|
|
100
|
+
labelNames: ["outcome"],
|
|
101
|
+
registers: [this.registry],
|
|
102
|
+
});
|
|
103
|
+
this.gitCochangePairs = new Gauge({
|
|
104
|
+
name: "mcp_coordinator_git_cochange_pairs_total",
|
|
105
|
+
help: "Current git_cochange row count",
|
|
106
|
+
registers: [this.registry],
|
|
107
|
+
});
|
|
75
108
|
}
|
|
76
109
|
// ── Counter helpers (named methods make hook points obvious) ──
|
|
77
110
|
recordAnnounce(result) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize a file path for matching/correctness — NOT security.
|
|
3
|
+
*
|
|
4
|
+
* Returns POSIX (forward slash), repo-relative when repoRoot is provided,
|
|
5
|
+
* lower-cased when the path is Windows-style (drive letter prefix in repoRoot
|
|
6
|
+
* or input, or backslash in input). Collapses ./ and .. segments via
|
|
7
|
+
* path.posix.normalize.
|
|
8
|
+
*
|
|
9
|
+
* The lowercase pass is anchored to path SHAPE rather than `process.platform`
|
|
10
|
+
* so a Linux coordinator processing paths from a Windows agent (or a CI run
|
|
11
|
+
* exercising Windows-shaped fixtures) still produces consistent canonical
|
|
12
|
+
* forms.
|
|
13
|
+
*
|
|
14
|
+
* Throws when an absolute path falls outside repoRoot. Security path
|
|
15
|
+
* traversal checks are separate (see path-guard.ts:safeJoinUnderRoot).
|
|
16
|
+
*/
|
|
17
|
+
export declare function normalizePath(repoRoot: string | null, input: string): string;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
/**
|
|
3
|
+
* Normalize a file path for matching/correctness — NOT security.
|
|
4
|
+
*
|
|
5
|
+
* Returns POSIX (forward slash), repo-relative when repoRoot is provided,
|
|
6
|
+
* lower-cased when the path is Windows-style (drive letter prefix in repoRoot
|
|
7
|
+
* or input, or backslash in input). Collapses ./ and .. segments via
|
|
8
|
+
* path.posix.normalize.
|
|
9
|
+
*
|
|
10
|
+
* The lowercase pass is anchored to path SHAPE rather than `process.platform`
|
|
11
|
+
* so a Linux coordinator processing paths from a Windows agent (or a CI run
|
|
12
|
+
* exercising Windows-shaped fixtures) still produces consistent canonical
|
|
13
|
+
* forms.
|
|
14
|
+
*
|
|
15
|
+
* Throws when an absolute path falls outside repoRoot. Security path
|
|
16
|
+
* traversal checks are separate (see path-guard.ts:safeJoinUnderRoot).
|
|
17
|
+
*/
|
|
18
|
+
export function normalizePath(repoRoot, input) {
|
|
19
|
+
const isWindowsStyle = (repoRoot != null && (/^[a-zA-Z]:/.test(repoRoot) || repoRoot.includes("\\"))) ||
|
|
20
|
+
/^[a-zA-Z]:/.test(input) ||
|
|
21
|
+
input.includes("\\");
|
|
22
|
+
let p = input.replace(/\\/g, "/");
|
|
23
|
+
if (repoRoot) {
|
|
24
|
+
const root = repoRoot.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
25
|
+
if (path.isAbsolute(input) || /^[a-zA-Z]:/.test(input)) {
|
|
26
|
+
const lowerP = isWindowsStyle ? p.toLowerCase() : p;
|
|
27
|
+
const lowerRoot = isWindowsStyle ? root.toLowerCase() : root;
|
|
28
|
+
if (!lowerP.startsWith(lowerRoot + "/") && lowerP !== lowerRoot) {
|
|
29
|
+
throw new Error(`path is outside repoRoot: ${input}`);
|
|
30
|
+
}
|
|
31
|
+
p = p.slice(root.length).replace(/^\/+/, "");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
p = path.posix.normalize(p).replace(/^\.\//, "");
|
|
35
|
+
if (isWindowsStyle)
|
|
36
|
+
p = p.toLowerCase();
|
|
37
|
+
return p;
|
|
38
|
+
}
|
package/dist/src/serve-http.js
CHANGED
|
@@ -17,6 +17,8 @@ import { createLogger } from "./logger.js";
|
|
|
17
17
|
import { initAuth, authenticateRequest, createToken, refreshToken, revokeAgent, setAuthLogger, verifyToken } from "./auth.js";
|
|
18
18
|
import { safeJoinUnderRoot } from "./path-guard.js";
|
|
19
19
|
import { handleRest as handleRestExt } from "./http/handle-rest.js";
|
|
20
|
+
import { handleLivez, handleReadyz, handleHealth } from "./http/handle-health.js";
|
|
21
|
+
import { serveMetrics } from "./metrics.js";
|
|
20
22
|
import { parseBody as parseBodyShared, json as jsonShared } from "./http/utils.js";
|
|
21
23
|
import { getVersion } from "../cli/version.js";
|
|
22
24
|
const VERSION = getVersion();
|
|
@@ -83,7 +85,15 @@ async function handleRest(req, res) {
|
|
|
83
85
|
}
|
|
84
86
|
async function handleAuth(req, res) {
|
|
85
87
|
const url = req.url || "";
|
|
86
|
-
|
|
88
|
+
let body;
|
|
89
|
+
try {
|
|
90
|
+
body = await parseBody(req);
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
const e = err;
|
|
94
|
+
json(res, { error: e.message || "Invalid request" }, e.statusCode || 400);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
87
97
|
if (url === "/api/auth/register" && req.method === "POST") {
|
|
88
98
|
const { agent_name, registration_secret } = body;
|
|
89
99
|
if (!agent_name || !registration_secret) {
|
|
@@ -184,6 +194,8 @@ function handleSse(req, res) {
|
|
|
184
194
|
Connection: "keep-alive",
|
|
185
195
|
"Access-Control-Allow-Origin": "*",
|
|
186
196
|
});
|
|
197
|
+
services.metrics.incSseClients();
|
|
198
|
+
services.metrics.recordHttpRequest("/api/events", 200);
|
|
187
199
|
// Use Last-Event-ID for resumption, otherwise send last 50
|
|
188
200
|
const lastEventId = parseInt(req.headers["last-event-id"] || "0", 10);
|
|
189
201
|
const events = lastEventId > 0
|
|
@@ -217,6 +229,7 @@ function handleSse(req, res) {
|
|
|
217
229
|
// fires between close and unsubscribe can't write to a dead socket.
|
|
218
230
|
clearInterval(heartbeat);
|
|
219
231
|
unsubscribe();
|
|
232
|
+
services.metrics.decSseClients();
|
|
220
233
|
});
|
|
221
234
|
}
|
|
222
235
|
export async function startServer(opts) {
|
|
@@ -303,8 +316,21 @@ export async function startServer(opts) {
|
|
|
303
316
|
}
|
|
304
317
|
return;
|
|
305
318
|
}
|
|
319
|
+
else if (url === "/livez") {
|
|
320
|
+
handleLivez(req, res);
|
|
321
|
+
services.metrics.recordHttpRequest("/livez", 200);
|
|
322
|
+
}
|
|
323
|
+
else if (url === "/readyz") {
|
|
324
|
+
handleReadyz(req, res, services);
|
|
325
|
+
services.metrics.recordHttpRequest("/readyz", res.statusCode || 0);
|
|
326
|
+
}
|
|
306
327
|
else if (url === "/health") {
|
|
307
|
-
|
|
328
|
+
handleHealth(req, res);
|
|
329
|
+
services.metrics.recordHttpRequest("/health", 200);
|
|
330
|
+
}
|
|
331
|
+
else if (url === "/metrics" && req.method === "GET") {
|
|
332
|
+
await serveMetrics(req, res, services, services.metrics);
|
|
333
|
+
services.metrics.recordHttpRequest("/metrics", 200);
|
|
308
334
|
}
|
|
309
335
|
else if (url === "/api/events" && req.method === "GET") {
|
|
310
336
|
handleSse(req, res);
|
|
@@ -361,15 +387,18 @@ export async function startServer(opts) {
|
|
|
361
387
|
const authResult = await authenticateRequest(req);
|
|
362
388
|
if (!authResult.ok) {
|
|
363
389
|
authLog.warn({ reason: authResult.error, url, ip: req.socket.remoteAddress }, "Auth rejected");
|
|
390
|
+
services.metrics.recordAuthRejected();
|
|
364
391
|
json(res, { error: authResult.error }, authResult.status);
|
|
365
392
|
return;
|
|
366
393
|
}
|
|
367
394
|
}
|
|
368
395
|
if (url.startsWith("/api/") && (req.method === "POST" || req.method === "GET")) {
|
|
369
396
|
await handleRest(req, res);
|
|
397
|
+
services.metrics.recordHttpRequest((url.split("?")[0] || ""), res.statusCode || 0);
|
|
370
398
|
}
|
|
371
399
|
else {
|
|
372
400
|
json(res, { error: "not found" }, 404);
|
|
401
|
+
services.metrics.recordHttpRequest((url.split("?")[0] || ""), 404);
|
|
373
402
|
}
|
|
374
403
|
}
|
|
375
404
|
}
|
|
@@ -418,6 +447,10 @@ export async function startServer(opts) {
|
|
|
418
447
|
services.mqttBridge.onOffline((agentId) => {
|
|
419
448
|
services.registry.setOffline(agentId);
|
|
420
449
|
services.consultation.handleAgentDeparture(agentId);
|
|
450
|
+
// Clear in-flight working_files AFTER consultation cleanup so any future
|
|
451
|
+
// consultation logic that might inspect working_files state for this agent
|
|
452
|
+
// sees the pre-cleanup view.
|
|
453
|
+
services.workingFiles.clearForAgent(agentId);
|
|
421
454
|
services.sseEmitter.emit("agent_offline", { agent_id: agentId });
|
|
422
455
|
});
|
|
423
456
|
// Wait for the HTTP server to be actually listening before resolving the
|
|
@@ -483,6 +516,12 @@ export async function startServer(opts) {
|
|
|
483
516
|
catch (err) {
|
|
484
517
|
log.warn({ err }, "Error stopping timeout sweeper");
|
|
485
518
|
}
|
|
519
|
+
try {
|
|
520
|
+
services.workingFiles.stopSweeper();
|
|
521
|
+
}
|
|
522
|
+
catch (err) {
|
|
523
|
+
log.warn({ err }, "Error stopping working-files sweeper");
|
|
524
|
+
}
|
|
486
525
|
try {
|
|
487
526
|
const { closeDb } = await import("./database.js");
|
|
488
527
|
closeDb?.();
|
|
@@ -11,7 +11,10 @@ import { SseEmitter } from "./sse-emitter.js";
|
|
|
11
11
|
import { MqttBridge } from "./mqtt-bridge.js";
|
|
12
12
|
import { AgentActivityTracker } from "./agent-activity.js";
|
|
13
13
|
import { QuotaCache } from "./quota/quota-cache.js";
|
|
14
|
+
import { WorkingFilesTracker } from "./working-files-tracker.js";
|
|
14
15
|
import { Metrics } from "./metrics.js";
|
|
16
|
+
import { TreeSitterExtractor } from "./tree-sitter-extractor.js";
|
|
17
|
+
import { GitCochangeBuilder } from "./git-cochange-builder.js";
|
|
15
18
|
import type { CoordinatorConfig } from "./types.js";
|
|
16
19
|
import { type Logger } from "./logger.js";
|
|
17
20
|
export interface CoordinatorServices {
|
|
@@ -23,12 +26,15 @@ export interface CoordinatorServices {
|
|
|
23
26
|
depMap: DependencyMapper;
|
|
24
27
|
fileTracker: FileTracker;
|
|
25
28
|
impactScorer: ImpactScorer;
|
|
29
|
+
workingFiles: WorkingFilesTracker;
|
|
26
30
|
introspection: IntrospectionManager;
|
|
27
31
|
contextProvider: SummaryContextProvider;
|
|
28
32
|
sseEmitter: SseEmitter;
|
|
29
33
|
mqttBridge: MqttBridge;
|
|
30
34
|
quotaCache: QuotaCache;
|
|
31
35
|
metrics: Metrics;
|
|
36
|
+
treeSitter: TreeSitterExtractor;
|
|
37
|
+
gitCochange: GitCochangeBuilder | null;
|
|
32
38
|
}
|
|
33
39
|
/** Create shared services (once at startup). */
|
|
34
40
|
export declare function createServices(config: CoordinatorConfig): CoordinatorServices;
|
package/dist/src/server-setup.js
CHANGED
|
@@ -18,7 +18,10 @@ import { SseEmitter } from "./sse-emitter.js";
|
|
|
18
18
|
import { MqttBridge } from "./mqtt-bridge.js";
|
|
19
19
|
import { AgentActivityTracker } from "./agent-activity.js";
|
|
20
20
|
import { QuotaCache } from "./quota/quota-cache.js";
|
|
21
|
+
import { WorkingFilesTracker } from "./working-files-tracker.js";
|
|
21
22
|
import { Metrics } from "./metrics.js";
|
|
23
|
+
import { TreeSitterExtractor } from "./tree-sitter-extractor.js";
|
|
24
|
+
import { GitCochangeBuilder } from "./git-cochange-builder.js";
|
|
22
25
|
import { createLogger } from "./logger.js";
|
|
23
26
|
import { getVersion } from "../cli/version.js";
|
|
24
27
|
const VERSION = getVersion();
|
|
@@ -26,18 +29,35 @@ const VERSION = getVersion();
|
|
|
26
29
|
export function createServices(config) {
|
|
27
30
|
initDatabase(config.dataDir);
|
|
28
31
|
const logger = createLogger();
|
|
32
|
+
const metrics = new Metrics();
|
|
29
33
|
const registry = new AgentRegistry();
|
|
30
34
|
const activityTracker = new AgentActivityTracker(registry);
|
|
31
35
|
const consultation = new Consultation(logger.child({ component: "consultation" }));
|
|
32
36
|
const depMap = new DependencyMapper();
|
|
33
37
|
const fileTracker = new FileTracker();
|
|
34
|
-
const
|
|
38
|
+
const workingFiles = new WorkingFilesTracker(logger.child({ component: "working-files" }), metrics);
|
|
39
|
+
workingFiles.startSweeper(parseInt(process.env.COORDINATOR_WORKING_FILES_SWEEP_INTERVAL_MS || "60000", 10));
|
|
40
|
+
const impactScorer = new ImpactScorer(registry, fileTracker, consultation, workingFiles);
|
|
35
41
|
const introspection = new IntrospectionManager();
|
|
36
42
|
const conflictDetector = new ConflictDetector(consultation, depMap, fileTracker, logger.child({ component: "conflict" }));
|
|
37
43
|
const contextProvider = new SummaryContextProvider(registry, consultation, fileTracker);
|
|
38
44
|
const sseEmitter = new SseEmitter();
|
|
39
45
|
const mqttBridge = new MqttBridge(logger.child({ component: "mqtt" }));
|
|
40
|
-
const
|
|
46
|
+
const treeSitter = new TreeSitterExtractor(metrics);
|
|
47
|
+
treeSitter.load().catch(() => { });
|
|
48
|
+
const repoRoot = process.env.COORDINATOR_REPO_ROOT;
|
|
49
|
+
const gitCochange = repoRoot
|
|
50
|
+
? new GitCochangeBuilder({
|
|
51
|
+
repoRoot,
|
|
52
|
+
logger: logger.child({ component: "gitcc" }),
|
|
53
|
+
metrics,
|
|
54
|
+
sinceDays: parseInt(process.env.COORDINATOR_LAYER4_SINCE_DAYS || "7", 10),
|
|
55
|
+
maxCount: parseInt(process.env.COORDINATOR_LAYER4_MAX_COMMITS || "2000", 10),
|
|
56
|
+
refreshMs: parseInt(process.env.COORDINATOR_LAYER4_REFRESH_INTERVAL_MS || "1800000", 10),
|
|
57
|
+
retryMs: parseInt(process.env.COORDINATOR_LAYER4_RETRY_MS || "300000", 10),
|
|
58
|
+
})
|
|
59
|
+
: null;
|
|
60
|
+
gitCochange?.startScheduler();
|
|
41
61
|
// Quota cache — macOS-only for now, Linux/Windows stubs return 503 via the
|
|
42
62
|
// /api/quota handler so raids keep running without a quota guardrail there.
|
|
43
63
|
// onRefresh fans the new data out to dashboard (SSE) + any live listener (MQTT)
|
|
@@ -88,7 +108,7 @@ export function createServices(config) {
|
|
|
88
108
|
});
|
|
89
109
|
return {
|
|
90
110
|
logger, registry, activityTracker, consultation, conflictDetector,
|
|
91
|
-
depMap, fileTracker, impactScorer, introspection, contextProvider, sseEmitter, mqttBridge, quotaCache, metrics,
|
|
111
|
+
depMap, fileTracker, impactScorer, workingFiles, introspection, contextProvider, sseEmitter, mqttBridge, quotaCache, metrics, treeSitter, gitCochange,
|
|
92
112
|
};
|
|
93
113
|
}
|
|
94
114
|
/** Create a new McpServer bound to the shared services (one per MCP session). */
|
|
@@ -30,7 +30,9 @@ export function registerConsultationTools(server, services, mcpLog) {
|
|
|
30
30
|
exports_affected: z.array(z.string()).optional(),
|
|
31
31
|
keep_open: z.boolean().optional().describe("Keep thread open even if no agents are concerned (for manual coordination like games or debates)"),
|
|
32
32
|
assigned_to: z.string().optional().describe("Directed-dispatch: only this agent_id will be allowed to claim the thread. Use for lead→worker handoffs in maitre/chaine/relais presets. Implies keep_open=true."),
|
|
33
|
-
|
|
33
|
+
target_symbols: z.array(z.string().max(256)).max(200).optional()
|
|
34
|
+
.describe("Qualified symbol names you intend to touch (e.g. 'UserService.getById'). Used by Layer 0.5 to annotate same-file overlaps."),
|
|
35
|
+
}, async ({ agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to, target_symbols }) => {
|
|
34
36
|
mcpLog.info({ tool: "announce_work", agent_id, subject, target_modules, target_files, assigned_to }, "Tool called");
|
|
35
37
|
const conflicts = conflictDetector.detect({ agent_id, target_modules, target_files });
|
|
36
38
|
const thread = consultation.announceWork({
|
|
@@ -41,7 +43,7 @@ export function registerConsultationTools(server, services, mcpLog) {
|
|
|
41
43
|
.run(JSON.stringify(conflicts), thread.id);
|
|
42
44
|
}
|
|
43
45
|
const { updated, categorized, respondents, planQuality } = runCommonAnnounceFlow(services, thread.id, {
|
|
44
|
-
agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open,
|
|
46
|
+
agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, target_symbols,
|
|
45
47
|
});
|
|
46
48
|
sseEmitter.emit("thread_opened", {
|
|
47
49
|
thread_id: thread.id, initiator: agent_id, subject, target_modules, conflicts,
|