mcp-coordinator 0.2.0 → 0.3.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 +846 -835
- package/dashboard/Dockerfile +19 -19
- package/dashboard/public/index.html +1178 -1178
- package/dist/cli/dashboard.js +9 -5
- package/dist/cli/server/start.js +24 -1
- package/dist/cli/server/status.js +16 -23
- package/dist/src/agent-activity.js +6 -6
- package/dist/src/agent-registry.js +6 -6
- package/dist/src/announce-workflow.d.ts +52 -0
- package/dist/src/announce-workflow.js +91 -0
- package/dist/src/consultation.d.ts +14 -0
- package/dist/src/consultation.js +110 -45
- package/dist/src/database.js +126 -126
- package/dist/src/dependency-map.js +3 -3
- package/dist/src/file-tracker.js +8 -8
- package/dist/src/http/handle-rest.d.ts +23 -0
- package/dist/src/http/handle-rest.js +374 -0
- package/dist/src/http/utils.d.ts +15 -0
- package/dist/src/http/utils.js +39 -0
- package/dist/src/introspection.js +1 -1
- package/dist/src/mqtt-bridge.d.ts +2 -0
- package/dist/src/mqtt-bridge.js +2 -0
- package/dist/src/mqtt-broker.d.ts +16 -0
- package/dist/src/mqtt-broker.js +16 -1
- package/dist/src/path-guard.d.ts +14 -0
- package/dist/src/path-guard.js +44 -0
- package/dist/src/reset-guard.d.ts +16 -0
- package/dist/src/reset-guard.js +24 -0
- package/dist/src/serve-http.d.ts +31 -1
- package/dist/src/serve-http.js +154 -445
- package/dist/src/server-setup.js +15 -364
- package/dist/src/tools/agents-tools.d.ts +8 -0
- package/dist/src/tools/agents-tools.js +46 -0
- package/dist/src/tools/consultation-tools.d.ts +21 -0
- package/dist/src/tools/consultation-tools.js +170 -0
- package/dist/src/tools/dependencies-tools.d.ts +8 -0
- package/dist/src/tools/dependencies-tools.js +27 -0
- package/dist/src/tools/files-tools.d.ts +8 -0
- package/dist/src/tools/files-tools.js +28 -0
- package/dist/src/tools/mqtt-tools.d.ts +9 -0
- package/dist/src/tools/mqtt-tools.js +33 -0
- package/dist/src/tools/status-tools.d.ts +8 -0
- package/dist/src/tools/status-tools.js +63 -0
- package/package.json +81 -80
package/dist/src/serve-http.js
CHANGED
|
@@ -14,8 +14,10 @@ const __dirname = path.dirname(__filename);
|
|
|
14
14
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
15
15
|
import { createServices, createMcpServer } from "./server-setup.js";
|
|
16
16
|
import { createLogger } from "./logger.js";
|
|
17
|
-
import { initAuth, authenticateRequest, createToken, refreshToken, revokeAgent, setAuthLogger } from "./auth.js";
|
|
18
|
-
import {
|
|
17
|
+
import { initAuth, authenticateRequest, createToken, refreshToken, revokeAgent, setAuthLogger, verifyToken } from "./auth.js";
|
|
18
|
+
import { safeJoinUnderRoot } from "./path-guard.js";
|
|
19
|
+
import { handleRest as handleRestExt } from "./http/handle-rest.js";
|
|
20
|
+
import { parseBody as parseBodyShared, json as jsonShared } from "./http/utils.js";
|
|
19
21
|
import { getVersion } from "../cli/version.js";
|
|
20
22
|
const VERSION = getVersion();
|
|
21
23
|
import { startEmbeddedMqttBroker } from "./mqtt-broker.js";
|
|
@@ -48,439 +50,36 @@ let httpLog;
|
|
|
48
50
|
let mcpLog;
|
|
49
51
|
let authLog;
|
|
50
52
|
let currentRunConfig = null;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
try {
|
|
57
|
-
resolve(body ? JSON.parse(body) : {});
|
|
58
|
-
}
|
|
59
|
-
catch {
|
|
60
|
-
reject(new Error("Invalid JSON"));
|
|
61
|
-
}
|
|
62
|
-
});
|
|
63
|
-
req.on("error", reject);
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
function json(res, data, status = 200) {
|
|
67
|
-
res.writeHead(status, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
|
|
68
|
-
res.end(JSON.stringify(data));
|
|
69
|
-
}
|
|
53
|
+
// S1: parseBody and json moved to ./http/utils.js (shared with handle-rest.ts).
|
|
54
|
+
// Re-bound to local names so the rest of this file (handleAuth, handleSse,
|
|
55
|
+
// startServer) can keep using `parseBody` / `json` without changes.
|
|
56
|
+
const parseBody = parseBodyShared;
|
|
57
|
+
const json = jsonShared;
|
|
70
58
|
function decodeJwtPayload(token) {
|
|
59
|
+
// Used only on tokens we just minted ourselves (to read the `exp` claim
|
|
60
|
+
// before returning it to the client). Real verification of inbound tokens
|
|
61
|
+
// happens in `authenticateRequest` via jose.jwtVerify().
|
|
71
62
|
const base64url = token.split(".")[1];
|
|
72
|
-
|
|
73
|
-
return JSON.parse(atob(base64));
|
|
63
|
+
return JSON.parse(Buffer.from(base64url, "base64url").toString("utf-8"));
|
|
74
64
|
}
|
|
75
65
|
function safeEqual(a, b) {
|
|
76
66
|
if (a.length !== b.length)
|
|
77
67
|
return false;
|
|
78
68
|
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
79
69
|
}
|
|
70
|
+
// S1: handleRest extracted to ./http/handle-rest.ts. Thin wrapper here keeps
|
|
71
|
+
// startServer's call site stable while the 382-line REST router lives in its
|
|
72
|
+
// own module. currentRunConfig stays here as the single mutable owner; the
|
|
73
|
+
// extracted function reads/writes via getRunConfig/setRunConfig accessors.
|
|
80
74
|
async function handleRest(req, res) {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (isPoll) {
|
|
90
|
-
httpLog.debug({ method: req.method, url, agent_id: agentId }, "REST request");
|
|
91
|
-
}
|
|
92
|
-
else {
|
|
93
|
-
httpLog.info({ method: req.method, url, agent_id: agentId }, "REST request");
|
|
94
|
-
}
|
|
95
|
-
const { registry, activityTracker, consultation, fileTracker, impactScorer, introspection, sseEmitter, mqttBridge, quotaCache } = services;
|
|
96
|
-
if (url === "/api/register") {
|
|
97
|
-
const { agent_id, name, modules } = body;
|
|
98
|
-
const agent = registry.register(agent_id, name, modules || []);
|
|
99
|
-
sseEmitter.emit("agent_online", { agent_id, name, modules });
|
|
100
|
-
json(res, agent);
|
|
101
|
-
}
|
|
102
|
-
else if (url === "/api/session-start") {
|
|
103
|
-
const { agent_id, agent_name } = body;
|
|
104
|
-
const online = registry.listOnline();
|
|
105
|
-
const openThreads = consultation.listThreads({ status: "open" });
|
|
106
|
-
const hotFiles = fileTracker.getHotFiles(30);
|
|
107
|
-
const briefing = [
|
|
108
|
-
`Agents en ligne: ${online.map((a) => a.name).join(", ") || "aucun"}`,
|
|
109
|
-
`Consultations ouvertes: ${openThreads.length}`,
|
|
110
|
-
`Hot files: ${hotFiles.map((f) => f.file_path).join(", ") || "aucun"}`,
|
|
111
|
-
].join("\n");
|
|
112
|
-
json(res, { briefing, summary: { online: online.length, open_threads: openThreads.length, hot_files: hotFiles.length } });
|
|
113
|
-
}
|
|
114
|
-
else if (url === "/api/session-stop") {
|
|
115
|
-
const { agent_id } = body;
|
|
116
|
-
registry.setOffline(agent_id);
|
|
117
|
-
activityTracker.reportOffline(agent_id);
|
|
118
|
-
consultation.handleAgentDeparture(agent_id);
|
|
119
|
-
sseEmitter.emit("agent_offline", { agent_id });
|
|
120
|
-
json(res, { ok: true });
|
|
121
|
-
}
|
|
122
|
-
else if (url === "/api/check-conflict") {
|
|
123
|
-
const { file, agent_id } = body;
|
|
124
|
-
const conflict = fileTracker.checkFileConflict(file, agent_id, 30);
|
|
125
|
-
const warnings = [];
|
|
126
|
-
if (conflict.conflict) {
|
|
127
|
-
warnings.push(`File ${file} recently edited by: ${conflict.agents.join(", ")}`);
|
|
128
|
-
}
|
|
129
|
-
json(res, { conflict: conflict.conflict, warnings });
|
|
130
|
-
}
|
|
131
|
-
else if (url === "/api/log-file") {
|
|
132
|
-
const { session_id, agent_id, agent_name, tool_name, file } = body;
|
|
133
|
-
fileTracker.log({ session_id, agent_id, agent_name, tool_name, file_path: file });
|
|
134
|
-
activityTracker.reportFileActivity(agent_id, file);
|
|
135
|
-
sseEmitter.emit("file_edited", { agent_id, agent_name: agent_name || agent_id, file, tool_name });
|
|
136
|
-
json(res, { ok: true });
|
|
137
|
-
}
|
|
138
|
-
else if (url === "/api/announce") {
|
|
139
|
-
const { agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to } = body;
|
|
140
|
-
// Quality gate on plan
|
|
141
|
-
const planQuality = assessPlanQuality(plan);
|
|
142
|
-
const effectiveMode = planQuality.mode;
|
|
143
|
-
const thread = consultation.announceWork({ agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to });
|
|
144
|
-
const agentInfo = registry.get(agent_id);
|
|
145
|
-
// Impact scoring: categorize all online agents
|
|
146
|
-
const categorized = impactScorer.categorize({
|
|
147
|
-
agent_id, target_modules, target_files, depends_on_files, exports_affected,
|
|
148
|
-
});
|
|
149
|
-
// Override expected_respondents with concerned agents from scorer
|
|
150
|
-
{
|
|
151
|
-
const db = (await import("./database.js")).getDb();
|
|
152
|
-
const concernedIds = categorized.concerned.map(s => s.agent_id);
|
|
153
|
-
db.prepare("UPDATE threads SET expected_respondents = ? WHERE id = ?")
|
|
154
|
-
.run(JSON.stringify(concernedIds), thread.id);
|
|
155
|
-
// Only auto-resolve when truly alone — no other online agents.
|
|
156
|
-
// If peers are online but not yet concerned (e.g. they haven't announced
|
|
157
|
-
// yet), keep the thread open so a subsequent announce can still match
|
|
158
|
-
// this work via Layer 0. Thread will timeout naturally if no one joins.
|
|
159
|
-
const otherOnlineCount = registry.listOnline().filter((a) => a.id !== agent_id).length;
|
|
160
|
-
const shouldAutoResolve = concernedIds.length === 0 && otherOnlineCount === 0;
|
|
161
|
-
if (shouldAutoResolve && thread.status === "open" && !keep_open) {
|
|
162
|
-
db.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ?")
|
|
163
|
-
.run(new Date().toISOString(), thread.id);
|
|
164
|
-
consultation.emitResolution(thread.id, "auto_resolved");
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
// Emit impact_scored SSE events for all agents
|
|
168
|
-
for (const s of [...categorized.concerned, ...categorized.gray_zone, ...categorized.pass]) {
|
|
169
|
-
sseEmitter.emit("impact_scored", {
|
|
170
|
-
thread_id: thread.id, agent_id: s.agent_id, agent_name: s.agent_name,
|
|
171
|
-
score: s.score, reasons: s.reasons, category: s.score >= 90 ? "concerned" : s.score >= 30 ? "gray_zone" : "pass",
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
// Create introspection records and emit introspection_requested for gray_zone agents
|
|
175
|
-
for (const s of categorized.gray_zone) {
|
|
176
|
-
introspection.create({ thread_id: thread.id, agent_id: s.agent_id, score: s.score, reasons: s.reasons });
|
|
177
|
-
sseEmitter.emit("introspection_requested", {
|
|
178
|
-
thread_id: thread.id, agent_id: s.agent_id, agent_name: s.agent_name, score: s.score, reasons: s.reasons,
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
const updated = consultation.getThread(thread.id);
|
|
182
|
-
const respondents = JSON.parse(updated.expected_respondents || "[]");
|
|
183
|
-
// Emit downgrade event when plan is provided but quality is insufficient
|
|
184
|
-
if (plan && effectiveMode === "discovery") {
|
|
185
|
-
sseEmitter.emit("impact_scored", {
|
|
186
|
-
thread_id: thread.id,
|
|
187
|
-
agent_id: agent_id,
|
|
188
|
-
agent_name: agentInfo?.name || agent_id,
|
|
189
|
-
score: planQuality.score,
|
|
190
|
-
reasons: [`plan downgraded: score ${planQuality.score}/3 — ${!planQuality.checks.mentions_files ? 'no files' : ''} ${!planQuality.checks.concrete_approach ? 'vague approach' : ''} ${!planQuality.checks.sufficient_detail ? 'too short' : ''}`.trim()],
|
|
191
|
-
category: "plan_quality",
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
sseEmitter.emit("thread_opened", {
|
|
195
|
-
thread_id: thread.id, subject, agent_id, agent_name: agentInfo?.name || agent_id,
|
|
196
|
-
target_modules, target_files, expected_respondents: respondents,
|
|
197
|
-
conflicts: updated.conflicts ? JSON.parse(updated.conflicts) : [],
|
|
198
|
-
created_at: updated.created_at,
|
|
199
|
-
mode: effectiveMode,
|
|
200
|
-
plan: plan || null,
|
|
201
|
-
plan_quality: planQuality,
|
|
202
|
-
});
|
|
203
|
-
json(res, { thread_id: thread.id, status: updated.status, impact: categorized });
|
|
204
|
-
}
|
|
205
|
-
else if (url === "/api/post-to-thread") {
|
|
206
|
-
const { thread_id, agent_id, agent_name, type, content } = body;
|
|
207
|
-
// Pre-check the thread so we can return actionable status codes instead
|
|
208
|
-
// of always-500 on any error. The client uses the status to decide
|
|
209
|
-
// whether to warn (unexpected) or silently skip (normal race).
|
|
210
|
-
const targetThread = consultation.getThread(thread_id);
|
|
211
|
-
if (!targetThread) {
|
|
212
|
-
json(res, { error: "thread_not_found", thread_id }, 404);
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
if (targetThread.status === "cancelled") {
|
|
216
|
-
json(res, { error: "thread_cancelled", thread_id }, 410);
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
const msg = consultation.postToThread({ thread_id, agent_id, agent_name, type, content });
|
|
220
|
-
const thread = consultation.getThread(thread_id);
|
|
221
|
-
sseEmitter.emit("message_posted", {
|
|
222
|
-
thread_id, agent_id, agent_name: agent_name || agent_id,
|
|
223
|
-
type, content, round: thread?.round || 1,
|
|
224
|
-
token_estimate: msg.token_estimate || 0,
|
|
225
|
-
});
|
|
226
|
-
json(res, msg);
|
|
227
|
-
}
|
|
228
|
-
else if (url === "/api/token-usage") {
|
|
229
|
-
// Agent → coordinator telemetry, emitted once per LLM turn so the dashboard
|
|
230
|
-
// and reports can pinpoint where tokens are being burned.
|
|
231
|
-
const payload = body;
|
|
232
|
-
sseEmitter.emit("token_usage", payload);
|
|
233
|
-
json(res, { ok: true });
|
|
234
|
-
}
|
|
235
|
-
else if (url === "/api/unclaim-task") {
|
|
236
|
-
const { thread_id, agent_id } = body;
|
|
237
|
-
if (!thread_id || !agent_id) {
|
|
238
|
-
json(res, { success: false, error: "thread_id and agent_id required" }, 400);
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
const db = (await import("./database.js")).getDb();
|
|
242
|
-
// F4: increment unclaim counter. After POISON_THRESHOLD aborts, flip status
|
|
243
|
-
// to "poisoned" so no agent claims it again — prevents the tight
|
|
244
|
-
// claim → no DONE → unclaim → re-claim loop we observed on stuck tasks.
|
|
245
|
-
// Only the claiming agent can unclaim to prevent cross-agent interference.
|
|
246
|
-
const POISON_THRESHOLD = 2;
|
|
247
|
-
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);
|
|
248
|
-
let poisoned = false;
|
|
249
|
-
if (result.changes === 1) {
|
|
250
|
-
const row = db.prepare("SELECT unclaim_count FROM threads WHERE id = ?").get(thread_id);
|
|
251
|
-
if (row && (row.unclaim_count ?? 0) >= POISON_THRESHOLD) {
|
|
252
|
-
db.prepare("UPDATE threads SET status = 'poisoned' WHERE id = ? AND status = 'open'").run(thread_id);
|
|
253
|
-
poisoned = true;
|
|
254
|
-
httpLog.warn({ thread_id, unclaim_count: row.unclaim_count }, "thread poisoned after repeated unclaims");
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
json(res, { success: result.changes === 1, poisoned });
|
|
258
|
-
}
|
|
259
|
-
else if (url === "/api/claim-task") {
|
|
260
|
-
const { thread_id, agent_id } = body;
|
|
261
|
-
if (!thread_id || !agent_id) {
|
|
262
|
-
json(res, { success: false, error: "thread_id and agent_id required" }, 400);
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
const db = (await import("./database.js")).getDb();
|
|
266
|
-
// Only claim threads with status='open' — poisoned threads are filtered out
|
|
267
|
-
// automatically because the status filter excludes them.
|
|
268
|
-
// Directed-dispatch constraint: if assigned_to is set, only that specific
|
|
269
|
-
// agent can claim; NULL keeps the original open-pool semantics.
|
|
270
|
-
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);
|
|
271
|
-
if (result.changes === 1) {
|
|
272
|
-
mqttBridge.publishTaskClaimed(thread_id, agent_id);
|
|
273
|
-
sseEmitter.emit("task_claimed", { thread_id, agent_id });
|
|
274
|
-
json(res, { success: true });
|
|
275
|
-
}
|
|
276
|
-
else {
|
|
277
|
-
const thread = consultation.getThread(thread_id);
|
|
278
|
-
// Surface the assigned_to in the 'why not' response so clients can
|
|
279
|
-
// distinguish "already claimed by X" from "reserved for Y".
|
|
280
|
-
json(res, {
|
|
281
|
-
success: false,
|
|
282
|
-
claimed_by: thread?.claimed_by || null,
|
|
283
|
-
assigned_to: thread?.assigned_to || null,
|
|
284
|
-
status: thread?.status,
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
else if (url === "/api/propose-resolution") {
|
|
289
|
-
const { thread_id, agent_id, summary } = body;
|
|
290
|
-
const agentInfo = registry.get(agent_id);
|
|
291
|
-
consultation.proposeResolution(thread_id, agent_id, summary);
|
|
292
|
-
sseEmitter.emit("resolution_proposed", {
|
|
293
|
-
thread_id, agent_id, agent_name: agentInfo?.name || agent_id, summary,
|
|
294
|
-
});
|
|
295
|
-
json(res, consultation.getThread(thread_id));
|
|
296
|
-
mqttBridge.publishTaskCompleted(thread_id, agent_id, summary);
|
|
297
|
-
}
|
|
298
|
-
else if (url === "/api/approve-resolution") {
|
|
299
|
-
const { thread_id, agent_id } = body;
|
|
300
|
-
const agentInfo = registry.get(agent_id);
|
|
301
|
-
consultation.approveResolution(thread_id, agent_id, agentInfo?.name);
|
|
302
|
-
const t = consultation.getThread(thread_id);
|
|
303
|
-
json(res, t);
|
|
304
|
-
}
|
|
305
|
-
else if (url?.startsWith("/api/consultation/") && url?.endsWith("/status")) {
|
|
306
|
-
const threadId = url.split("/")[3];
|
|
307
|
-
const thread = consultation.getThreadWithMessages(threadId);
|
|
308
|
-
if (!thread) {
|
|
309
|
-
json(res, { error: "not found" }, 404);
|
|
310
|
-
}
|
|
311
|
-
else {
|
|
312
|
-
json(res, {
|
|
313
|
-
status: thread.thread.status,
|
|
314
|
-
messages: thread.messages,
|
|
315
|
-
resolution_summary: thread.thread.resolution_summary,
|
|
316
|
-
expected_respondents: JSON.parse(thread.thread.expected_respondents || "[]"),
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
else if (url === "/api/threads-active") {
|
|
321
|
-
const open = consultation.listThreads({ status: "open" });
|
|
322
|
-
const resolving = consultation.listThreads({ status: "resolving" });
|
|
323
|
-
json(res, [...open, ...resolving]);
|
|
324
|
-
}
|
|
325
|
-
else if (url === "/api/hot-files") {
|
|
326
|
-
const { since_minutes } = body;
|
|
327
|
-
json(res, fileTracker.getHotFiles(since_minutes || 30));
|
|
328
|
-
}
|
|
329
|
-
else if (url === "/api/quota") {
|
|
330
|
-
// Pre-flight + live widget endpoint. 200 with fresh QuotaInfo when the
|
|
331
|
-
// Keychain + Anthropic API are reachable, 503 otherwise. Consumers treat
|
|
332
|
-
// 503 as "quota unknown = proceed" (fail-open) per the project decision.
|
|
333
|
-
const info = await quotaCache.get();
|
|
334
|
-
if (!info) {
|
|
335
|
-
const status = quotaCache.snapshot();
|
|
336
|
-
json(res, {
|
|
337
|
-
error: "quota unavailable",
|
|
338
|
-
reason: status.lastError,
|
|
339
|
-
cooldown_until: status.cooldownUntil,
|
|
340
|
-
}, 503);
|
|
341
|
-
}
|
|
342
|
-
else {
|
|
343
|
-
json(res, {
|
|
344
|
-
five_hour: info.fiveHour,
|
|
345
|
-
seven_day: info.sevenDay,
|
|
346
|
-
seven_day_sonnet: info.sevenDaySonnet,
|
|
347
|
-
fetched_at: info.fetchedAt,
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
else if (url === "/api/quota/refresh") {
|
|
352
|
-
// Force-refresh the cache, bypassing the TTL. Used by the dashboard's
|
|
353
|
-
// manual refresh button. The underlying quotaCache.refresh() is single-
|
|
354
|
-
// flight-deduped, so mashing the button doesn't stack parallel fetches.
|
|
355
|
-
// The onRefresh callback on the cache broadcasts via SSE + MQTT, so the
|
|
356
|
-
// dashboard receives the update through the normal channel too — this
|
|
357
|
-
// endpoint only exists for "give me the answer now" semantics.
|
|
358
|
-
const info = await quotaCache.refresh();
|
|
359
|
-
if (!info) {
|
|
360
|
-
const status = quotaCache.snapshot();
|
|
361
|
-
json(res, {
|
|
362
|
-
error: "quota unavailable",
|
|
363
|
-
reason: status.lastError,
|
|
364
|
-
cooldown_until: status.cooldownUntil,
|
|
365
|
-
}, 503);
|
|
366
|
-
}
|
|
367
|
-
else {
|
|
368
|
-
json(res, {
|
|
369
|
-
five_hour: info.fiveHour,
|
|
370
|
-
seven_day: info.sevenDay,
|
|
371
|
-
seven_day_sonnet: info.sevenDaySonnet,
|
|
372
|
-
fetched_at: info.fetchedAt,
|
|
373
|
-
});
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
else if (url === "/api/introspection-response") {
|
|
377
|
-
const { introspection_id, concerned, reason } = body;
|
|
378
|
-
const intro = introspection.respond(introspection_id, concerned, reason);
|
|
379
|
-
// If concerned, add to thread's expected_respondents
|
|
380
|
-
if (concerned && intro) {
|
|
381
|
-
const db = (await import("./database.js")).getDb();
|
|
382
|
-
const thread = consultation.getThread(intro.thread_id);
|
|
383
|
-
if (thread && (thread.status === "open" || thread.status === "resolving")) {
|
|
384
|
-
const respondents = JSON.parse(thread.expected_respondents || "[]");
|
|
385
|
-
if (!respondents.includes(intro.agent_id)) {
|
|
386
|
-
respondents.push(intro.agent_id);
|
|
387
|
-
db.prepare("UPDATE threads SET expected_respondents = ? WHERE id = ?")
|
|
388
|
-
.run(JSON.stringify(respondents), thread.id);
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
const agentInfo = registry.get(intro?.agent_id || "");
|
|
393
|
-
sseEmitter.emit("introspection_completed", {
|
|
394
|
-
introspection_id, thread_id: intro?.thread_id,
|
|
395
|
-
agent_id: intro?.agent_id, agent_name: agentInfo?.name || intro?.agent_id,
|
|
396
|
-
concerned, reason,
|
|
397
|
-
});
|
|
398
|
-
json(res, intro);
|
|
399
|
-
}
|
|
400
|
-
else if (url?.startsWith("/api/pending-introspections")) {
|
|
401
|
-
const urlObj = new URL(url, "http://localhost");
|
|
402
|
-
const agent_id = urlObj.searchParams.get("agent_id") || "";
|
|
403
|
-
const pending = introspection.getPending(agent_id);
|
|
404
|
-
json(res, pending);
|
|
405
|
-
}
|
|
406
|
-
else if (url === "/api/run-config") {
|
|
407
|
-
if (req.method === "POST") {
|
|
408
|
-
currentRunConfig = body;
|
|
409
|
-
sseEmitter.emit("run_config", currentRunConfig);
|
|
410
|
-
json(res, { ok: true });
|
|
411
|
-
}
|
|
412
|
-
else {
|
|
413
|
-
json(res, currentRunConfig || { active: false });
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
else if (url === "/api/reset") {
|
|
417
|
-
// Reset all tables for clean test run (disable FK checks to avoid ordering issues)
|
|
418
|
-
const db = (await import("./database.js")).getDb();
|
|
419
|
-
db.exec("PRAGMA foreign_keys = OFF");
|
|
420
|
-
db.exec("DELETE FROM introspections");
|
|
421
|
-
db.exec("DELETE FROM events");
|
|
422
|
-
db.exec("DELETE FROM thread_messages");
|
|
423
|
-
db.exec("DELETE FROM threads");
|
|
424
|
-
db.exec("DELETE FROM action_summaries");
|
|
425
|
-
db.exec("DELETE FROM file_activity");
|
|
426
|
-
db.exec("DELETE FROM agent_activity_status");
|
|
427
|
-
db.exec("DELETE FROM dependency_map");
|
|
428
|
-
db.exec("DELETE FROM agents");
|
|
429
|
-
db.exec("DELETE FROM revoked_agents");
|
|
430
|
-
db.exec("PRAGMA foreign_keys = ON");
|
|
431
|
-
currentRunConfig = null;
|
|
432
|
-
json(res, { ok: true });
|
|
433
|
-
}
|
|
434
|
-
else if (url === "/api/check-interrupt") {
|
|
435
|
-
const { agent_id } = body;
|
|
436
|
-
// Check for threads where this agent is an expected respondent and hasn't posted yet.
|
|
437
|
-
// Covers both open threads (waiting for initial response) and resolving threads
|
|
438
|
-
// (waiting for approval/contest of a proposed resolution).
|
|
439
|
-
const pendingThreads = [
|
|
440
|
-
...consultation.listThreads({ status: "open" }),
|
|
441
|
-
...consultation.listThreads({ status: "resolving" }),
|
|
442
|
-
].filter((t) => {
|
|
443
|
-
const respondents = JSON.parse(t.expected_respondents || "[]");
|
|
444
|
-
return respondents.includes(agent_id);
|
|
445
|
-
});
|
|
446
|
-
if (pendingThreads.length > 0) {
|
|
447
|
-
const details = pendingThreads.map((t) => ({
|
|
448
|
-
thread_id: t.id,
|
|
449
|
-
subject: t.subject,
|
|
450
|
-
initiator_id: t.initiator_id,
|
|
451
|
-
status: t.status,
|
|
452
|
-
target_files: JSON.parse(t.target_files || "[]"),
|
|
453
|
-
}));
|
|
454
|
-
json(res, { interrupt: true, threads: details });
|
|
455
|
-
}
|
|
456
|
-
else {
|
|
457
|
-
json(res, { interrupt: false });
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
else if (url?.startsWith("/api/agent-status/")) {
|
|
461
|
-
const agentId = url.split("/")[3];
|
|
462
|
-
const agent = registry.get(agentId);
|
|
463
|
-
if (!agent) {
|
|
464
|
-
json(res, { registered: false, status: "unknown" });
|
|
465
|
-
}
|
|
466
|
-
else {
|
|
467
|
-
const activity = activityTracker.getActivity(agentId, { idleAfterMinutes: 5 });
|
|
468
|
-
json(res, { registered: true, status: agent.status, activity: activity.activity_status });
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
else if (url === "/api/status") {
|
|
472
|
-
const online = registry.listOnline();
|
|
473
|
-
const openThreads = consultation.listThreads({ status: "open" });
|
|
474
|
-
json(res, {
|
|
475
|
-
online: online.length,
|
|
476
|
-
open_threads: openThreads.length,
|
|
477
|
-
hot_files: fileTracker.getHotFiles(30).length,
|
|
478
|
-
mqtt: services.mqttBridge.isConnected(),
|
|
479
|
-
});
|
|
480
|
-
}
|
|
481
|
-
else {
|
|
482
|
-
json(res, { error: "not found" }, 404);
|
|
483
|
-
}
|
|
75
|
+
const ctx = {
|
|
76
|
+
services,
|
|
77
|
+
httpLog,
|
|
78
|
+
authEnabled: AUTH_ENABLED,
|
|
79
|
+
getRunConfig: () => currentRunConfig,
|
|
80
|
+
setRunConfig: (cfg) => { currentRunConfig = cfg; },
|
|
81
|
+
};
|
|
82
|
+
return handleRestExt(req, res, ctx);
|
|
484
83
|
}
|
|
485
84
|
async function handleAuth(req, res) {
|
|
486
85
|
const url = req.url || "";
|
|
@@ -592,6 +191,9 @@ function handleSse(req, res) {
|
|
|
592
191
|
export async function startServer(opts) {
|
|
593
192
|
const port = opts?.port ?? PORT;
|
|
594
193
|
const dataDir = opts?.dataDir ?? DATA_DIR;
|
|
194
|
+
// Resolve MQTT ports per-call so tests/embedders can override module-load env values.
|
|
195
|
+
const mqttTcpPort = opts?.mqttTcpPort ?? MQTT_TCP_PORT;
|
|
196
|
+
const mqttWsPath = opts?.mqttWsPath ?? MQTT_WS_PATH;
|
|
595
197
|
services = createServices({ dataDir });
|
|
596
198
|
const log = services.logger;
|
|
597
199
|
httpLog = log.child({ component: "http" });
|
|
@@ -638,10 +240,19 @@ export async function startServer(opts) {
|
|
|
638
240
|
json(res, { error: "dashboard not available" }, 404);
|
|
639
241
|
return;
|
|
640
242
|
}
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
243
|
+
// B5 fix: defend against path traversal. safeJoinUnderRoot decodes the
|
|
244
|
+
// URL, strips leading slashes, resolves the path, and verifies the
|
|
245
|
+
// result stays under dashboardDir. Returns null on traversal attempts.
|
|
246
|
+
let filePath;
|
|
247
|
+
if (url === "/dashboard" || url === "/dashboard/") {
|
|
248
|
+
filePath = path.join(dashboardDir, "index.html");
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
// Strip query string before joining (browsers append ?v=...)
|
|
252
|
+
const urlPath = (url.split("?")[0] || "").replace("/dashboard/", "");
|
|
253
|
+
filePath = safeJoinUnderRoot(dashboardDir, urlPath);
|
|
254
|
+
}
|
|
255
|
+
if (filePath && existsSync(filePath)) {
|
|
645
256
|
const ext = path.extname(filePath);
|
|
646
257
|
const contentTypes = {
|
|
647
258
|
".html": "text/html",
|
|
@@ -739,30 +350,128 @@ export async function startServer(opts) {
|
|
|
739
350
|
// Start the embedded MQTT broker (TCP + WebSocket on HTTP upgrade).
|
|
740
351
|
// Awaiting ensures the TCP listener is fully bound before we connect our
|
|
741
352
|
// own client or tell users the coordinator is ready.
|
|
742
|
-
|
|
743
|
-
|
|
353
|
+
// B3 fix: when AUTH_ENABLED, gate every MQTT CONNECT by JWT in the password
|
|
354
|
+
// field. Anonymous connections are rejected. Default off (essaim and any
|
|
355
|
+
// client without auth keep working unchanged).
|
|
356
|
+
const mqttAuth = AUTH_ENABLED
|
|
357
|
+
? async (_username, password) => {
|
|
358
|
+
if (!password)
|
|
359
|
+
return false;
|
|
360
|
+
try {
|
|
361
|
+
await verifyToken(password.toString("utf-8"));
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
: undefined;
|
|
369
|
+
const broker = await startEmbeddedMqttBroker({
|
|
370
|
+
tcpPort: mqttTcpPort,
|
|
744
371
|
httpServer,
|
|
745
|
-
wsPath:
|
|
372
|
+
wsPath: mqttWsPath,
|
|
746
373
|
logger: log.child({ component: "mqtt-broker" }),
|
|
374
|
+
authenticate: mqttAuth,
|
|
375
|
+
});
|
|
376
|
+
// B3: when AUTH_ENABLED, the internal coordinator client must authenticate
|
|
377
|
+
// too. Mint a short-lived admin token for the bridge.
|
|
378
|
+
const internalToken = AUTH_ENABLED ? await createToken("coordinator-internal", "admin", "1h") : undefined;
|
|
379
|
+
await services.mqttBridge.connect({
|
|
380
|
+
url: `mqtt://127.0.0.1:${mqttTcpPort}`,
|
|
381
|
+
username: AUTH_ENABLED ? "coordinator-internal" : undefined,
|
|
382
|
+
password: internalToken,
|
|
747
383
|
});
|
|
748
|
-
// Connect the coordinator's own MQTT client to the embedded broker BEFORE
|
|
749
|
-
// the HTTP server accepts requests — agents shouldn't see a half-ready coordinator.
|
|
750
|
-
await services.mqttBridge.connect({ url: `mqtt://127.0.0.1:${MQTT_TCP_PORT}` });
|
|
751
384
|
services.mqttBridge.onOffline((agentId) => {
|
|
752
385
|
services.registry.setOffline(agentId);
|
|
753
386
|
services.consultation.handleAgentDeparture(agentId);
|
|
754
387
|
services.sseEmitter.emit("agent_offline", { agent_id: agentId });
|
|
755
388
|
});
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
389
|
+
// Wait for the HTTP server to be actually listening before resolving the
|
|
390
|
+
// returned handle. Otherwise callers (tests, essaim) may try to connect
|
|
391
|
+
// before the port is bound.
|
|
392
|
+
await new Promise((resolve, reject) => {
|
|
393
|
+
const onError = (err) => reject(err);
|
|
394
|
+
httpServer.once("error", onError);
|
|
395
|
+
httpServer.listen(port, () => {
|
|
396
|
+
httpServer.off("error", onError);
|
|
397
|
+
log.info({
|
|
398
|
+
port,
|
|
399
|
+
mcp: `POST http://localhost:${port}/mcp`,
|
|
400
|
+
rest: `POST http://localhost:${port}/api/*`,
|
|
401
|
+
sse: `GET http://localhost:${port}/api/events`,
|
|
402
|
+
mqtt_tcp: `mqtt://127.0.0.1:${mqttTcpPort}`,
|
|
403
|
+
mqtt_ws: `ws://localhost:${port}${mqttWsPath}`,
|
|
404
|
+
}, "Coordinator v3 started");
|
|
405
|
+
resolve();
|
|
406
|
+
});
|
|
765
407
|
});
|
|
408
|
+
// B2 fix: start the consultation timeout sweeper.
|
|
409
|
+
// Reads no longer mutate state — this background tick handles timeouts.
|
|
410
|
+
services.consultation.startTimeoutSweeper();
|
|
411
|
+
// B6 fix: graceful shutdown.
|
|
412
|
+
// Cleanup sequence: stop accepting new HTTP connections → end MQTT bridge →
|
|
413
|
+
// close MQTT broker → stop quota background timer → close DB.
|
|
414
|
+
// Idempotent: stopped flag prevents double-cleanup if SIGTERM races with
|
|
415
|
+
// an explicit handle.stop() call.
|
|
416
|
+
let stopped = false;
|
|
417
|
+
const stop = async () => {
|
|
418
|
+
if (stopped)
|
|
419
|
+
return;
|
|
420
|
+
stopped = true;
|
|
421
|
+
log.info("Coordinator shutting down...");
|
|
422
|
+
try {
|
|
423
|
+
await new Promise((resolve) => httpServer.close(() => resolve()));
|
|
424
|
+
}
|
|
425
|
+
catch (err) {
|
|
426
|
+
log.warn({ err }, "Error closing HTTP server");
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
await services.mqttBridge.disconnect();
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
432
|
+
log.warn({ err }, "Error disconnecting MQTT bridge");
|
|
433
|
+
}
|
|
434
|
+
try {
|
|
435
|
+
await broker.close();
|
|
436
|
+
}
|
|
437
|
+
catch (err) {
|
|
438
|
+
log.warn({ err }, "Error closing MQTT broker");
|
|
439
|
+
}
|
|
440
|
+
try {
|
|
441
|
+
services.quotaCache.stopBackgroundTick();
|
|
442
|
+
}
|
|
443
|
+
catch (err) {
|
|
444
|
+
log.warn({ err }, "Error stopping quota timer");
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
services.consultation.stopTimeoutSweeper();
|
|
448
|
+
}
|
|
449
|
+
catch (err) {
|
|
450
|
+
log.warn({ err }, "Error stopping timeout sweeper");
|
|
451
|
+
}
|
|
452
|
+
try {
|
|
453
|
+
const { closeDb } = await import("./database.js");
|
|
454
|
+
closeDb?.();
|
|
455
|
+
}
|
|
456
|
+
catch (err) {
|
|
457
|
+
log.warn({ err }, "Error closing database");
|
|
458
|
+
}
|
|
459
|
+
log.info("Coordinator shutdown complete");
|
|
460
|
+
};
|
|
461
|
+
// Register signal handlers (default true). Embedders can opt out via
|
|
462
|
+
// registerSignalHandlers: false to manage their own teardown.
|
|
463
|
+
if (opts?.registerSignalHandlers !== false) {
|
|
464
|
+
const onSignal = (signal) => {
|
|
465
|
+
log.info({ signal }, "Received shutdown signal");
|
|
466
|
+
stop().then(() => process.exit(0)).catch((err) => {
|
|
467
|
+
log.error({ err }, "Shutdown error, forcing exit");
|
|
468
|
+
process.exit(1);
|
|
469
|
+
});
|
|
470
|
+
};
|
|
471
|
+
process.once("SIGTERM", () => onSignal("SIGTERM"));
|
|
472
|
+
process.once("SIGINT", () => onSignal("SIGINT"));
|
|
473
|
+
}
|
|
474
|
+
return { port, stop };
|
|
766
475
|
}
|
|
767
476
|
// Auto-start when run directly (not imported)
|
|
768
477
|
const isMainModule = process.argv[1]?.endsWith("serve-http.ts") || process.argv[1]?.endsWith("serve-http.js");
|