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/cli/dashboard.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
import {
|
|
2
|
+
import { spawn } from "child_process";
|
|
3
3
|
export function createDashboardCommand() {
|
|
4
4
|
return new Command("dashboard")
|
|
5
5
|
.description("Open the real-time dashboard")
|
|
6
6
|
.action(() => {
|
|
7
7
|
const url = "http://localhost:3100/dashboard";
|
|
8
8
|
console.log(`Dashboard: ${url}`);
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
// Use spawn with an argv array (no shell) so the URL is never
|
|
10
|
+
// interpolated into a shell command — eliminates command-injection risk.
|
|
11
|
+
const opener = process.platform === "darwin" ? "open"
|
|
12
|
+
: process.platform === "win32" ? "explorer.exe"
|
|
13
|
+
: "xdg-open";
|
|
14
|
+
const child = spawn(opener, [url], { stdio: "ignore", detached: true });
|
|
15
|
+
child.on("error", () => { });
|
|
16
|
+
child.unref();
|
|
13
17
|
});
|
|
14
18
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
/**
|
|
3
|
+
* Check whether the coordinator daemon appears to be running.
|
|
4
|
+
* Returns the pid if alive, or null otherwise.
|
|
5
|
+
*/
|
|
6
|
+
export declare function getRunningCoordinatorPid(configDir: string): number | null;
|
|
7
|
+
export declare function createServerBackupCommand(): Command;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { existsSync, readFileSync, statSync } from "fs";
|
|
3
|
+
import { join, resolve, basename } from "path";
|
|
4
|
+
import { create as tarCreate } from "tar";
|
|
5
|
+
import { getConfigDir, loadConfig } from "../config.js";
|
|
6
|
+
/**
|
|
7
|
+
* Format a timestamp suitable for filenames: YYYY-MM-DD-HHMMSS (UTC).
|
|
8
|
+
*/
|
|
9
|
+
function timestampSlug(date = new Date()) {
|
|
10
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
11
|
+
return (`${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}` +
|
|
12
|
+
`-${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}`);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Returns true if a process with the given pid is currently alive.
|
|
16
|
+
* On both POSIX and Windows, sending signal 0 acts as a liveness probe
|
|
17
|
+
* (it raises ESRCH if the process is dead, EPERM if alive but not ours).
|
|
18
|
+
*/
|
|
19
|
+
function isProcessAlive(pid) {
|
|
20
|
+
try {
|
|
21
|
+
process.kill(pid, 0);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
// EPERM means the process exists but we lack permission — still "alive".
|
|
26
|
+
if (err.code === "EPERM")
|
|
27
|
+
return true;
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Check whether the coordinator daemon appears to be running.
|
|
33
|
+
* Returns the pid if alive, or null otherwise.
|
|
34
|
+
*/
|
|
35
|
+
export function getRunningCoordinatorPid(configDir) {
|
|
36
|
+
const pidPath = join(configDir, "server.pid");
|
|
37
|
+
if (!existsSync(pidPath))
|
|
38
|
+
return null;
|
|
39
|
+
const raw = readFileSync(pidPath, "utf-8").trim();
|
|
40
|
+
const pid = parseInt(raw, 10);
|
|
41
|
+
if (Number.isNaN(pid))
|
|
42
|
+
return null;
|
|
43
|
+
return isProcessAlive(pid) ? pid : null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Recursively walk a directory and yield relative file paths.
|
|
47
|
+
* Used purely for reporting (file count) — tar handles the actual packing.
|
|
48
|
+
*/
|
|
49
|
+
async function countFiles(root) {
|
|
50
|
+
const { readdir, stat } = await import("fs/promises");
|
|
51
|
+
let count = 0;
|
|
52
|
+
const walk = async (dir) => {
|
|
53
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
const full = join(dir, entry.name);
|
|
56
|
+
if (entry.isDirectory()) {
|
|
57
|
+
await walk(full);
|
|
58
|
+
}
|
|
59
|
+
else if (entry.isFile()) {
|
|
60
|
+
count += 1;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
if (existsSync(root))
|
|
65
|
+
await walk(root);
|
|
66
|
+
return count;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Build the list of entries (relative to configDir) we want to include in the
|
|
70
|
+
* tarball. We deliberately keep the layout flat — the same paths used at
|
|
71
|
+
* runtime — so a `tar -xzf` into `~/.mcp-coordinator/` is a valid restore.
|
|
72
|
+
*
|
|
73
|
+
* NOTE on live backups: this command refuses to run while the coordinator is
|
|
74
|
+
* up because better-sqlite3's WAL journal may have uncommitted writes that
|
|
75
|
+
* file-copy will miss. For online backups, switch to SQLite's Online Backup
|
|
76
|
+
* API (`db.backup(path)` from better-sqlite3) and snapshot config.json
|
|
77
|
+
* separately — see docs/superpowers/working/v04/backup-integration.md.
|
|
78
|
+
*/
|
|
79
|
+
function buildEntries(configDir, dataDirAbsolute) {
|
|
80
|
+
const entries = [];
|
|
81
|
+
if (existsSync(join(configDir, "config.json")))
|
|
82
|
+
entries.push("config.json");
|
|
83
|
+
// The data dir might live outside ~/.mcp-coordinator (custom --data-dir).
|
|
84
|
+
// tar's `cwd` option can only point at one directory, so when the data dir
|
|
85
|
+
// is non-default we pack it under its absolute path inside the archive
|
|
86
|
+
// (preserving structure for round-trip restore via --data-dir).
|
|
87
|
+
const defaultDataDir = join(configDir, "data");
|
|
88
|
+
if (resolve(dataDirAbsolute) === resolve(defaultDataDir) && existsSync(defaultDataDir)) {
|
|
89
|
+
entries.push("data");
|
|
90
|
+
}
|
|
91
|
+
return entries;
|
|
92
|
+
}
|
|
93
|
+
export function createServerBackupCommand() {
|
|
94
|
+
return new Command("backup")
|
|
95
|
+
.description("Snapshot the coordinator config + SQLite database to a tar.gz archive")
|
|
96
|
+
.option("--output <path>", "Output tarball path (default ./mcp-coordinator-backup-<ts>.tar.gz)")
|
|
97
|
+
.option("--data-dir <path>", "Data directory to back up (overrides config.server.data_dir)")
|
|
98
|
+
.option("--force", "Skip the running-coordinator safety check")
|
|
99
|
+
.action(async (opts) => {
|
|
100
|
+
const configDir = getConfigDir();
|
|
101
|
+
const config = loadConfig(configDir);
|
|
102
|
+
const dataDir = resolve(opts.dataDir ?? process.env.COORDINATOR_DATA_DIR ?? config.server.data_dir);
|
|
103
|
+
// Safety: refuse when the daemon is up. WAL writes might be in flight.
|
|
104
|
+
const runningPid = getRunningCoordinatorPid(configDir);
|
|
105
|
+
if (runningPid !== null && !opts.force) {
|
|
106
|
+
console.error(`Coordinator is running (PID ${runningPid}).`);
|
|
107
|
+
console.error("Refusing to back up: live SQLite WAL writes may be in flight.");
|
|
108
|
+
console.error("Either stop it first ('mcp-coordinator server stop') or pass --force.");
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
if (!existsSync(configDir)) {
|
|
112
|
+
console.error(`No coordinator config directory at ${configDir} — nothing to back up.`);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
const ts = timestampSlug();
|
|
116
|
+
const outputPath = resolve(opts.output ?? join(process.cwd(), `mcp-coordinator-backup-${ts}.tar.gz`));
|
|
117
|
+
const defaultDataDir = join(configDir, "data");
|
|
118
|
+
const dataIsCustom = resolve(dataDir) !== resolve(defaultDataDir);
|
|
119
|
+
// Pack ~/.mcp-coordinator entries from configDir as cwd.
|
|
120
|
+
const entries = buildEntries(configDir, dataDir);
|
|
121
|
+
// For custom data dirs, pack them under their absolute path so restore
|
|
122
|
+
// can reproduce the original location (or be redirected with --data-dir).
|
|
123
|
+
const customDataEntries = [];
|
|
124
|
+
if (dataIsCustom && existsSync(dataDir)) {
|
|
125
|
+
customDataEntries.push({ cwd: resolve(dataDir, ".."), entry: basename(dataDir) });
|
|
126
|
+
}
|
|
127
|
+
if (entries.length === 0 && customDataEntries.length === 0) {
|
|
128
|
+
console.error("Nothing to back up: no config.json and no data directory found.");
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
// First archive pass: config + default data (if any).
|
|
132
|
+
if (entries.length > 0) {
|
|
133
|
+
await tarCreate({ gzip: true, file: outputPath, cwd: configDir, portable: true }, entries);
|
|
134
|
+
}
|
|
135
|
+
// Second pass for a custom data dir — append into the same archive.
|
|
136
|
+
// tar's gzip mode doesn't support append, so we re-create instead by
|
|
137
|
+
// extracting the previous entries and re-packing. Simpler path: emit
|
|
138
|
+
// a sibling .data.tar.gz when custom dir is in use, and document it.
|
|
139
|
+
if (customDataEntries.length > 0) {
|
|
140
|
+
const dataArchive = outputPath.replace(/\.tar\.gz$/, ".data.tar.gz");
|
|
141
|
+
for (const { cwd, entry } of customDataEntries) {
|
|
142
|
+
await tarCreate({ gzip: true, file: dataArchive, cwd, portable: true }, [entry]);
|
|
143
|
+
}
|
|
144
|
+
console.log(`Custom data dir packed separately: ${dataArchive}`);
|
|
145
|
+
}
|
|
146
|
+
// outputPath only exists if entries.length > 0; report on whichever
|
|
147
|
+
// archive(s) we actually produced.
|
|
148
|
+
const reportPath = entries.length > 0
|
|
149
|
+
? outputPath
|
|
150
|
+
: outputPath.replace(/\.tar\.gz$/, ".data.tar.gz");
|
|
151
|
+
const sizeBytes = existsSync(reportPath) ? statSync(reportPath).size : 0;
|
|
152
|
+
const sizeMB = (sizeBytes / (1024 * 1024)).toFixed(2);
|
|
153
|
+
const fileCount = (existsSync(join(configDir, "config.json")) ? 1 : 0) +
|
|
154
|
+
(await countFiles(dataIsCustom ? dataDir : defaultDataDir));
|
|
155
|
+
console.log("Backup complete.");
|
|
156
|
+
console.log(` Archive: ${reportPath}`);
|
|
157
|
+
console.log(` Size: ${sizeMB} MB (${sizeBytes} bytes)`);
|
|
158
|
+
console.log(` Files: ${fileCount}`);
|
|
159
|
+
console.log(` ConfigDir: ${configDir}`);
|
|
160
|
+
console.log(` DataDir: ${dataDir}${dataIsCustom ? " (custom)" : ""}`);
|
|
161
|
+
});
|
|
162
|
+
}
|
package/dist/cli/server/index.js
CHANGED
|
@@ -3,11 +3,16 @@ import { createServerStartCommand } from "./start.js";
|
|
|
3
3
|
import { createServerStopCommand } from "./stop.js";
|
|
4
4
|
import { createServerStatusCommand } from "./status.js";
|
|
5
5
|
import { createServerLogsCommand } from "./logs.js";
|
|
6
|
+
import { createServerBackupCommand } from "./backup.js";
|
|
7
|
+
import { createServerRestoreCommand } from "./restore.js";
|
|
6
8
|
export function createServerProgram() {
|
|
7
9
|
const server = new Command("server").description("Manage the coordination server");
|
|
8
10
|
server.addCommand(createServerStartCommand());
|
|
9
11
|
server.addCommand(createServerStopCommand());
|
|
10
12
|
server.addCommand(createServerStatusCommand());
|
|
11
13
|
server.addCommand(createServerLogsCommand());
|
|
14
|
+
// v0.4 Operability
|
|
15
|
+
server.addCommand(createServerBackupCommand());
|
|
16
|
+
server.addCommand(createServerRestoreCommand());
|
|
12
17
|
return server;
|
|
13
18
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { existsSync, mkdirSync, renameSync, statSync } from "fs";
|
|
3
|
+
import { resolve } from "path";
|
|
4
|
+
import { extract as tarExtract, list as tarList } from "tar";
|
|
5
|
+
import { getConfigDir } from "../config.js";
|
|
6
|
+
import { getRunningCoordinatorPid } from "./backup.js";
|
|
7
|
+
function timestampSlug(date = new Date()) {
|
|
8
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
9
|
+
return (`${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}` +
|
|
10
|
+
`-${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}`);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Inspect the tarball without extracting and return the list of top-level
|
|
14
|
+
* entries (file or dir names). Used to validate structure before touching
|
|
15
|
+
* the user's existing config dir.
|
|
16
|
+
*/
|
|
17
|
+
async function listTarballEntries(tarPath) {
|
|
18
|
+
const entries = [];
|
|
19
|
+
await tarList({
|
|
20
|
+
file: tarPath,
|
|
21
|
+
onReadEntry: (entry) => {
|
|
22
|
+
// Trailing slash on dirs — strip it, take the first path segment only.
|
|
23
|
+
const head = entry.path.replace(/\\/g, "/").split("/")[0];
|
|
24
|
+
if (head && !entries.includes(head))
|
|
25
|
+
entries.push(head);
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
return entries;
|
|
29
|
+
}
|
|
30
|
+
export function createServerRestoreCommand() {
|
|
31
|
+
return new Command("restore")
|
|
32
|
+
.description("Restore a coordinator config + database snapshot from a tar.gz archive")
|
|
33
|
+
.argument("<tarball>", "Path to the backup .tar.gz produced by 'mcp-coordinator server backup'")
|
|
34
|
+
.option("--force", "Skip the running-coordinator safety check")
|
|
35
|
+
.option("--no-backup", "Do not snapshot the existing config dir before overwriting")
|
|
36
|
+
.option("--data-dir <path>", "Override data directory (rarely needed)")
|
|
37
|
+
.action(async (tarballArg, opts) => {
|
|
38
|
+
const tarPath = resolve(tarballArg);
|
|
39
|
+
if (!existsSync(tarPath)) {
|
|
40
|
+
console.error(`Tarball not found: ${tarPath}`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
const tarStat = statSync(tarPath);
|
|
44
|
+
if (!tarStat.isFile()) {
|
|
45
|
+
console.error(`Not a regular file: ${tarPath}`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
const configDir = getConfigDir();
|
|
49
|
+
// Safety: refuse when the daemon is running so we don't clobber an
|
|
50
|
+
// open SQLite handle (would corrupt the WAL on the daemon side).
|
|
51
|
+
const runningPid = getRunningCoordinatorPid(configDir);
|
|
52
|
+
if (runningPid !== null && !opts.force) {
|
|
53
|
+
console.error(`Coordinator is running (PID ${runningPid}).`);
|
|
54
|
+
console.error("Refusing to restore: stop the coordinator first or pass --force.");
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
// Validate tarball contents BEFORE moving anything aside.
|
|
58
|
+
let entries;
|
|
59
|
+
try {
|
|
60
|
+
entries = await listTarballEntries(tarPath);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
console.error(`Failed to read tarball: ${err.message}`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
const hasConfig = entries.includes("config.json");
|
|
67
|
+
const hasData = entries.includes("data");
|
|
68
|
+
if (!hasConfig && !hasData) {
|
|
69
|
+
console.error("Tarball does not contain expected entries (config.json or data/).");
|
|
70
|
+
console.error(`Top-level entries found: ${entries.join(", ") || "(none)"}`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
// Snapshot the existing config dir before overwriting.
|
|
74
|
+
// commander auto-flips `--no-backup` -> opts.backup = false. The default
|
|
75
|
+
// value is `true` (the option is registered with .option("--no-backup")
|
|
76
|
+
// below, which makes commander seed `opts.backup = true`).
|
|
77
|
+
const shouldSnapshot = opts.backup !== false;
|
|
78
|
+
let snapshotPath = null;
|
|
79
|
+
if (shouldSnapshot && existsSync(configDir)) {
|
|
80
|
+
snapshotPath = `${configDir}.bak-${timestampSlug()}`;
|
|
81
|
+
renameSync(configDir, snapshotPath);
|
|
82
|
+
console.log(`Existing config moved aside: ${snapshotPath}`);
|
|
83
|
+
}
|
|
84
|
+
// Recreate the target dir and extract.
|
|
85
|
+
mkdirSync(configDir, { recursive: true });
|
|
86
|
+
try {
|
|
87
|
+
await tarExtract({ file: tarPath, cwd: configDir });
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
console.error(`Extraction failed: ${err.message}`);
|
|
91
|
+
// Try to roll back the snapshot if we made one.
|
|
92
|
+
if (snapshotPath !== null && existsSync(snapshotPath)) {
|
|
93
|
+
// Best-effort: only roll back if extraction created an empty dir.
|
|
94
|
+
try {
|
|
95
|
+
renameSync(configDir, `${configDir}.failed-${timestampSlug()}`);
|
|
96
|
+
renameSync(snapshotPath, configDir);
|
|
97
|
+
console.error("Rolled back to previous config dir.");
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
console.error(`Manual recovery required — snapshot at: ${snapshotPath}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
console.log("Restore complete.");
|
|
106
|
+
console.log(` Source: ${tarPath}`);
|
|
107
|
+
console.log(` ConfigDir: ${configDir}`);
|
|
108
|
+
console.log(` Restored: ${entries.filter((e) => e === "config.json" || e === "data").join(", ")}`);
|
|
109
|
+
if (snapshotPath !== null) {
|
|
110
|
+
console.log(` Previous: ${snapshotPath} (delete once verified)`);
|
|
111
|
+
}
|
|
112
|
+
if (opts.dataDir !== undefined) {
|
|
113
|
+
console.log(` Note: --data-dir was provided but restore extracts to default location.\n` +
|
|
114
|
+
` Update config.json or COORDINATOR_DATA_DIR if you need a non-default path.`);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
package/dist/cli/server/start.js
CHANGED
|
@@ -24,10 +24,33 @@ export function createServerStartCommand() {
|
|
|
24
24
|
const isBun = typeof globalThis.Bun !== "undefined";
|
|
25
25
|
const cmd = isBun ? process.execPath : process.execPath;
|
|
26
26
|
const args = isBun ? ["server", "start"] : [process.argv[1], "server", "start"];
|
|
27
|
+
// Only forward the env vars the daemon actually needs.
|
|
28
|
+
// Each var is read explicitly (no Object.keys / dynamic indexing into
|
|
29
|
+
// process.env) so the daemon can't inherit unrelated parent-process
|
|
30
|
+
// secrets such as AWS_*, GITHUB_TOKEN, OPENAI_API_KEY, etc.
|
|
31
|
+
const childEnv = {
|
|
32
|
+
PATH: process.env.PATH ?? "",
|
|
33
|
+
HOME: process.env.HOME ?? process.env.USERPROFILE ?? "",
|
|
34
|
+
PORT: String(port),
|
|
35
|
+
COORDINATOR_DATA_DIR: dataDir,
|
|
36
|
+
};
|
|
37
|
+
const fwd = (key, value) => {
|
|
38
|
+
if (value !== undefined)
|
|
39
|
+
childEnv[key] = value;
|
|
40
|
+
};
|
|
41
|
+
fwd("NODE_ENV", process.env.NODE_ENV);
|
|
42
|
+
fwd("LOG_LEVEL", process.env.LOG_LEVEL);
|
|
43
|
+
fwd("COORDINATOR_AUTH_ENABLED", process.env.COORDINATOR_AUTH_ENABLED);
|
|
44
|
+
fwd("COORDINATOR_JWT_SECRET", process.env.COORDINATOR_JWT_SECRET);
|
|
45
|
+
fwd("COORDINATOR_JWT_EXPIRY", process.env.COORDINATOR_JWT_EXPIRY);
|
|
46
|
+
fwd("COORDINATOR_REGISTRATION_SECRET", process.env.COORDINATOR_REGISTRATION_SECRET);
|
|
47
|
+
fwd("COORDINATOR_ADMIN_SECRET", process.env.COORDINATOR_ADMIN_SECRET);
|
|
48
|
+
fwd("COORDINATOR_MQTT_TCP_PORT", process.env.COORDINATOR_MQTT_TCP_PORT);
|
|
49
|
+
fwd("COORDINATOR_MQTT_WS_PATH", process.env.COORDINATOR_MQTT_WS_PATH);
|
|
27
50
|
const child = spawn(cmd, args, {
|
|
28
51
|
detached: true,
|
|
29
52
|
stdio: ["ignore", logFd, logFd],
|
|
30
|
-
env:
|
|
53
|
+
env: childEnv,
|
|
31
54
|
});
|
|
32
55
|
// Write PID file
|
|
33
56
|
writeFileSync(join(configDir, "server.pid"), String(child.pid));
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { readFileSync, existsSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
|
-
import { execSync } from "child_process";
|
|
5
4
|
import { getConfigDir, loadConfig } from "../config.js";
|
|
5
|
+
async function fetchJson(url, init) {
|
|
6
|
+
try {
|
|
7
|
+
const response = await fetch(url, { ...init, signal: AbortSignal.timeout(3000) });
|
|
8
|
+
if (!response.ok)
|
|
9
|
+
return null;
|
|
10
|
+
return (await response.json());
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
6
16
|
export function createServerStatusCommand() {
|
|
7
17
|
return new Command("status")
|
|
8
18
|
.description("Show coordinator status")
|
|
9
|
-
.action(() => {
|
|
19
|
+
.action(async () => {
|
|
10
20
|
const configDir = getConfigDir();
|
|
11
21
|
const pidPath = join(configDir, "server.pid");
|
|
12
22
|
const config = loadConfig();
|
|
@@ -26,28 +36,11 @@ export function createServerStatusCommand() {
|
|
|
26
36
|
console.log("Coordinator: stopped (stale PID file)");
|
|
27
37
|
return;
|
|
28
38
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const raw = execSync(`curl -s --max-time 3 http://localhost:${port}/health`, {
|
|
33
|
-
encoding: "utf-8",
|
|
34
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
35
|
-
});
|
|
36
|
-
health = JSON.parse(raw);
|
|
37
|
-
}
|
|
38
|
-
catch { }
|
|
39
|
-
if (health.status === "ok") {
|
|
40
|
-
let status = {};
|
|
41
|
-
try {
|
|
42
|
-
const raw = execSync(`curl -s --max-time 3 -X POST http://localhost:${port}/api/status`, {
|
|
43
|
-
encoding: "utf-8",
|
|
44
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
45
|
-
});
|
|
46
|
-
status = JSON.parse(raw);
|
|
47
|
-
}
|
|
48
|
-
catch { }
|
|
39
|
+
const health = await fetchJson(`http://localhost:${port}/health`);
|
|
40
|
+
if (health?.status === "ok") {
|
|
41
|
+
const status = await fetchJson(`http://localhost:${port}/api/status`, { method: "POST" });
|
|
49
42
|
console.log(`Coordinator: running (PID ${pid}, port ${port})`);
|
|
50
|
-
if (status
|
|
43
|
+
if (status?.online !== undefined) {
|
|
51
44
|
console.log(`Agents: ${status.online} online`);
|
|
52
45
|
console.log(`Threads: ${status.open_threads} open`);
|
|
53
46
|
}
|
|
@@ -59,12 +59,12 @@ export class AgentActivityTracker {
|
|
|
59
59
|
// ── Private ──
|
|
60
60
|
upsert(agentId, status, file, thread) {
|
|
61
61
|
const db = getDb();
|
|
62
|
-
db.prepare(`INSERT INTO agent_activity_status (agent_id, activity_status, current_file, current_thread, last_activity_at)
|
|
63
|
-
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
64
|
-
ON CONFLICT(agent_id) DO UPDATE SET
|
|
65
|
-
activity_status = excluded.activity_status,
|
|
66
|
-
current_file = excluded.current_file,
|
|
67
|
-
current_thread = excluded.current_thread,
|
|
62
|
+
db.prepare(`INSERT INTO agent_activity_status (agent_id, activity_status, current_file, current_thread, last_activity_at)
|
|
63
|
+
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
64
|
+
ON CONFLICT(agent_id) DO UPDATE SET
|
|
65
|
+
activity_status = excluded.activity_status,
|
|
66
|
+
current_file = excluded.current_file,
|
|
67
|
+
current_thread = excluded.current_thread,
|
|
68
68
|
last_activity_at = CURRENT_TIMESTAMP`).run(agentId, status, file, thread);
|
|
69
69
|
}
|
|
70
70
|
}
|
|
@@ -2,12 +2,12 @@ import { getDb } from "./database.js";
|
|
|
2
2
|
export class AgentRegistry {
|
|
3
3
|
register(agentId, name, modules) {
|
|
4
4
|
const db = getDb();
|
|
5
|
-
db.prepare(`INSERT INTO agents (id, name, modules, status, registered_at, last_seen_at)
|
|
6
|
-
VALUES (?, ?, ?, 'online', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
7
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
8
|
-
name = excluded.name,
|
|
9
|
-
modules = excluded.modules,
|
|
10
|
-
status = 'online',
|
|
5
|
+
db.prepare(`INSERT INTO agents (id, name, modules, status, registered_at, last_seen_at)
|
|
6
|
+
VALUES (?, ?, ?, 'online', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
7
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
8
|
+
name = excluded.name,
|
|
9
|
+
modules = excluded.modules,
|
|
10
|
+
status = 'online',
|
|
11
11
|
last_seen_at = CURRENT_TIMESTAMP`).run(agentId, name, JSON.stringify(modules));
|
|
12
12
|
return this.get(agentId);
|
|
13
13
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Thread } from "./types.js";
|
|
2
|
+
import type { CoordinatorServices } from "./server-setup.js";
|
|
3
|
+
import type { CategorizedImpact } from "./impact-scorer.js";
|
|
4
|
+
import { type PlanQualityResult } from "./plan-quality.js";
|
|
5
|
+
/**
|
|
6
|
+
* S2 fix: shared `announce_work` orchestration.
|
|
7
|
+
*
|
|
8
|
+
* The MCP tool handler (`server-setup.ts`) and the REST endpoint
|
|
9
|
+
* (`serve-http.ts`) historically duplicated ~95 lines of code that
|
|
10
|
+
* scored impact, overrode respondents, auto-resolved when alone,
|
|
11
|
+
* and emitted impact/introspection/plan-quality SSE events.
|
|
12
|
+
*
|
|
13
|
+
* This module extracts that common orchestration. Both transports
|
|
14
|
+
* call `runCommonAnnounceFlow()` after creating the thread; each
|
|
15
|
+
* transport then adds its own pre-step (MCP: conflict detection)
|
|
16
|
+
* and post-step (MCP: MQTT publish + context gathering, REST: JSON
|
|
17
|
+
* response) so existing SSE/response contracts are preserved.
|
|
18
|
+
*
|
|
19
|
+
* Why not unify the response/SSE shapes too? The MCP and REST
|
|
20
|
+
* `thread_opened` event payloads have DIFFERENT field sets today
|
|
21
|
+
* (e.g. MCP uses `initiator`, REST uses `agent_id` + `agent_name`).
|
|
22
|
+
* essaim (and other consumers) may depend on those exact shapes.
|
|
23
|
+
* Behavioral unification is a separate, riskier change deferred to
|
|
24
|
+
* a later major.
|
|
25
|
+
*/
|
|
26
|
+
export interface CommonFlowResult {
|
|
27
|
+
/** The same thread you passed in, refreshed after the override+auto-resolve. */
|
|
28
|
+
updated: Thread;
|
|
29
|
+
/** Impact scoring across all online agents. */
|
|
30
|
+
categorized: CategorizedImpact;
|
|
31
|
+
/** Concerned agent IDs (== updated.expected_respondents). */
|
|
32
|
+
respondents: string[];
|
|
33
|
+
/** Plan quality assessment (callers may emit downgrade event). */
|
|
34
|
+
planQuality: PlanQualityResult;
|
|
35
|
+
}
|
|
36
|
+
export interface CommonFlowParams {
|
|
37
|
+
agent_id: string;
|
|
38
|
+
subject: string;
|
|
39
|
+
plan?: string;
|
|
40
|
+
target_modules: string[];
|
|
41
|
+
target_files: string[];
|
|
42
|
+
depends_on_files?: string[];
|
|
43
|
+
exports_affected?: string[];
|
|
44
|
+
keep_open?: boolean;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Run the common post-`announceWork` orchestration. Mutates the thread row
|
|
48
|
+
* (overrides `expected_respondents`, may transition to `resolved`) and emits
|
|
49
|
+
* SSE events for impact scoring + introspection. Pure side-effects on the
|
|
50
|
+
* services + DB; returns metadata callers use to build their own responses.
|
|
51
|
+
*/
|
|
52
|
+
export declare function runCommonAnnounceFlow(services: CoordinatorServices, threadId: string, params: CommonFlowParams): CommonFlowResult;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { getDb } from "./database.js";
|
|
2
|
+
import { assessPlanQuality } from "./plan-quality.js";
|
|
3
|
+
/**
|
|
4
|
+
* Run the common post-`announceWork` orchestration. Mutates the thread row
|
|
5
|
+
* (overrides `expected_respondents`, may transition to `resolved`) and emits
|
|
6
|
+
* SSE events for impact scoring + introspection. Pure side-effects on the
|
|
7
|
+
* services + DB; returns metadata callers use to build their own responses.
|
|
8
|
+
*/
|
|
9
|
+
export function runCommonAnnounceFlow(services, threadId, params) {
|
|
10
|
+
const { registry, consultation, impactScorer, introspection, sseEmitter } = services;
|
|
11
|
+
// 1. Score impact: categorize all online agents into concerned / gray_zone / pass.
|
|
12
|
+
const categorized = impactScorer.categorize({
|
|
13
|
+
agent_id: params.agent_id,
|
|
14
|
+
target_modules: params.target_modules,
|
|
15
|
+
target_files: params.target_files,
|
|
16
|
+
depends_on_files: params.depends_on_files,
|
|
17
|
+
exports_affected: params.exports_affected,
|
|
18
|
+
});
|
|
19
|
+
// 2. Override expected_respondents on the thread with the scored set.
|
|
20
|
+
// Auto-resolve only when truly alone — if peers are online but not concerned
|
|
21
|
+
// (e.g., they haven't announced yet), keep the thread open so a subsequent
|
|
22
|
+
// announce can match via Layer 0. Thread will timeout naturally if no one joins.
|
|
23
|
+
const db = getDb();
|
|
24
|
+
const concernedIds = categorized.concerned.map((s) => s.agent_id);
|
|
25
|
+
db.prepare("UPDATE threads SET expected_respondents = ? WHERE id = ?")
|
|
26
|
+
.run(JSON.stringify(concernedIds), threadId);
|
|
27
|
+
const otherOnlineCount = registry.listOnline().filter((a) => a.id !== params.agent_id).length;
|
|
28
|
+
const shouldAutoResolve = concernedIds.length === 0 && otherOnlineCount === 0;
|
|
29
|
+
const currentThread = consultation.getThread(threadId);
|
|
30
|
+
if (shouldAutoResolve && currentThread.status === "open" && !params.keep_open) {
|
|
31
|
+
db.prepare("UPDATE threads SET status = 'resolved', resolved_at = ? WHERE id = ?")
|
|
32
|
+
.run(new Date().toISOString(), threadId);
|
|
33
|
+
consultation.emitResolution(threadId, "auto_resolved");
|
|
34
|
+
}
|
|
35
|
+
// 3. Emit impact_scored SSE events for every scored agent.
|
|
36
|
+
for (const s of [...categorized.concerned, ...categorized.gray_zone, ...categorized.pass]) {
|
|
37
|
+
sseEmitter.emit("impact_scored", {
|
|
38
|
+
thread_id: threadId,
|
|
39
|
+
agent_id: s.agent_id,
|
|
40
|
+
agent_name: s.agent_name,
|
|
41
|
+
score: s.score,
|
|
42
|
+
reasons: s.reasons,
|
|
43
|
+
category: scoredCategory(s),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
// 4. Create introspection records and emit introspection_requested for gray_zone agents.
|
|
47
|
+
for (const s of categorized.gray_zone) {
|
|
48
|
+
introspection.create({ thread_id: threadId, agent_id: s.agent_id, score: s.score, reasons: s.reasons });
|
|
49
|
+
sseEmitter.emit("introspection_requested", {
|
|
50
|
+
thread_id: threadId,
|
|
51
|
+
agent_id: s.agent_id,
|
|
52
|
+
agent_name: s.agent_name,
|
|
53
|
+
score: s.score,
|
|
54
|
+
reasons: s.reasons,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
// 5. Plan quality downgrade event — both transports emit this when a plan
|
|
58
|
+
// was provided but quality was insufficient. Callers can re-emit if their
|
|
59
|
+
// payload shape differs (MCP vs REST agent_name source).
|
|
60
|
+
const planQuality = assessPlanQuality(params.plan);
|
|
61
|
+
if (params.plan && planQuality.mode === "discovery") {
|
|
62
|
+
sseEmitter.emit("impact_scored", {
|
|
63
|
+
thread_id: threadId,
|
|
64
|
+
agent_id: params.agent_id,
|
|
65
|
+
agent_name: registry.get(params.agent_id)?.name || params.agent_id,
|
|
66
|
+
score: planQuality.score,
|
|
67
|
+
reasons: [planDowngradeReason(planQuality)],
|
|
68
|
+
category: "plan_quality",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const updated = consultation.getThread(threadId);
|
|
72
|
+
const respondents = JSON.parse(updated.expected_respondents || "[]");
|
|
73
|
+
return { updated, categorized, respondents, planQuality };
|
|
74
|
+
}
|
|
75
|
+
function scoredCategory(s) {
|
|
76
|
+
if (s.score >= 90)
|
|
77
|
+
return "concerned";
|
|
78
|
+
if (s.score >= 30)
|
|
79
|
+
return "gray_zone";
|
|
80
|
+
return "pass";
|
|
81
|
+
}
|
|
82
|
+
function planDowngradeReason(pq) {
|
|
83
|
+
const flags = [];
|
|
84
|
+
if (!pq.checks.mentions_files)
|
|
85
|
+
flags.push("no files");
|
|
86
|
+
if (!pq.checks.concrete_approach)
|
|
87
|
+
flags.push("vague approach");
|
|
88
|
+
if (!pq.checks.sufficient_detail)
|
|
89
|
+
flags.push("too short");
|
|
90
|
+
return `plan downgraded: score ${pq.score}/3 — ${flags.join(" ")}`.trim();
|
|
91
|
+
}
|
|
@@ -13,8 +13,22 @@ export interface ResolutionEvent {
|
|
|
13
13
|
export declare class Consultation {
|
|
14
14
|
private onResolveCallback;
|
|
15
15
|
private log;
|
|
16
|
+
private timeoutSweeperHandle;
|
|
16
17
|
constructor(logger?: Logger);
|
|
17
18
|
onResolve(callback: (event: ResolutionEvent) => void): void;
|
|
19
|
+
/**
|
|
20
|
+
* B2 fix: replace the side-effect-on-read timeout check with an explicit
|
|
21
|
+
* background sweeper. Each tick atomically claims and resolves any thread
|
|
22
|
+
* past its deadline, then emits resolution events outside the transaction.
|
|
23
|
+
*
|
|
24
|
+
* Default tick interval: 30 seconds. Tests can pass a shorter interval to
|
|
25
|
+
* exercise the sweeper, or call checkTimeouts() explicitly.
|
|
26
|
+
*
|
|
27
|
+
* Safe to call multiple times — second call is a no-op until the previous
|
|
28
|
+
* sweeper is stopped.
|
|
29
|
+
*/
|
|
30
|
+
startTimeoutSweeper(intervalMs?: number): void;
|
|
31
|
+
stopTimeoutSweeper(): void;
|
|
18
32
|
emitResolution(threadId: string, type: ResolutionType, approvedBy?: string, approvedByName?: string): void;
|
|
19
33
|
announceWork(params: {
|
|
20
34
|
agent_id: string;
|
|
@@ -60,6 +74,14 @@ export declare class Consultation {
|
|
|
60
74
|
* parsing the thread list themselves.
|
|
61
75
|
*/
|
|
62
76
|
assigned_to_me?: string;
|
|
77
|
+
/**
|
|
78
|
+
* P2 perf: bound resolved-thread queries to a recency window. Without
|
|
79
|
+
* this, the impact scorer would scan all-time resolved threads on every
|
|
80
|
+
* announce_work call (O(historical-threads) per scoring pass). The window
|
|
81
|
+
* applies to resolved_at when status='resolved', otherwise to created_at,
|
|
82
|
+
* so the filter is meaningful for both states.
|
|
83
|
+
*/
|
|
84
|
+
since_minutes?: number;
|
|
63
85
|
}): Thread[];
|
|
64
86
|
getThreadUpdates(agentId: string, since?: string): ThreadMessage[];
|
|
65
87
|
logActionSummary(params: {
|