mcp-coordinator 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/dashboard/public/index.html +23 -0
- package/dist/cli/server/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 +33 -0
- package/dist/src/announce-workflow.d.ts +1 -0
- package/dist/src/announce-workflow.js +28 -0
- package/dist/src/consultation.d.ts +8 -0
- package/dist/src/consultation.js +8 -0
- package/dist/src/database.js +65 -0
- package/dist/src/db-adapter.d.ts +30 -0
- package/dist/src/db-adapter.js +32 -1
- package/dist/src/dependency-map.js +2 -2
- package/dist/src/file-tracker.d.ts +12 -0
- package/dist/src/file-tracker.js +35 -2
- package/dist/src/git-cochange-builder.d.ts +32 -0
- package/dist/src/git-cochange-builder.js +238 -0
- package/dist/src/http/handle-health.d.ts +23 -0
- package/dist/src/http/handle-health.js +112 -0
- package/dist/src/http/handle-rest.js +83 -2
- package/dist/src/http/utils.d.ts +0 -4
- package/dist/src/http/utils.js +16 -2
- package/dist/src/impact-scorer.d.ts +5 -1
- package/dist/src/impact-scorer.js +182 -55
- package/dist/src/metrics.d.ts +88 -0
- package/dist/src/metrics.js +195 -0
- package/dist/src/mqtt-bridge.d.ts +19 -0
- package/dist/src/mqtt-bridge.js +53 -5
- package/dist/src/path-normalize.d.ts +17 -0
- package/dist/src/path-normalize.js +38 -0
- package/dist/src/serve-http.js +76 -3
- package/dist/src/server-setup.d.ts +8 -0
- package/dist/src/server-setup.js +31 -3
- package/dist/src/sse-emitter.d.ts +6 -0
- package/dist/src/sse-emitter.js +50 -2
- package/dist/src/tools/consultation-tools.js +4 -2
- package/dist/src/tree-sitter-extractor.d.ts +36 -0
- package/dist/src/tree-sitter-extractor.js +354 -0
- package/dist/src/working-files-tracker.d.ts +42 -0
- package/dist/src/working-files-tracker.js +111 -0
- package/package.json +20 -1
package/README.md
CHANGED
|
@@ -666,6 +666,20 @@ Resolution priority (highest to lowest): CLI flag → env var → config.json
|
|
|
666
666
|
| `COORDINATOR_ADMIN_SECRET` | — | Separate secret for admin token creation |
|
|
667
667
|
| `MAX_QUOTA_PCT` | `95` | Pre-flight abort threshold for Anthropic quota |
|
|
668
668
|
|
|
669
|
+
### Environment variables (v0.6)
|
|
670
|
+
|
|
671
|
+
| Variable | Default | Effect |
|
|
672
|
+
|---|---|---|
|
|
673
|
+
| `COORDINATOR_REPO_ROOT` | (unset → team-mode) | Repo root for path-guard, FS fallback, Layer 4 |
|
|
674
|
+
| `COORDINATOR_MAX_BODY_BYTES` | `1048576` | parseBody hard cap |
|
|
675
|
+
| `COORDINATOR_LAYER4_DENYLIST` | (uses defaults) | Comma-separated globs appended to denylist |
|
|
676
|
+
| `COORDINATOR_LAYER4_SINCE_DAYS` | `7` | git log --since window |
|
|
677
|
+
| `COORDINATOR_LAYER4_MAX_COMMITS` | `2000` | git log --max-count |
|
|
678
|
+
| `COORDINATOR_LAYER4_REFRESH_INTERVAL_MS` | `1800000` | Refresh on success |
|
|
679
|
+
| `COORDINATOR_LAYER4_RETRY_MS` | `300000` | Retry on timeout |
|
|
680
|
+
| `COORDINATOR_WORKING_FILES_TTL_MIN` | `30` | working_files claim TTL |
|
|
681
|
+
| `COORDINATOR_WORKING_FILES_SWEEP_INTERVAL_MS` | `60000` | TTL sweeper tick |
|
|
682
|
+
|
|
669
683
|
---
|
|
670
684
|
|
|
671
685
|
## Structured Logging
|
|
@@ -217,6 +217,16 @@
|
|
|
217
217
|
<div id="token-total"></div>
|
|
218
218
|
<div id="token-agents"></div>
|
|
219
219
|
|
|
220
|
+
<div class="panel-title" style="margin-top:16px;">Conflict signals (24h)</div>
|
|
221
|
+
<div id="conflict-signals">
|
|
222
|
+
<div class="metric"><span class="metric-label">L0 announced</span><span class="metric-value" data-layer="L0">—</span></div>
|
|
223
|
+
<div class="metric"><span class="metric-label">L1 same file</span><span class="metric-value" data-layer="L1">—</span></div>
|
|
224
|
+
<div class="metric"><span class="metric-label">L0.5 disjoint</span><span class="metric-value" data-layer="L0.5">—</span></div>
|
|
225
|
+
<div class="metric"><span class="metric-label">L2 depends_on</span><span class="metric-value" data-layer="L2">—</span></div>
|
|
226
|
+
<div class="metric"><span class="metric-label">L3 module</span><span class="metric-value" data-layer="L3">—</span></div>
|
|
227
|
+
<div class="metric"><span class="metric-label">L4 co-change</span><span class="metric-value" data-layer="L4">—</span></div>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
220
230
|
<div class="panel-title" style="margin-top:16px;">Configuration</div>
|
|
221
231
|
<div id="run-config">
|
|
222
232
|
<div style="color:#64748b;font-size:12px;">Aucun run actif</div>
|
|
@@ -1171,6 +1181,19 @@
|
|
|
1171
1181
|
// Fetch config on load
|
|
1172
1182
|
fetch(`${COORDINATOR_URL}/api/run-config`).then(r => r.json()).then(renderRunConfig).catch(() => {});
|
|
1173
1183
|
|
|
1184
|
+
async function refreshConflictSignals() {
|
|
1185
|
+
try {
|
|
1186
|
+
const r = await fetch(`${COORDINATOR_URL}/api/scoring-stats?since=24h`);
|
|
1187
|
+
const j = await r.json();
|
|
1188
|
+
for (const layer of j.layers) {
|
|
1189
|
+
const el = document.querySelector(`[data-layer="${layer.layer}"]`);
|
|
1190
|
+
if (el) el.textContent = layer.fire_count;
|
|
1191
|
+
}
|
|
1192
|
+
} catch {}
|
|
1193
|
+
}
|
|
1194
|
+
refreshConflictSignals();
|
|
1195
|
+
setInterval(refreshConflictSignals, 30000);
|
|
1196
|
+
|
|
1174
1197
|
connectSSE();
|
|
1175
1198
|
updateAgents();
|
|
1176
1199
|
</script>
|
|
@@ -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
|
@@ -8,7 +8,32 @@ export function createServerStartCommand() {
|
|
|
8
8
|
.option("--port <port>", "Server port")
|
|
9
9
|
.option("--data-dir <path>", "Data directory")
|
|
10
10
|
.option("--daemon", "Run as background daemon")
|
|
11
|
+
.option("--repo-root <path>", "Project repo root (enables Layer 4 + FS fallback). Default env COORDINATOR_REPO_ROOT.")
|
|
12
|
+
.option("--max-body-bytes <bytes>", "Max HTTP request body in bytes. Default 1048576.")
|
|
13
|
+
.option("--working-files-ttl-min <minutes>", "TTL for working_files claims. Default 30.")
|
|
14
|
+
.option("--working-files-sweep-ms <ms>", "TTL sweeper tick interval. Default 60000.")
|
|
15
|
+
.option("--layer4-since-days <days>", "git log --since window. Default 7.")
|
|
16
|
+
.option("--layer4-max-commits <count>", "git log --max-count. Default 2000.")
|
|
17
|
+
.option("--layer4-refresh-ms <ms>", "Layer 4 successful-build refresh interval. Default 1800000.")
|
|
18
|
+
.option("--layer4-retry-ms <ms>", "Layer 4 retry interval on timeout. Default 300000.")
|
|
11
19
|
.action(async (opts) => {
|
|
20
|
+
// Wire CLI flags to env vars (CLI takes precedence; rest of codebase reads from env)
|
|
21
|
+
if (opts.repoRoot)
|
|
22
|
+
process.env.COORDINATOR_REPO_ROOT = opts.repoRoot;
|
|
23
|
+
if (opts.maxBodyBytes)
|
|
24
|
+
process.env.COORDINATOR_MAX_BODY_BYTES = opts.maxBodyBytes;
|
|
25
|
+
if (opts.workingFilesTtlMin)
|
|
26
|
+
process.env.COORDINATOR_WORKING_FILES_TTL_MIN = opts.workingFilesTtlMin;
|
|
27
|
+
if (opts.workingFilesSweepMs)
|
|
28
|
+
process.env.COORDINATOR_WORKING_FILES_SWEEP_INTERVAL_MS = opts.workingFilesSweepMs;
|
|
29
|
+
if (opts.layer4SinceDays)
|
|
30
|
+
process.env.COORDINATOR_LAYER4_SINCE_DAYS = opts.layer4SinceDays;
|
|
31
|
+
if (opts.layer4MaxCommits)
|
|
32
|
+
process.env.COORDINATOR_LAYER4_MAX_COMMITS = opts.layer4MaxCommits;
|
|
33
|
+
if (opts.layer4RefreshMs)
|
|
34
|
+
process.env.COORDINATOR_LAYER4_REFRESH_INTERVAL_MS = opts.layer4RefreshMs;
|
|
35
|
+
if (opts.layer4RetryMs)
|
|
36
|
+
process.env.COORDINATOR_LAYER4_RETRY_MS = opts.layer4RetryMs;
|
|
12
37
|
const config = loadConfig();
|
|
13
38
|
const port = parseInt(opts.port ?? process.env.PORT ?? String(config.server.port), 10);
|
|
14
39
|
const dataDir = opts.dataDir ?? process.env.COORDINATOR_DATA_DIR ?? config.server.data_dir;
|
|
@@ -47,6 +72,14 @@ export function createServerStartCommand() {
|
|
|
47
72
|
fwd("COORDINATOR_ADMIN_SECRET", process.env.COORDINATOR_ADMIN_SECRET);
|
|
48
73
|
fwd("COORDINATOR_MQTT_TCP_PORT", process.env.COORDINATOR_MQTT_TCP_PORT);
|
|
49
74
|
fwd("COORDINATOR_MQTT_WS_PATH", process.env.COORDINATOR_MQTT_WS_PATH);
|
|
75
|
+
fwd("COORDINATOR_REPO_ROOT", process.env.COORDINATOR_REPO_ROOT);
|
|
76
|
+
fwd("COORDINATOR_MAX_BODY_BYTES", process.env.COORDINATOR_MAX_BODY_BYTES);
|
|
77
|
+
fwd("COORDINATOR_WORKING_FILES_TTL_MIN", process.env.COORDINATOR_WORKING_FILES_TTL_MIN);
|
|
78
|
+
fwd("COORDINATOR_WORKING_FILES_SWEEP_INTERVAL_MS", process.env.COORDINATOR_WORKING_FILES_SWEEP_INTERVAL_MS);
|
|
79
|
+
fwd("COORDINATOR_LAYER4_SINCE_DAYS", process.env.COORDINATOR_LAYER4_SINCE_DAYS);
|
|
80
|
+
fwd("COORDINATOR_LAYER4_MAX_COMMITS", process.env.COORDINATOR_LAYER4_MAX_COMMITS);
|
|
81
|
+
fwd("COORDINATOR_LAYER4_REFRESH_INTERVAL_MS", process.env.COORDINATOR_LAYER4_REFRESH_INTERVAL_MS);
|
|
82
|
+
fwd("COORDINATOR_LAYER4_RETRY_MS", process.env.COORDINATOR_LAYER4_RETRY_MS);
|
|
50
83
|
const child = spawn(cmd, args, {
|
|
51
84
|
detached: true,
|
|
52
85
|
stdio: ["ignore", logFd, logFd],
|
|
@@ -15,7 +15,16 @@ export function runCommonAnnounceFlow(services, threadId, params) {
|
|
|
15
15
|
target_files: params.target_files,
|
|
16
16
|
depends_on_files: params.depends_on_files,
|
|
17
17
|
exports_affected: params.exports_affected,
|
|
18
|
+
target_symbols: params.target_symbols,
|
|
18
19
|
});
|
|
20
|
+
// Layer firing log: one row per concerned/gray-zone scored agent.
|
|
21
|
+
// Used by /api/scoring-stats and the dashboard "Conflict signals" panel.
|
|
22
|
+
const dbForFirings = getDb();
|
|
23
|
+
const insertFiring = dbForFirings.prepare("INSERT INTO layer_firings (thread_id, layer, score, agent_id) VALUES (?, ?, ?, ?)");
|
|
24
|
+
for (const s of [...categorized.concerned, ...categorized.gray_zone]) {
|
|
25
|
+
const layer = inferLayerFromReasons(s.reasons);
|
|
26
|
+
insertFiring.run(threadId, layer, s.score, s.agent_id);
|
|
27
|
+
}
|
|
19
28
|
// 2. Override expected_respondents on the thread with the scored set.
|
|
20
29
|
// Auto-resolve only when truly alone — if peers are online but not concerned
|
|
21
30
|
// (e.g., they haven't announced yet), keep the thread open so a subsequent
|
|
@@ -72,6 +81,25 @@ export function runCommonAnnounceFlow(services, threadId, params) {
|
|
|
72
81
|
const respondents = JSON.parse(updated.expected_respondents || "[]");
|
|
73
82
|
return { updated, categorized, respondents, planQuality };
|
|
74
83
|
}
|
|
84
|
+
function inferLayerFromReasons(reasons) {
|
|
85
|
+
for (const r of reasons) {
|
|
86
|
+
if (r.includes("disjoint symbols"))
|
|
87
|
+
return "L0.5";
|
|
88
|
+
if (r.includes("announced same file") || r.includes("modifies my dependency") || r.includes("they depend on my target"))
|
|
89
|
+
return "L0";
|
|
90
|
+
if (r.includes("same file (in flight)"))
|
|
91
|
+
return "L1";
|
|
92
|
+
if (r.includes("same file"))
|
|
93
|
+
return "L1";
|
|
94
|
+
if (r.includes("co-change"))
|
|
95
|
+
return "L4";
|
|
96
|
+
if (r.includes("depends on"))
|
|
97
|
+
return "L2";
|
|
98
|
+
if (r.includes("module overlap"))
|
|
99
|
+
return "L3";
|
|
100
|
+
}
|
|
101
|
+
return "L1";
|
|
102
|
+
}
|
|
75
103
|
function scoredCategory(s) {
|
|
76
104
|
if (s.score >= 90)
|
|
77
105
|
return "concerned";
|
|
@@ -74,6 +74,14 @@ export declare class Consultation {
|
|
|
74
74
|
* parsing the thread list themselves.
|
|
75
75
|
*/
|
|
76
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;
|
|
77
85
|
}): Thread[];
|
|
78
86
|
getThreadUpdates(agentId: string, since?: string): ThreadMessage[];
|
|
79
87
|
logActionSummary(params: {
|
package/dist/src/consultation.js
CHANGED
|
@@ -320,6 +320,14 @@ export class Consultation {
|
|
|
320
320
|
sql += " AND (assigned_to IS NULL OR assigned_to = ?)";
|
|
321
321
|
params.push(filters.assigned_to_me);
|
|
322
322
|
}
|
|
323
|
+
if (typeof filters.since_minutes === "number") {
|
|
324
|
+
// For resolved threads, gate on resolved_at (the moment that matters
|
|
325
|
+
// for "recent enough to still influence scoring"). For open/resolving
|
|
326
|
+
// threads, gate on created_at since they have no resolved_at yet.
|
|
327
|
+
// COALESCE picks the right column per row.
|
|
328
|
+
sql += " AND COALESCE(resolved_at, created_at) > datetime('now', '-' || ? || ' minutes')";
|
|
329
|
+
params.push(filters.since_minutes);
|
|
330
|
+
}
|
|
323
331
|
sql += " ORDER BY created_at DESC";
|
|
324
332
|
return db.prepare(sql).all(...params);
|
|
325
333
|
}
|
package/dist/src/database.js
CHANGED
|
@@ -3,6 +3,7 @@ import { mkdirSync } from "fs";
|
|
|
3
3
|
import { createRequire } from "module";
|
|
4
4
|
const require = createRequire(import.meta.url);
|
|
5
5
|
let db;
|
|
6
|
+
const CURRENT_USER_VERSION = 6;
|
|
6
7
|
const SCHEMA = `
|
|
7
8
|
CREATE TABLE IF NOT EXISTS agents (
|
|
8
9
|
id TEXT PRIMARY KEY,
|
|
@@ -129,6 +130,45 @@ const SCHEMA = `
|
|
|
129
130
|
revoked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
130
131
|
revoked_by TEXT NOT NULL
|
|
131
132
|
);
|
|
133
|
+
|
|
134
|
+
CREATE TABLE IF NOT EXISTS working_files (
|
|
135
|
+
agent_id TEXT NOT NULL,
|
|
136
|
+
file_path TEXT NOT NULL,
|
|
137
|
+
started_at TEXT NOT NULL,
|
|
138
|
+
last_activity_at TEXT NOT NULL,
|
|
139
|
+
claim_until TEXT NOT NULL,
|
|
140
|
+
PRIMARY KEY (agent_id, file_path)
|
|
141
|
+
);
|
|
142
|
+
CREATE INDEX IF NOT EXISTS idx_working_files_path ON working_files(file_path);
|
|
143
|
+
CREATE INDEX IF NOT EXISTS idx_working_files_until ON working_files(claim_until);
|
|
144
|
+
|
|
145
|
+
CREATE TABLE IF NOT EXISTS git_cochange (
|
|
146
|
+
file_a TEXT NOT NULL,
|
|
147
|
+
file_b TEXT NOT NULL,
|
|
148
|
+
count INTEGER NOT NULL,
|
|
149
|
+
total_commits INTEGER NOT NULL,
|
|
150
|
+
computed_at TEXT NOT NULL,
|
|
151
|
+
PRIMARY KEY (file_a, file_b),
|
|
152
|
+
CHECK (file_a < file_b)
|
|
153
|
+
);
|
|
154
|
+
CREATE INDEX IF NOT EXISTS idx_cochange_a ON git_cochange(file_a);
|
|
155
|
+
CREATE INDEX IF NOT EXISTS idx_cochange_b ON git_cochange(file_b);
|
|
156
|
+
|
|
157
|
+
CREATE TABLE IF NOT EXISTS git_cochange_meta (
|
|
158
|
+
k TEXT PRIMARY KEY,
|
|
159
|
+
v TEXT
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
CREATE TABLE IF NOT EXISTS layer_firings (
|
|
163
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
164
|
+
thread_id TEXT,
|
|
165
|
+
layer TEXT NOT NULL,
|
|
166
|
+
score INTEGER NOT NULL,
|
|
167
|
+
agent_id TEXT,
|
|
168
|
+
fired_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
169
|
+
);
|
|
170
|
+
CREATE INDEX IF NOT EXISTS idx_firings_layer ON layer_firings(layer, fired_at);
|
|
171
|
+
CREATE INDEX IF NOT EXISTS idx_firings_thread ON layer_firings(thread_id);
|
|
132
172
|
`;
|
|
133
173
|
function createBetterSqlite3(dataDir) {
|
|
134
174
|
mkdirSync(dataDir, { recursive: true });
|
|
@@ -157,6 +197,20 @@ export function initDatabase(dataDir) {
|
|
|
157
197
|
else {
|
|
158
198
|
db = createBetterSqlite3(dataDir);
|
|
159
199
|
}
|
|
200
|
+
// Check for downgrade: refuse if DB was written by a newer binary
|
|
201
|
+
let foundVersion = 0;
|
|
202
|
+
try {
|
|
203
|
+
const v = db
|
|
204
|
+
.prepare("PRAGMA user_version")
|
|
205
|
+
.get();
|
|
206
|
+
foundVersion = v?.user_version ?? 0;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
foundVersion = 0;
|
|
210
|
+
}
|
|
211
|
+
if (foundVersion > CURRENT_USER_VERSION) {
|
|
212
|
+
throw new Error(`Database schema is from a newer version (${foundVersion}) than this binary supports (${CURRENT_USER_VERSION}). Downgrade not supported.`);
|
|
213
|
+
}
|
|
160
214
|
db.exec(SCHEMA);
|
|
161
215
|
// Migrations for existing databases — columns may already exist
|
|
162
216
|
try {
|
|
@@ -182,6 +236,17 @@ export function initDatabase(dataDir) {
|
|
|
182
236
|
db.exec("ALTER TABLE threads ADD COLUMN assigned_to TEXT");
|
|
183
237
|
}
|
|
184
238
|
catch { /* already exists */ }
|
|
239
|
+
// v0.6: per-edit symbol metadata on file_activity
|
|
240
|
+
try {
|
|
241
|
+
db.exec("ALTER TABLE file_activity ADD COLUMN symbols_touched TEXT");
|
|
242
|
+
}
|
|
243
|
+
catch { /* already exists */ }
|
|
244
|
+
try {
|
|
245
|
+
db.exec("ALTER TABLE file_activity ADD COLUMN content_hash TEXT");
|
|
246
|
+
}
|
|
247
|
+
catch { /* already exists */ }
|
|
248
|
+
// v0.6: schema version marker. Used by cli/server/restore.ts to refuse downgrades.
|
|
249
|
+
db.exec("PRAGMA user_version = 6");
|
|
185
250
|
}
|
|
186
251
|
export function getDb() {
|
|
187
252
|
if (!db)
|
package/dist/src/db-adapter.d.ts
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database adapter surface.
|
|
3
|
+
*
|
|
4
|
+
* Design intent: this file is the *contract* both `createBetterSqlite3` and
|
|
5
|
+
* `createBunSqlite` (in `database.ts`) implement. The interfaces are a strict
|
|
6
|
+
* subset of better-sqlite3's API that Bun:sqlite also satisfies, so callers
|
|
7
|
+
* stay portable across both runtimes.
|
|
8
|
+
*
|
|
9
|
+
* Helpers (e.g. `withTransaction`) live here so portable code paths can use
|
|
10
|
+
* one canonical entry point without each call site re-deriving the
|
|
11
|
+
* `db.transaction(fn)()` two-step pattern.
|
|
12
|
+
*/
|
|
1
13
|
export interface RunResult {
|
|
2
14
|
changes: number;
|
|
3
15
|
lastInsertRowid: number;
|
|
@@ -13,3 +25,21 @@ export interface DatabaseAdapter {
|
|
|
13
25
|
close(): void;
|
|
14
26
|
transaction<T>(fn: () => T): () => T;
|
|
15
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Run `fn` inside a single SQLite transaction and return its result.
|
|
30
|
+
*
|
|
31
|
+
* Replaces the verbose two-step pattern:
|
|
32
|
+
*
|
|
33
|
+
* const tx = db.transaction(() => { ...; return value; });
|
|
34
|
+
* const value = tx();
|
|
35
|
+
*
|
|
36
|
+
* with:
|
|
37
|
+
*
|
|
38
|
+
* const value = withTransaction(db, () => { ...; return value; });
|
|
39
|
+
*
|
|
40
|
+
* Errors thrown inside `fn` propagate to the caller and the transaction is
|
|
41
|
+
* rolled back by the underlying driver (better-sqlite3 / bun:sqlite both do
|
|
42
|
+
* this). Use this for any read-modify-write block where multiple statements
|
|
43
|
+
* must be atomic.
|
|
44
|
+
*/
|
|
45
|
+
export declare function withTransaction<T>(db: DatabaseAdapter, fn: () => T): T;
|
package/dist/src/db-adapter.js
CHANGED
|
@@ -1 +1,32 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Database adapter surface.
|
|
3
|
+
*
|
|
4
|
+
* Design intent: this file is the *contract* both `createBetterSqlite3` and
|
|
5
|
+
* `createBunSqlite` (in `database.ts`) implement. The interfaces are a strict
|
|
6
|
+
* subset of better-sqlite3's API that Bun:sqlite also satisfies, so callers
|
|
7
|
+
* stay portable across both runtimes.
|
|
8
|
+
*
|
|
9
|
+
* Helpers (e.g. `withTransaction`) live here so portable code paths can use
|
|
10
|
+
* one canonical entry point without each call site re-deriving the
|
|
11
|
+
* `db.transaction(fn)()` two-step pattern.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Run `fn` inside a single SQLite transaction and return its result.
|
|
15
|
+
*
|
|
16
|
+
* Replaces the verbose two-step pattern:
|
|
17
|
+
*
|
|
18
|
+
* const tx = db.transaction(() => { ...; return value; });
|
|
19
|
+
* const value = tx();
|
|
20
|
+
*
|
|
21
|
+
* with:
|
|
22
|
+
*
|
|
23
|
+
* const value = withTransaction(db, () => { ...; return value; });
|
|
24
|
+
*
|
|
25
|
+
* Errors thrown inside `fn` propagate to the caller and the transaction is
|
|
26
|
+
* rolled back by the underlying driver (better-sqlite3 / bun:sqlite both do
|
|
27
|
+
* this). Use this for any read-modify-write block where multiple statements
|
|
28
|
+
* must be atomic.
|
|
29
|
+
*/
|
|
30
|
+
export function withTransaction(db, fn) {
|
|
31
|
+
return db.transaction(fn)();
|
|
32
|
+
}
|