mcp-coordinator 0.2.1 → 0.4.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 -846
- package/dashboard/Dockerfile +19 -19
- package/dashboard/public/index.html +1178 -1178
- package/dist/cli/dashboard.js +9 -5
- package/dist/cli/server/backup.d.ts +7 -0
- package/dist/cli/server/backup.js +162 -0
- package/dist/cli/server/index.js +5 -0
- package/dist/cli/server/restore.d.ts +2 -0
- package/dist/cli/server/restore.js +117 -0
- 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 +22 -0
- package/dist/src/consultation.js +118 -45
- package/dist/src/database.js +126 -126
- package/dist/src/db-adapter.d.ts +30 -0
- package/dist/src/db-adapter.js +32 -1
- package/dist/src/dependency-map.js +5 -5
- package/dist/src/file-tracker.d.ts +10 -0
- package/dist/src/file-tracker.js +40 -8
- package/dist/src/http/handle-health.d.ts +23 -0
- package/dist/src/http/handle-health.js +86 -0
- 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/impact-scorer.js +87 -50
- package/dist/src/introspection.js +1 -1
- package/dist/src/metrics.d.ts +83 -0
- package/dist/src/metrics.js +162 -0
- package/dist/src/mqtt-bridge.d.ts +21 -0
- package/dist/src/mqtt-bridge.js +55 -5
- 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 +189 -446
- package/dist/src/server-setup.d.ts +2 -0
- package/dist/src/server-setup.js +25 -366
- package/dist/src/sse-emitter.d.ts +6 -0
- package/dist/src/sse-emitter.js +50 -2
- 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 +83 -80
package/dist/src/file-tracker.js
CHANGED
|
@@ -3,7 +3,7 @@ export class FileTracker {
|
|
|
3
3
|
log(params) {
|
|
4
4
|
const db = getDb();
|
|
5
5
|
const module = this.fileToModule(params.file_path);
|
|
6
|
-
db.prepare(`INSERT INTO file_activity (session_id, agent_id, agent_name, tool_name, file_path, module)
|
|
6
|
+
db.prepare(`INSERT INTO file_activity (session_id, agent_id, agent_name, tool_name, file_path, module)
|
|
7
7
|
VALUES (?, ?, ?, ?, ?, ?)`).run(params.session_id, params.agent_id, params.agent_name || null, params.tool_name, params.file_path, module);
|
|
8
8
|
}
|
|
9
9
|
getBySession(sessionId) {
|
|
@@ -12,11 +12,11 @@ export class FileTracker {
|
|
|
12
12
|
}
|
|
13
13
|
getHotFiles(sinceMinutes = 30) {
|
|
14
14
|
const db = getDb();
|
|
15
|
-
const rows = db.prepare(`SELECT file_path, COUNT(DISTINCT agent_id) as agent_count, GROUP_CONCAT(DISTINCT agent_id) as agents
|
|
16
|
-
FROM file_activity
|
|
17
|
-
WHERE created_at > datetime('now', '-' || ? || ' minutes')
|
|
18
|
-
GROUP BY file_path
|
|
19
|
-
HAVING COUNT(DISTINCT agent_id) > 1
|
|
15
|
+
const rows = db.prepare(`SELECT file_path, COUNT(DISTINCT agent_id) as agent_count, GROUP_CONCAT(DISTINCT agent_id) as agents
|
|
16
|
+
FROM file_activity
|
|
17
|
+
WHERE created_at > datetime('now', '-' || ? || ' minutes')
|
|
18
|
+
GROUP BY file_path
|
|
19
|
+
HAVING COUNT(DISTINCT agent_id) > 1
|
|
20
20
|
ORDER BY agent_count DESC`).all(sinceMinutes);
|
|
21
21
|
return rows.map((r) => ({
|
|
22
22
|
file_path: r.file_path,
|
|
@@ -26,11 +26,43 @@ export class FileTracker {
|
|
|
26
26
|
}
|
|
27
27
|
checkFileConflict(filePath, agentId, withinMinutes = 30) {
|
|
28
28
|
const db = getDb();
|
|
29
|
-
const rows = db.prepare(`SELECT DISTINCT agent_id FROM file_activity
|
|
30
|
-
WHERE file_path = ? AND agent_id != ?
|
|
29
|
+
const rows = db.prepare(`SELECT DISTINCT agent_id FROM file_activity
|
|
30
|
+
WHERE file_path = ? AND agent_id != ?
|
|
31
31
|
AND created_at > datetime('now', '-' || ? || ' minutes')`).all(filePath, agentId, withinMinutes);
|
|
32
32
|
return { conflict: rows.length > 0, agents: rows.map((r) => r.agent_id) };
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* P2 perf: batch lookup of recent file→agents activity. Replaces N
|
|
36
|
+
* `checkFileConflict` calls (one per file) with a single SQL query, then
|
|
37
|
+
* builds an in-memory reverse index. The impact scorer uses this so its
|
|
38
|
+
* per-file inner loop is O(1) Map.get() rather than O(F) SQL round-trips.
|
|
39
|
+
*
|
|
40
|
+
* Excludes the calling agent (so the scorer doesn't flag the announcer
|
|
41
|
+
* against themselves). Returns Map<file_path, Set<agent_id>>.
|
|
42
|
+
*/
|
|
43
|
+
getFileToAgentsIndex(filePaths, excludeAgentId, withinMinutes = 30) {
|
|
44
|
+
const index = new Map();
|
|
45
|
+
if (filePaths.length === 0)
|
|
46
|
+
return index;
|
|
47
|
+
const db = getDb();
|
|
48
|
+
// Dynamic IN-list — better-sqlite3 binds each ? positionally. Cheap because
|
|
49
|
+
// the impact scorer only passes target_files + depends_on_files (typically
|
|
50
|
+
// a handful of files per announce_work call).
|
|
51
|
+
const placeholders = filePaths.map(() => "?").join(",");
|
|
52
|
+
const rows = db.prepare(`SELECT DISTINCT file_path, agent_id FROM file_activity
|
|
53
|
+
WHERE file_path IN (${placeholders})
|
|
54
|
+
AND agent_id != ?
|
|
55
|
+
AND created_at > datetime('now', '-' || ? || ' minutes')`).all(...filePaths, excludeAgentId, withinMinutes);
|
|
56
|
+
for (const r of rows) {
|
|
57
|
+
let set = index.get(r.file_path);
|
|
58
|
+
if (!set) {
|
|
59
|
+
set = new Set();
|
|
60
|
+
index.set(r.file_path, set);
|
|
61
|
+
}
|
|
62
|
+
set.add(r.agent_id);
|
|
63
|
+
}
|
|
64
|
+
return index;
|
|
65
|
+
}
|
|
34
66
|
fileToModule(filePath) {
|
|
35
67
|
// Strip leading / so "/server/src/x.ts" and "server/src/x.ts" produce the
|
|
36
68
|
// same module name. Without this, split("/") on an absolute path yields
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "http";
|
|
2
|
+
import type { CoordinatorServices } from "../server-setup.js";
|
|
3
|
+
/**
|
|
4
|
+
* Liveness probe — process is alive. Always returns 200 with no dep checks
|
|
5
|
+
* so orchestrators don't restart the pod over transient downstream failures.
|
|
6
|
+
*/
|
|
7
|
+
export declare function handleLivez(_req: IncomingMessage, res: ServerResponse): void;
|
|
8
|
+
/**
|
|
9
|
+
* Readiness probe — downstream deps must all be green for the LB to route
|
|
10
|
+
* traffic here. 503 when any check fails so the pod is drained until ready.
|
|
11
|
+
*
|
|
12
|
+
* Each check is wrapped in try/catch so a thrown DB/MQTT error becomes a
|
|
13
|
+
* structured `{ok:false,error:"…"}` instead of a 500. The response shape is
|
|
14
|
+
* identical between 200 and 503 so consumers can parse uniformly.
|
|
15
|
+
*/
|
|
16
|
+
export declare function handleReadyz(_req: IncomingMessage, res: ServerResponse, services: Pick<CoordinatorServices, "mqttBridge">): void;
|
|
17
|
+
/**
|
|
18
|
+
* Backwards-compatible alias. The original /health route returned a fixed
|
|
19
|
+
* {status:"ok",version} payload with no dep checks; semantically that is a
|
|
20
|
+
* liveness probe, so we delegate. Anything that polled /health for "is the
|
|
21
|
+
* process up" continues to work without changes.
|
|
22
|
+
*/
|
|
23
|
+
export declare function handleHealth(req: IncomingMessage, res: ServerResponse): void;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { getDb } from "../database.js";
|
|
2
|
+
import { json } from "./utils.js";
|
|
3
|
+
import { getVersion } from "../../cli/version.js";
|
|
4
|
+
/**
|
|
5
|
+
* v0.4 Operability: Kubernetes-style health probes.
|
|
6
|
+
*
|
|
7
|
+
* - /livez → is the process alive? Used by an orchestrator (k8s, systemd,
|
|
8
|
+
* docker swarm) to decide whether to restart the pod. MUST NOT
|
|
9
|
+
* check downstream deps; an unreachable DB does not mean the
|
|
10
|
+
* coordinator process should be killed and restarted.
|
|
11
|
+
*
|
|
12
|
+
* - /readyz → are downstream deps ready? Used by a load balancer / service
|
|
13
|
+
* mesh to decide whether to add this pod to rotation. Returns 503
|
|
14
|
+
* when the DB or MQTT broker is not reachable so the LB drains
|
|
15
|
+
* traffic until the coordinator can actually serve it.
|
|
16
|
+
*
|
|
17
|
+
* - /health → backwards-compat alias for /livez. The original stub returned
|
|
18
|
+
* {status:"ok",version} unconditionally; preserving alive-only
|
|
19
|
+
* semantics keeps existing dashboards and uptime probes green
|
|
20
|
+
* without forcing them to migrate.
|
|
21
|
+
*/
|
|
22
|
+
const STARTED_AT_MS = Date.now();
|
|
23
|
+
const VERSION = getVersion();
|
|
24
|
+
function uptimeSeconds() {
|
|
25
|
+
return Math.floor((Date.now() - STARTED_AT_MS) / 1000);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Liveness probe — process is alive. Always returns 200 with no dep checks
|
|
29
|
+
* so orchestrators don't restart the pod over transient downstream failures.
|
|
30
|
+
*/
|
|
31
|
+
export function handleLivez(_req, res) {
|
|
32
|
+
json(res, {
|
|
33
|
+
status: "alive",
|
|
34
|
+
uptime_seconds: uptimeSeconds(),
|
|
35
|
+
version: VERSION,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Readiness probe — downstream deps must all be green for the LB to route
|
|
40
|
+
* traffic here. 503 when any check fails so the pod is drained until ready.
|
|
41
|
+
*
|
|
42
|
+
* Each check is wrapped in try/catch so a thrown DB/MQTT error becomes a
|
|
43
|
+
* structured `{ok:false,error:"…"}` instead of a 500. The response shape is
|
|
44
|
+
* identical between 200 and 503 so consumers can parse uniformly.
|
|
45
|
+
*/
|
|
46
|
+
export function handleReadyz(_req, res, services) {
|
|
47
|
+
const checks = {
|
|
48
|
+
db: { ok: false },
|
|
49
|
+
mqtt: { ok: false },
|
|
50
|
+
};
|
|
51
|
+
try {
|
|
52
|
+
// Cheapest possible round-trip that exercises the connection without
|
|
53
|
+
// touching application tables. Throws if the handle is closed or the
|
|
54
|
+
// file is locked beyond busy_timeout.
|
|
55
|
+
getDb().prepare("SELECT 1").get();
|
|
56
|
+
checks.db.ok = true;
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
checks.db.error = err.message;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
if (services.mqttBridge.isConnected()) {
|
|
63
|
+
checks.mqtt.ok = true;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
checks.mqtt.error = "not connected";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
checks.mqtt.error = err.message;
|
|
71
|
+
}
|
|
72
|
+
const allOk = checks.db.ok && checks.mqtt.ok;
|
|
73
|
+
json(res, {
|
|
74
|
+
status: allOk ? "ready" : "not_ready",
|
|
75
|
+
checks,
|
|
76
|
+
}, allOk ? 200 : 503);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Backwards-compatible alias. The original /health route returned a fixed
|
|
80
|
+
* {status:"ok",version} payload with no dep checks; semantically that is a
|
|
81
|
+
* liveness probe, so we delegate. Anything that polled /health for "is the
|
|
82
|
+
* process up" continues to work without changes.
|
|
83
|
+
*/
|
|
84
|
+
export function handleHealth(req, res) {
|
|
85
|
+
return handleLivez(req, res);
|
|
86
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "http";
|
|
2
|
+
import type { CoordinatorServices } from "../server-setup.js";
|
|
3
|
+
import type { Logger } from "../logger.js";
|
|
4
|
+
/**
|
|
5
|
+
* S1: REST router extracted from serve-http.ts. Was a 382-line `handleRest`
|
|
6
|
+
* function inside startServer's closure with module-scope captures for
|
|
7
|
+
* services, httpLog, currentRunConfig, AUTH_ENABLED, etc.
|
|
8
|
+
*
|
|
9
|
+
* The body is unchanged — only ambient closure references became explicit
|
|
10
|
+
* fields on RestContext. parseBody/json moved to ./utils.js to share between
|
|
11
|
+
* REST + SSE + auth handlers.
|
|
12
|
+
*
|
|
13
|
+
* The currentRunConfig mutable state stays in serve-http.ts (single source
|
|
14
|
+
* of truth) and is exposed via getRunConfig/setRunConfig on the context.
|
|
15
|
+
*/
|
|
16
|
+
export interface RestContext {
|
|
17
|
+
services: CoordinatorServices;
|
|
18
|
+
httpLog: Logger;
|
|
19
|
+
authEnabled: boolean;
|
|
20
|
+
getRunConfig: () => Record<string, unknown> | null;
|
|
21
|
+
setRunConfig: (cfg: Record<string, unknown> | null) => void;
|
|
22
|
+
}
|
|
23
|
+
export declare function handleRest(req: IncomingMessage, res: ServerResponse, ctx: RestContext): Promise<void>;
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { getDb } from "../database.js";
|
|
2
|
+
import { runCommonAnnounceFlow } from "../announce-workflow.js";
|
|
3
|
+
import { canResetDb } from "../reset-guard.js";
|
|
4
|
+
import { parseBody, json } from "./utils.js";
|
|
5
|
+
export async function handleRest(req, res, ctx) {
|
|
6
|
+
const { services, httpLog, authEnabled, getRunConfig, setRunConfig } = ctx;
|
|
7
|
+
const url = req.url || "";
|
|
8
|
+
const body = await parseBody(req);
|
|
9
|
+
const agentId = body.agent_id;
|
|
10
|
+
// Dashboard/work-stealing polls these endpoints every few seconds — demote to debug
|
|
11
|
+
// to keep the info log focused on coordination events (announce, claim, resolve, etc).
|
|
12
|
+
const isPoll = url === "/api/hot-files" || url === "/api/threads-active" || url === "/api/status" || url === "/api/quota";
|
|
13
|
+
// Note: /api/quota/refresh is NOT in the poll list — it's a manual user
|
|
14
|
+
// action and deserves an info-level log for auditability.
|
|
15
|
+
if (isPoll) {
|
|
16
|
+
httpLog.debug({ method: req.method, url, agent_id: agentId }, "REST request");
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
httpLog.info({ method: req.method, url, agent_id: agentId }, "REST request");
|
|
20
|
+
}
|
|
21
|
+
const { registry, activityTracker, consultation, fileTracker, introspection, sseEmitter, mqttBridge, quotaCache } = services;
|
|
22
|
+
if (url === "/api/register") {
|
|
23
|
+
const { agent_id, name, modules } = body;
|
|
24
|
+
const agent = registry.register(agent_id, name, modules || []);
|
|
25
|
+
sseEmitter.emit("agent_online", { agent_id, name, modules });
|
|
26
|
+
json(res, agent);
|
|
27
|
+
}
|
|
28
|
+
else if (url === "/api/session-start") {
|
|
29
|
+
const online = registry.listOnline();
|
|
30
|
+
const openThreads = consultation.listThreads({ status: "open" });
|
|
31
|
+
const hotFiles = fileTracker.getHotFiles(30);
|
|
32
|
+
const briefing = [
|
|
33
|
+
`Agents en ligne: ${online.map((a) => a.name).join(", ") || "aucun"}`,
|
|
34
|
+
`Consultations ouvertes: ${openThreads.length}`,
|
|
35
|
+
`Hot files: ${hotFiles.map((f) => f.file_path).join(", ") || "aucun"}`,
|
|
36
|
+
].join("\n");
|
|
37
|
+
json(res, { briefing, summary: { online: online.length, open_threads: openThreads.length, hot_files: hotFiles.length } });
|
|
38
|
+
}
|
|
39
|
+
else if (url === "/api/session-stop") {
|
|
40
|
+
const { agent_id } = body;
|
|
41
|
+
registry.setOffline(agent_id);
|
|
42
|
+
activityTracker.reportOffline(agent_id);
|
|
43
|
+
consultation.handleAgentDeparture(agent_id);
|
|
44
|
+
sseEmitter.emit("agent_offline", { agent_id });
|
|
45
|
+
json(res, { ok: true });
|
|
46
|
+
}
|
|
47
|
+
else if (url === "/api/check-conflict") {
|
|
48
|
+
const { file, agent_id } = body;
|
|
49
|
+
const conflict = fileTracker.checkFileConflict(file, agent_id, 30);
|
|
50
|
+
const warnings = [];
|
|
51
|
+
if (conflict.conflict) {
|
|
52
|
+
warnings.push(`File ${file} recently edited by: ${conflict.agents.join(", ")}`);
|
|
53
|
+
}
|
|
54
|
+
json(res, { conflict: conflict.conflict, warnings });
|
|
55
|
+
}
|
|
56
|
+
else if (url === "/api/log-file") {
|
|
57
|
+
const { session_id, agent_id, agent_name, tool_name, file } = body;
|
|
58
|
+
fileTracker.log({ session_id, agent_id, agent_name, tool_name, file_path: file });
|
|
59
|
+
activityTracker.reportFileActivity(agent_id, file);
|
|
60
|
+
sseEmitter.emit("file_edited", { agent_id, agent_name: agent_name || agent_id, file, tool_name });
|
|
61
|
+
json(res, { ok: true });
|
|
62
|
+
}
|
|
63
|
+
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;
|
|
65
|
+
const thread = consultation.announceWork({ agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to });
|
|
66
|
+
const agentInfo = registry.get(agent_id);
|
|
67
|
+
// S2 fix: shared workflow (impact scoring, override respondents, auto-resolve,
|
|
68
|
+
// impact_scored + introspection SSE, plan-quality downgrade event). Same
|
|
69
|
+
// function used by the MCP announce_work tool path.
|
|
70
|
+
const { updated, categorized, respondents, planQuality } = runCommonAnnounceFlow(services, thread.id, {
|
|
71
|
+
agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open,
|
|
72
|
+
});
|
|
73
|
+
// REST-specific thread_opened SSE shape (different field set than MCP — kept
|
|
74
|
+
// divergent because consumers may depend on this exact contract).
|
|
75
|
+
sseEmitter.emit("thread_opened", {
|
|
76
|
+
thread_id: thread.id, subject, agent_id, agent_name: agentInfo?.name || agent_id,
|
|
77
|
+
target_modules, target_files, expected_respondents: respondents,
|
|
78
|
+
conflicts: updated.conflicts ? JSON.parse(updated.conflicts) : [],
|
|
79
|
+
created_at: updated.created_at,
|
|
80
|
+
mode: planQuality.mode,
|
|
81
|
+
plan: plan || null,
|
|
82
|
+
plan_quality: planQuality,
|
|
83
|
+
});
|
|
84
|
+
json(res, { thread_id: thread.id, status: updated.status, impact: categorized });
|
|
85
|
+
}
|
|
86
|
+
else if (url === "/api/post-to-thread") {
|
|
87
|
+
const { thread_id, agent_id, agent_name, type, content } = body;
|
|
88
|
+
// Pre-check the thread so we can return actionable status codes instead
|
|
89
|
+
// of always-500 on any error. The client uses the status to decide
|
|
90
|
+
// whether to warn (unexpected) or silently skip (normal race).
|
|
91
|
+
const targetThread = consultation.getThread(thread_id);
|
|
92
|
+
if (!targetThread) {
|
|
93
|
+
json(res, { error: "thread_not_found", thread_id }, 404);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (targetThread.status === "cancelled") {
|
|
97
|
+
json(res, { error: "thread_cancelled", thread_id }, 410);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const msg = consultation.postToThread({ thread_id, agent_id, agent_name, type, content });
|
|
101
|
+
const thread = consultation.getThread(thread_id);
|
|
102
|
+
sseEmitter.emit("message_posted", {
|
|
103
|
+
thread_id, agent_id, agent_name: agent_name || agent_id,
|
|
104
|
+
type, content, round: thread?.round || 1,
|
|
105
|
+
token_estimate: msg.token_estimate || 0,
|
|
106
|
+
});
|
|
107
|
+
json(res, msg);
|
|
108
|
+
}
|
|
109
|
+
else if (url === "/api/token-usage") {
|
|
110
|
+
// Agent → coordinator telemetry, emitted once per LLM turn so the dashboard
|
|
111
|
+
// and reports can pinpoint where tokens are being burned.
|
|
112
|
+
const payload = body;
|
|
113
|
+
sseEmitter.emit("token_usage", payload);
|
|
114
|
+
json(res, { ok: true });
|
|
115
|
+
}
|
|
116
|
+
else if (url === "/api/unclaim-task") {
|
|
117
|
+
const { thread_id, agent_id } = body;
|
|
118
|
+
if (!thread_id || !agent_id) {
|
|
119
|
+
json(res, { success: false, error: "thread_id and agent_id required" }, 400);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const db = getDb();
|
|
123
|
+
// F4: increment unclaim counter. After POISON_THRESHOLD aborts, flip status
|
|
124
|
+
// to "poisoned" so no agent claims it again — prevents the tight
|
|
125
|
+
// claim → no DONE → unclaim → re-claim loop we observed on stuck tasks.
|
|
126
|
+
// Only the claiming agent can unclaim to prevent cross-agent interference.
|
|
127
|
+
const POISON_THRESHOLD = 2;
|
|
128
|
+
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);
|
|
129
|
+
let poisoned = false;
|
|
130
|
+
if (result.changes === 1) {
|
|
131
|
+
const row = db.prepare("SELECT unclaim_count FROM threads WHERE id = ?").get(thread_id);
|
|
132
|
+
if (row && (row.unclaim_count ?? 0) >= POISON_THRESHOLD) {
|
|
133
|
+
db.prepare("UPDATE threads SET status = 'poisoned' WHERE id = ? AND status = 'open'").run(thread_id);
|
|
134
|
+
poisoned = true;
|
|
135
|
+
httpLog.warn({ thread_id, unclaim_count: row.unclaim_count }, "thread poisoned after repeated unclaims");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
json(res, { success: result.changes === 1, poisoned });
|
|
139
|
+
}
|
|
140
|
+
else if (url === "/api/claim-task") {
|
|
141
|
+
const { thread_id, agent_id } = body;
|
|
142
|
+
if (!thread_id || !agent_id) {
|
|
143
|
+
json(res, { success: false, error: "thread_id and agent_id required" }, 400);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const db = getDb();
|
|
147
|
+
// Only claim threads with status='open' — poisoned threads are filtered out
|
|
148
|
+
// automatically because the status filter excludes them.
|
|
149
|
+
// Directed-dispatch constraint: if assigned_to is set, only that specific
|
|
150
|
+
// agent can claim; NULL keeps the original open-pool semantics.
|
|
151
|
+
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);
|
|
152
|
+
if (result.changes === 1) {
|
|
153
|
+
mqttBridge.publishTaskClaimed(thread_id, agent_id);
|
|
154
|
+
sseEmitter.emit("task_claimed", { thread_id, agent_id });
|
|
155
|
+
json(res, { success: true });
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
const thread = consultation.getThread(thread_id);
|
|
159
|
+
// Surface the assigned_to in the 'why not' response so clients can
|
|
160
|
+
// distinguish "already claimed by X" from "reserved for Y".
|
|
161
|
+
json(res, {
|
|
162
|
+
success: false,
|
|
163
|
+
claimed_by: thread?.claimed_by || null,
|
|
164
|
+
assigned_to: thread?.assigned_to || null,
|
|
165
|
+
status: thread?.status,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else if (url === "/api/propose-resolution") {
|
|
170
|
+
const { thread_id, agent_id, summary } = body;
|
|
171
|
+
const agentInfo = registry.get(agent_id);
|
|
172
|
+
consultation.proposeResolution(thread_id, agent_id, summary);
|
|
173
|
+
sseEmitter.emit("resolution_proposed", {
|
|
174
|
+
thread_id, agent_id, agent_name: agentInfo?.name || agent_id, summary,
|
|
175
|
+
});
|
|
176
|
+
json(res, consultation.getThread(thread_id));
|
|
177
|
+
mqttBridge.publishTaskCompleted(thread_id, agent_id, summary);
|
|
178
|
+
}
|
|
179
|
+
else if (url === "/api/approve-resolution") {
|
|
180
|
+
const { thread_id, agent_id } = body;
|
|
181
|
+
const agentInfo = registry.get(agent_id);
|
|
182
|
+
consultation.approveResolution(thread_id, agent_id, agentInfo?.name);
|
|
183
|
+
const t = consultation.getThread(thread_id);
|
|
184
|
+
json(res, t);
|
|
185
|
+
}
|
|
186
|
+
else if (url?.startsWith("/api/consultation/") && url?.endsWith("/status")) {
|
|
187
|
+
const threadId = url.split("/")[3];
|
|
188
|
+
const thread = consultation.getThreadWithMessages(threadId);
|
|
189
|
+
if (!thread) {
|
|
190
|
+
json(res, { error: "not found" }, 404);
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
json(res, {
|
|
194
|
+
status: thread.thread.status,
|
|
195
|
+
messages: thread.messages,
|
|
196
|
+
resolution_summary: thread.thread.resolution_summary,
|
|
197
|
+
expected_respondents: JSON.parse(thread.thread.expected_respondents || "[]"),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
else if (url === "/api/threads-active") {
|
|
202
|
+
const open = consultation.listThreads({ status: "open" });
|
|
203
|
+
const resolving = consultation.listThreads({ status: "resolving" });
|
|
204
|
+
json(res, [...open, ...resolving]);
|
|
205
|
+
}
|
|
206
|
+
else if (url === "/api/hot-files") {
|
|
207
|
+
const { since_minutes } = body;
|
|
208
|
+
json(res, fileTracker.getHotFiles(since_minutes || 30));
|
|
209
|
+
}
|
|
210
|
+
else if (url === "/api/quota") {
|
|
211
|
+
// Pre-flight + live widget endpoint. 200 with fresh QuotaInfo when the
|
|
212
|
+
// Keychain + Anthropic API are reachable, 503 otherwise. Consumers treat
|
|
213
|
+
// 503 as "quota unknown = proceed" (fail-open) per the project decision.
|
|
214
|
+
const info = await quotaCache.get();
|
|
215
|
+
if (!info) {
|
|
216
|
+
const status = quotaCache.snapshot();
|
|
217
|
+
json(res, {
|
|
218
|
+
error: "quota unavailable",
|
|
219
|
+
reason: status.lastError,
|
|
220
|
+
cooldown_until: status.cooldownUntil,
|
|
221
|
+
}, 503);
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
json(res, {
|
|
225
|
+
five_hour: info.fiveHour,
|
|
226
|
+
seven_day: info.sevenDay,
|
|
227
|
+
seven_day_sonnet: info.sevenDaySonnet,
|
|
228
|
+
fetched_at: info.fetchedAt,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
else if (url === "/api/quota/refresh") {
|
|
233
|
+
// Force-refresh the cache, bypassing the TTL. Used by the dashboard's
|
|
234
|
+
// manual refresh button. The underlying quotaCache.refresh() is single-
|
|
235
|
+
// flight-deduped, so mashing the button doesn't stack parallel fetches.
|
|
236
|
+
// The onRefresh callback on the cache broadcasts via SSE + MQTT, so the
|
|
237
|
+
// dashboard receives the update through the normal channel too — this
|
|
238
|
+
// endpoint only exists for "give me the answer now" semantics.
|
|
239
|
+
const info = await quotaCache.refresh();
|
|
240
|
+
if (!info) {
|
|
241
|
+
const status = quotaCache.snapshot();
|
|
242
|
+
json(res, {
|
|
243
|
+
error: "quota unavailable",
|
|
244
|
+
reason: status.lastError,
|
|
245
|
+
cooldown_until: status.cooldownUntil,
|
|
246
|
+
}, 503);
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
json(res, {
|
|
250
|
+
five_hour: info.fiveHour,
|
|
251
|
+
seven_day: info.sevenDay,
|
|
252
|
+
seven_day_sonnet: info.sevenDaySonnet,
|
|
253
|
+
fetched_at: info.fetchedAt,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
else if (url === "/api/introspection-response") {
|
|
258
|
+
const { introspection_id, concerned, reason } = body;
|
|
259
|
+
const intro = introspection.respond(introspection_id, concerned, reason);
|
|
260
|
+
// If concerned, add to thread's expected_respondents
|
|
261
|
+
if (concerned && intro) {
|
|
262
|
+
const db = getDb();
|
|
263
|
+
const thread = consultation.getThread(intro.thread_id);
|
|
264
|
+
if (thread && (thread.status === "open" || thread.status === "resolving")) {
|
|
265
|
+
const respondents = JSON.parse(thread.expected_respondents || "[]");
|
|
266
|
+
if (!respondents.includes(intro.agent_id)) {
|
|
267
|
+
respondents.push(intro.agent_id);
|
|
268
|
+
db.prepare("UPDATE threads SET expected_respondents = ? WHERE id = ?")
|
|
269
|
+
.run(JSON.stringify(respondents), thread.id);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const agentInfo = registry.get(intro?.agent_id || "");
|
|
274
|
+
sseEmitter.emit("introspection_completed", {
|
|
275
|
+
introspection_id, thread_id: intro?.thread_id,
|
|
276
|
+
agent_id: intro?.agent_id, agent_name: agentInfo?.name || intro?.agent_id,
|
|
277
|
+
concerned, reason,
|
|
278
|
+
});
|
|
279
|
+
json(res, intro);
|
|
280
|
+
}
|
|
281
|
+
else if (url?.startsWith("/api/pending-introspections")) {
|
|
282
|
+
const urlObj = new URL(url, "http://localhost");
|
|
283
|
+
const agent_id = urlObj.searchParams.get("agent_id") || "";
|
|
284
|
+
const pending = introspection.getPending(agent_id);
|
|
285
|
+
json(res, pending);
|
|
286
|
+
}
|
|
287
|
+
else if (url === "/api/run-config") {
|
|
288
|
+
if (req.method === "POST") {
|
|
289
|
+
setRunConfig(body);
|
|
290
|
+
sseEmitter.emit("run_config", getRunConfig());
|
|
291
|
+
json(res, { ok: true });
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
json(res, getRunConfig() || { active: false });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
else if (url === "/api/reset") {
|
|
298
|
+
// B4 fix: gate destructive reset when AUTH is disabled.
|
|
299
|
+
// When AUTH_ENABLED=true, ADMIN_ONLY_ROUTES already enforced upstream
|
|
300
|
+
// by authenticateRequest (see auth.ts). This guard covers the AUTH off case.
|
|
301
|
+
if (!canResetDb(process.env, authEnabled)) {
|
|
302
|
+
json(res, {
|
|
303
|
+
error: "Forbidden: /api/reset requires NODE_ENV=test, COORDINATOR_ALLOW_RESET=true, or COORDINATOR_AUTH_ENABLED with admin token",
|
|
304
|
+
}, 403);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
// Reset all tables for clean test run (disable FK checks to avoid ordering issues)
|
|
308
|
+
const db = getDb();
|
|
309
|
+
db.exec("PRAGMA foreign_keys = OFF");
|
|
310
|
+
db.exec("DELETE FROM introspections");
|
|
311
|
+
db.exec("DELETE FROM events");
|
|
312
|
+
db.exec("DELETE FROM thread_messages");
|
|
313
|
+
db.exec("DELETE FROM threads");
|
|
314
|
+
db.exec("DELETE FROM action_summaries");
|
|
315
|
+
db.exec("DELETE FROM file_activity");
|
|
316
|
+
db.exec("DELETE FROM agent_activity_status");
|
|
317
|
+
db.exec("DELETE FROM dependency_map");
|
|
318
|
+
db.exec("DELETE FROM agents");
|
|
319
|
+
db.exec("DELETE FROM revoked_agents");
|
|
320
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
321
|
+
setRunConfig(null);
|
|
322
|
+
json(res, { ok: true });
|
|
323
|
+
}
|
|
324
|
+
else if (url === "/api/check-interrupt") {
|
|
325
|
+
const { agent_id } = body;
|
|
326
|
+
// Check for threads where this agent is an expected respondent and hasn't posted yet.
|
|
327
|
+
// Covers both open threads (waiting for initial response) and resolving threads
|
|
328
|
+
// (waiting for approval/contest of a proposed resolution).
|
|
329
|
+
const pendingThreads = [
|
|
330
|
+
...consultation.listThreads({ status: "open" }),
|
|
331
|
+
...consultation.listThreads({ status: "resolving" }),
|
|
332
|
+
].filter((t) => {
|
|
333
|
+
const respondents = JSON.parse(t.expected_respondents || "[]");
|
|
334
|
+
return respondents.includes(agent_id);
|
|
335
|
+
});
|
|
336
|
+
if (pendingThreads.length > 0) {
|
|
337
|
+
const details = pendingThreads.map((t) => ({
|
|
338
|
+
thread_id: t.id,
|
|
339
|
+
subject: t.subject,
|
|
340
|
+
initiator_id: t.initiator_id,
|
|
341
|
+
status: t.status,
|
|
342
|
+
target_files: JSON.parse(t.target_files || "[]"),
|
|
343
|
+
}));
|
|
344
|
+
json(res, { interrupt: true, threads: details });
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
json(res, { interrupt: false });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
else if (url?.startsWith("/api/agent-status/")) {
|
|
351
|
+
const aid = url.split("/")[3];
|
|
352
|
+
const agent = registry.get(aid);
|
|
353
|
+
if (!agent) {
|
|
354
|
+
json(res, { registered: false, status: "unknown" });
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
const activity = activityTracker.getActivity(aid, { idleAfterMinutes: 5 });
|
|
358
|
+
json(res, { registered: true, status: agent.status, activity: activity.activity_status });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
else if (url === "/api/status") {
|
|
362
|
+
const online = registry.listOnline();
|
|
363
|
+
const openThreads = consultation.listThreads({ status: "open" });
|
|
364
|
+
json(res, {
|
|
365
|
+
online: online.length,
|
|
366
|
+
open_threads: openThreads.length,
|
|
367
|
+
hot_files: fileTracker.getHotFiles(30).length,
|
|
368
|
+
mqtt: services.mqttBridge.isConnected(),
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
json(res, { error: "not found" }, 404);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
export declare function parseBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
|
7
|
+
export declare function json(res: ServerResponse, data: unknown, status?: number): void;
|
|
8
|
+
/**
|
|
9
|
+
* Decode a JWT payload WITHOUT verifying. Used only on tokens we just minted
|
|
10
|
+
* ourselves (to read the `exp` claim before returning it to the client). Real
|
|
11
|
+
* verification of inbound tokens happens in `authenticateRequest` via
|
|
12
|
+
* jose.jwtVerify().
|
|
13
|
+
*/
|
|
14
|
+
export declare function decodeJwtPayload(token: string): Record<string, unknown>;
|
|
15
|
+
export declare function safeEqual(a: string, b: string): boolean;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { timingSafeEqual } from "crypto";
|
|
2
|
+
/**
|
|
3
|
+
* S1: shared HTTP helpers extracted from serve-http.ts.
|
|
4
|
+
* parseBody, json, decodeJwtPayload, safeEqual.
|
|
5
|
+
*/
|
|
6
|
+
export function parseBody(req) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
let body = "";
|
|
9
|
+
req.on("data", (chunk) => (body += chunk.toString()));
|
|
10
|
+
req.on("end", () => {
|
|
11
|
+
try {
|
|
12
|
+
resolve(body ? JSON.parse(body) : {});
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
reject(new Error("Invalid JSON"));
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
req.on("error", reject);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
export function json(res, data, status = 200) {
|
|
22
|
+
res.writeHead(status, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
|
|
23
|
+
res.end(JSON.stringify(data));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Decode a JWT payload WITHOUT verifying. Used only on tokens we just minted
|
|
27
|
+
* ourselves (to read the `exp` claim before returning it to the client). Real
|
|
28
|
+
* verification of inbound tokens happens in `authenticateRequest` via
|
|
29
|
+
* jose.jwtVerify().
|
|
30
|
+
*/
|
|
31
|
+
export function decodeJwtPayload(token) {
|
|
32
|
+
const base64url = token.split(".")[1];
|
|
33
|
+
return JSON.parse(Buffer.from(base64url, "base64url").toString("utf-8"));
|
|
34
|
+
}
|
|
35
|
+
export function safeEqual(a, b) {
|
|
36
|
+
if (a.length !== b.length)
|
|
37
|
+
return false;
|
|
38
|
+
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
39
|
+
}
|