relaybrain 0.0.0-stage → 0.2.1
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/LICENSE +21 -0
- package/README.md +434 -3
- package/backend/coordinator/client.js +364 -0
- package/backend/coordinator/lockTable.js +649 -0
- package/backend/coordinator/server.js +179 -0
- package/backend/lib/centralAuth.js +62 -0
- package/backend/lib/centralStore.js +112 -0
- package/backend/lib/centralSync.js +88 -0
- package/backend/lib/cloneRepo.js +108 -0
- package/backend/lib/collisionMetrics.js +231 -0
- package/backend/lib/conflicts.js +35 -0
- package/backend/lib/dashboard.js +53 -0
- package/backend/lib/depGraph.js +393 -0
- package/backend/lib/deps.js +46 -0
- package/backend/lib/dirtyFiles.js +33 -0
- package/backend/lib/editMatchers.js +55 -0
- package/backend/lib/falkor/analytics.js +228 -0
- package/backend/lib/falkor/bus.js +16 -0
- package/backend/lib/falkor/central.js +281 -0
- package/backend/lib/falkor/client.js +165 -0
- package/backend/lib/falkor/config.js +73 -0
- package/backend/lib/falkor/coord.js +299 -0
- package/backend/lib/falkor/cursor.js +58 -0
- package/backend/lib/falkor/demo.js +221 -0
- package/backend/lib/falkor/fixtures.js +85 -0
- package/backend/lib/falkor/index.js +146 -0
- package/backend/lib/falkor/ingest.js +233 -0
- package/backend/lib/falkor/intents.js +32 -0
- package/backend/lib/falkor/keys.js +102 -0
- package/backend/lib/falkor/migrations.js +56 -0
- package/backend/lib/falkor/project.js +307 -0
- package/backend/lib/falkor/query.js +344 -0
- package/backend/lib/falkor/redact.js +30 -0
- package/backend/lib/falkor/routes.js +125 -0
- package/backend/lib/falkor/schema.js +108 -0
- package/backend/lib/falkor/write.js +281 -0
- package/backend/lib/githubLogin.js +108 -0
- package/backend/lib/githubOAuth.js +42 -0
- package/backend/lib/githubRepo.js +117 -0
- package/backend/lib/harvest.js +85 -0
- package/backend/lib/httpServer.js +27 -0
- package/backend/lib/installAgentRelay.js +235 -0
- package/backend/lib/installHooks.js +309 -0
- package/backend/lib/lamport.js +14 -0
- package/backend/lib/localBrowse.js +109 -0
- package/backend/lib/memorySync.js +63 -0
- package/backend/lib/notices.js +150 -0
- package/backend/lib/ot.js +74 -0
- package/backend/lib/patches.js +192 -0
- package/backend/lib/paths.js +85 -0
- package/backend/lib/peerClash.js +64 -0
- package/backend/lib/presence.js +35 -0
- package/backend/lib/relayOsBlock.js +84 -0
- package/backend/lib/repoSnapshot.js +125 -0
- package/backend/lib/room.js +638 -0
- package/backend/lib/roomAsk.js +148 -0
- package/backend/lib/roomAuth.js +215 -0
- package/backend/lib/roomControl.js +226 -0
- package/backend/lib/roomLocks.js +201 -0
- package/backend/lib/roomSignal.js +265 -0
- package/backend/lib/roomSync.js +194 -0
- package/backend/lib/seed.js +107 -0
- package/backend/lib/sseHub.js +40 -0
- package/backend/lib/store.js +79 -0
- package/backend/lib/supergraph.js +46 -0
- package/backend/lib/transcripts/discover.js +437 -0
- package/backend/lib/transcripts/parse.js +558 -0
- package/backend/lib/transcripts/sessionId.js +36 -0
- package/backend/lib/transcripts/sync.js +291 -0
- package/backend/lib/transcripts/util.js +299 -0
- package/backend/lib/treeSitterImports.js +219 -0
- package/backend/lib/watchWorkspace.js +42 -0
- package/backend/mcp/README.md +193 -0
- package/backend/mcp/server.js +42 -0
- package/backend/mcp/tools.js +484 -0
- package/backend/server.js +3894 -0
- package/cli/graph.js +286 -0
- package/cli/relay.js +475 -0
- package/docker-compose.falkor.yml +17 -0
- package/docs/MEMORY-GRAPH.md +314 -0
- package/hooks/relay-antigravity-post-tool.js +2 -0
- package/hooks/relay-antigravity-pre-read.js +2 -0
- package/hooks/relay-antigravity-pre-tool.js +2 -0
- package/hooks/relay-antigravity-stop.js +2 -0
- package/hooks/relay-claude-post-tool.js +2 -0
- package/hooks/relay-claude-pre-read.js +2 -0
- package/hooks/relay-claude-pre-tool.js +2 -0
- package/hooks/relay-claude-stop.js +2 -0
- package/hooks/relay-codex-post-tool.js +2 -0
- package/hooks/relay-codex-pre-read.js +2 -0
- package/hooks/relay-codex-pre-tool.js +2 -0
- package/hooks/relay-codex-stop.js +2 -0
- package/hooks/relay-copilot-post-tool.js +2 -0
- package/hooks/relay-copilot-pre-read.js +2 -0
- package/hooks/relay-copilot-pre-tool.js +2 -0
- package/hooks/relay-copilot-stop.js +2 -0
- package/hooks/relay-cursor-post-tool.js +2 -0
- package/hooks/relay-cursor-pre-read.js +2 -0
- package/hooks/relay-cursor-pre-tool.js +2 -0
- package/hooks/relay-cursor-stop.js +2 -0
- package/hooks/relay-hook-lib.js +621 -0
- package/mission-control/out/404.html +1 -0
- package/mission-control/out/__next.__PAGE__.txt +26 -0
- package/mission-control/out/__next._full.txt +21 -0
- package/mission-control/out/__next._tree.txt +4 -0
- package/mission-control/out/_next/static/BuhfCxd0WQE65yIzZJCOM/_buildManifest.js +11 -0
- package/mission-control/out/_next/static/BuhfCxd0WQE65yIzZJCOM/_clientMiddlewareManifest.js +1 -0
- package/mission-control/out/_next/static/BuhfCxd0WQE65yIzZJCOM/_ssgManifest.js +1 -0
- package/mission-control/out/_next/static/chunks/0-3l04fnym95-.js +22 -0
- package/mission-control/out/_next/static/chunks/064ntycm-fx5v.js +1 -0
- package/mission-control/out/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
- package/mission-control/out/_next/static/chunks/0ehjiuuxbbhq9.js +1 -0
- package/mission-control/out/_next/static/chunks/1-dy52ls2ntb_.css +4 -0
- package/mission-control/out/_next/static/chunks/1q0cmt0kqufow.js +2 -0
- package/mission-control/out/_next/static/chunks/1ya1u0izct4f6.js +1 -0
- package/mission-control/out/_next/static/chunks/2n66mlnb34vjq.js +1 -0
- package/mission-control/out/_next/static/chunks/2xar70jzx8f1y.css +1 -0
- package/mission-control/out/_next/static/chunks/31cgqj_-l15yt.js +31 -0
- package/mission-control/out/_next/static/chunks/31qgr-p-oulpb.js +1 -0
- package/mission-control/out/_next/static/chunks/3vq9c4hivvgn_.js +1 -0
- package/mission-control/out/_next/static/chunks/turbopack-3hsllzpnzz12p.js +1 -0
- package/mission-control/out/_next/static/media/icon.27otmh_fv3buh.svg +4 -0
- package/mission-control/out/_not-found/__next._full.txt +18 -0
- package/mission-control/out/_not-found/__next._not-found/__PAGE__.txt +24 -0
- package/mission-control/out/_not-found/__next._tree.txt +3 -0
- package/mission-control/out/_not-found.html +1 -0
- package/mission-control/out/_not-found.txt +18 -0
- package/mission-control/out/agents/antigravity.png +0 -0
- package/mission-control/out/agents/claude.png +0 -0
- package/mission-control/out/agents/codex.png +0 -0
- package/mission-control/out/agents/copilot.png +0 -0
- package/mission-control/out/agents/cursor.png +0 -0
- package/mission-control/out/icon.svg +4 -0
- package/mission-control/out/index.html +1 -0
- package/mission-control/out/index.txt +21 -0
- package/mission-control/out/logo.png +0 -0
- package/mission-control/out/logo_transparent.png +0 -0
- package/package.json +76 -6
- package/scripts/falkor-rebuild.js +38 -0
- package/scripts/falkor-seed-demo.js +29 -0
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
const { EventEmitter } = require("events");
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const crypto = require("crypto");
|
|
5
|
+
const { locksDirFor } = require("../lib/paths");
|
|
6
|
+
const { normalizeWorkspaceRoot } = require("../lib/transcripts/util");
|
|
7
|
+
|
|
8
|
+
const DEFAULT_TTL_MS = 15_000;
|
|
9
|
+
const MIN_TTL_MS = 5_000;
|
|
10
|
+
const MAX_TTL_MS = 300_000;
|
|
11
|
+
const CLEANUP_MS = 5_000;
|
|
12
|
+
|
|
13
|
+
function clampTtl(ttlMs) {
|
|
14
|
+
const n = Number(ttlMs);
|
|
15
|
+
if (!Number.isFinite(n)) return DEFAULT_TTL_MS;
|
|
16
|
+
return Math.min(MAX_TTL_MS, Math.max(MIN_TTL_MS, n));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeFile(filePath) {
|
|
20
|
+
return String(filePath || "")
|
|
21
|
+
.replace(/\\/g, "/")
|
|
22
|
+
.replace(/^\.?\//, "")
|
|
23
|
+
.trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normWorkspace(value) {
|
|
27
|
+
return String(value || "")
|
|
28
|
+
.replace(/\\/g, "/")
|
|
29
|
+
.replace(/\/+$/, "")
|
|
30
|
+
.toLowerCase();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function workspaceRoot(value) {
|
|
34
|
+
if (!value || typeof value !== "string") return null;
|
|
35
|
+
if (!/[\\/]/.test(value)) return null;
|
|
36
|
+
return normalizeWorkspaceRoot(value) || path.resolve(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function lockKey(workspaceId, filePath) {
|
|
40
|
+
return `${normWorkspace(workspaceId)}::${normalizeFile(filePath)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function lockHash(filePath, workspaceId) {
|
|
44
|
+
const material = workspaceId ? `${normWorkspace(workspaceId)}::${normalizeFile(filePath)}` : normalizeFile(filePath);
|
|
45
|
+
return crypto.createHash("sha256").update(material).digest("hex").slice(0, 16);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// agentId is `label:host:session`.
|
|
49
|
+
function sameOwner(a, b) {
|
|
50
|
+
if (!a || !b) return false;
|
|
51
|
+
if (a === b) return true;
|
|
52
|
+
const pa = String(a).split(":");
|
|
53
|
+
const pb = String(b).split(":");
|
|
54
|
+
const sameHost = (pa[1] || "").toLowerCase() === (pb[1] || "").toLowerCase();
|
|
55
|
+
if (!sameHost) return false;
|
|
56
|
+
|
|
57
|
+
const sa = (pa[2] || "").toLowerCase();
|
|
58
|
+
const sb = (pb[2] || "").toLowerCase();
|
|
59
|
+
const known = (s) => s !== "" && s !== "local";
|
|
60
|
+
|
|
61
|
+
// When both sides name a real session, the session decides and the label is
|
|
62
|
+
// irrelevant. That cuts both ways:
|
|
63
|
+
// - one editor firing two hook configs claims as `Cursor:host:sid` and again
|
|
64
|
+
// as `Claude Code:host:sid`; matching on session stops it deadlocking
|
|
65
|
+
// against itself
|
|
66
|
+
// - two concurrent turns of the SAME product are `Antigravity:host:A` and
|
|
67
|
+
// `Antigravity:host:B`; matching on label alone used to call those one
|
|
68
|
+
// owner, so they silently shared the file and neither was ever blocked
|
|
69
|
+
if (known(sa) && known(sb)) return sa === sb;
|
|
70
|
+
|
|
71
|
+
// At least one side never told us its session. Nothing distinguishes them,
|
|
72
|
+
// so fall back to the label and err towards letting an agent re-take its own
|
|
73
|
+
// lock rather than stranding it behind a lock it cannot identify.
|
|
74
|
+
return pa[0] === pb[0];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sameWorkspace(a, b) {
|
|
78
|
+
if (!a || !b) return false;
|
|
79
|
+
return normWorkspace(a) === normWorkspace(b);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function filterKeys(filter) {
|
|
83
|
+
const keys = new Set();
|
|
84
|
+
const add = (value) => {
|
|
85
|
+
if (!value) return;
|
|
86
|
+
keys.add(String(value));
|
|
87
|
+
keys.add(normWorkspace(value));
|
|
88
|
+
const root = workspaceRoot(value);
|
|
89
|
+
if (root) keys.add(normWorkspace(root));
|
|
90
|
+
};
|
|
91
|
+
if (filter && typeof filter === "object") {
|
|
92
|
+
add(filter.id);
|
|
93
|
+
add(filter.path);
|
|
94
|
+
add(filter.workspaceId);
|
|
95
|
+
for (const extra of filter.aliases || []) add(extra);
|
|
96
|
+
} else {
|
|
97
|
+
add(filter);
|
|
98
|
+
}
|
|
99
|
+
return keys;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function belongsTo(entry, keys) {
|
|
103
|
+
if (!keys.size) return false;
|
|
104
|
+
const wid = entry.workspaceId;
|
|
105
|
+
if (!wid) return false;
|
|
106
|
+
return keys.has(String(wid)) || keys.has(normWorkspace(wid));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function alive(entry, now = Date.now()) {
|
|
110
|
+
const beat = entry.lastHeartbeat || entry.claimedAt;
|
|
111
|
+
return now < beat + entry.ttlMs;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// How long a released lock stays on the board, and how many to keep around.
|
|
115
|
+
const RECENT_TTL_MS = 45000;
|
|
116
|
+
const RECENT_MAX = 200;
|
|
117
|
+
|
|
118
|
+
// Reads are presence, not claims: they decay on their own and are capped so a
|
|
119
|
+
// repo-wide grep cannot flood the board.
|
|
120
|
+
const READ_TTL_MS = 30000;
|
|
121
|
+
const READ_MAX = 300;
|
|
122
|
+
// How long an agent that was refused a file stays interested in its next patch.
|
|
123
|
+
const WAITER_TTL_MS = 5 * 60 * 1000;
|
|
124
|
+
|
|
125
|
+
class LockTable extends EventEmitter {
|
|
126
|
+
constructor(opts = {}) {
|
|
127
|
+
super();
|
|
128
|
+
this.locks = new Map();
|
|
129
|
+
this.locksDir = opts.locksDir || null;
|
|
130
|
+
this.defaultWorkspace = opts.workspacePath || null;
|
|
131
|
+
this._timer = null;
|
|
132
|
+
this._loaded = new Map();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
startCleanup() {
|
|
136
|
+
if (this._timer) return;
|
|
137
|
+
this._timer = setInterval(() => this.sweepExpired(), CLEANUP_MS);
|
|
138
|
+
if (this._timer.unref) this._timer.unref();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
stopCleanup() {
|
|
142
|
+
if (this._timer) clearInterval(this._timer);
|
|
143
|
+
this._timer = null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
dirFor(workspaceId) {
|
|
147
|
+
try {
|
|
148
|
+
const root = workspaceRoot(workspaceId) || this.defaultWorkspace;
|
|
149
|
+
return root ? locksDirFor(root) : this.locksDir;
|
|
150
|
+
} catch {
|
|
151
|
+
return this.locksDir;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
persist(entry) {
|
|
156
|
+
try {
|
|
157
|
+
const dir = this.dirFor(entry.workspaceId);
|
|
158
|
+
if (!dir) return;
|
|
159
|
+
const serial = { ...entry, readers: [...(entry.readers || [])] };
|
|
160
|
+
fs.writeFileSync(path.join(dir, `${lockHash(entry.filePath, entry.workspaceId)}.lock`), JSON.stringify(serial, null, 2));
|
|
161
|
+
} catch {
|
|
162
|
+
/* in-memory lock still counts; a missing drive must not drop the claim */
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
unpersist(filePath, workspaceId) {
|
|
167
|
+
const dir = this.dirFor(workspaceId);
|
|
168
|
+
if (!dir) return;
|
|
169
|
+
for (const name of [lockHash(filePath, workspaceId), lockHash(filePath)]) {
|
|
170
|
+
try {
|
|
171
|
+
fs.unlinkSync(path.join(dir, `${name}.lock`));
|
|
172
|
+
} catch {
|
|
173
|
+
/* ignore */
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
ingestEntry(entry, fallbackWorkspace) {
|
|
179
|
+
if (!entry?.filePath || !alive(entry)) return;
|
|
180
|
+
entry.workspaceId = workspaceRoot(entry.workspaceId) || entry.workspaceId || fallbackWorkspace || this.defaultWorkspace;
|
|
181
|
+
if (!entry.workspaceId) return;
|
|
182
|
+
entry.readers = new Set(entry.readers || []);
|
|
183
|
+
this.locks.set(lockKey(entry.workspaceId, entry.filePath), entry);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Fold this workspace's on-disk lock files into the table.
|
|
188
|
+
*
|
|
189
|
+
* This used to run exactly once per workspace per process. But this process is
|
|
190
|
+
* not the only writer: every hook that cannot reach the coordinator falls back
|
|
191
|
+
* to `filesystemClaim`, and each of those runs in its own short-lived node
|
|
192
|
+
* process. Those locks are real — they arbitrate correctly between agents —
|
|
193
|
+
* but a one-shot load meant the long-running server never saw a single one of
|
|
194
|
+
* them, so the Coordinator board showed nothing while agents were in fact
|
|
195
|
+
* locking files against each other.
|
|
196
|
+
*
|
|
197
|
+
* Re-scan instead, gated on the directory's own mtime so a quiet workspace
|
|
198
|
+
* costs one stat.
|
|
199
|
+
*/
|
|
200
|
+
loadWorkspace(workspacePath) {
|
|
201
|
+
const root = workspaceRoot(workspacePath) || workspacePath;
|
|
202
|
+
if (!root) return;
|
|
203
|
+
const mark = normWorkspace(root);
|
|
204
|
+
let dir;
|
|
205
|
+
try {
|
|
206
|
+
dir = locksDirFor(root);
|
|
207
|
+
} catch {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (!dir || !fs.existsSync(dir)) return;
|
|
211
|
+
let stamp = 0;
|
|
212
|
+
try {
|
|
213
|
+
stamp = fs.statSync(dir).mtimeMs;
|
|
214
|
+
} catch {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const seen = this._loaded.get(mark);
|
|
218
|
+
// Poll the directory at most this often even when its mtime looks unchanged:
|
|
219
|
+
// some filesystems do not bump a directory's mtime when a file inside it is
|
|
220
|
+
// rewritten in place, which is exactly what a heartbeat does.
|
|
221
|
+
if (seen && seen.stamp === stamp && Date.now() - seen.at < 1000) return;
|
|
222
|
+
this._loaded.set(mark, { stamp, at: Date.now() });
|
|
223
|
+
let names = [];
|
|
224
|
+
try {
|
|
225
|
+
names = fs.readdirSync(dir);
|
|
226
|
+
} catch {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
let added = false;
|
|
230
|
+
for (const name of names) {
|
|
231
|
+
if (!name.endsWith(".lock")) continue;
|
|
232
|
+
try {
|
|
233
|
+
const entry = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
|
|
234
|
+
// An entry this table already owns is authoritative in memory: rereading
|
|
235
|
+
// it would clobber a live heartbeat with whatever last hit the disk.
|
|
236
|
+
const key = lockKey(workspaceRoot(entry.workspaceId) || entry.workspaceId || root, entry.filePath);
|
|
237
|
+
if (this.locks.has(key)) continue;
|
|
238
|
+
const before = this.locks.size;
|
|
239
|
+
this.ingestEntry(entry, root);
|
|
240
|
+
if (this.locks.size !== before) added = true;
|
|
241
|
+
} catch {
|
|
242
|
+
/* a partially written lock file is retried on the next scan */
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// Deferred: loadWorkspace runs inside list()/status(), and a synchronous
|
|
246
|
+
// emit would re-enter whichever read is already in progress.
|
|
247
|
+
if (added) setImmediate(() => this.emit("change", { type: "adopted", workspaceId: root }));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
loadFromDisk() {
|
|
251
|
+
if (this.defaultWorkspace) this.loadWorkspace(this.defaultWorkspace);
|
|
252
|
+
else if (this.locksDir && fs.existsSync(this.locksDir)) {
|
|
253
|
+
const root = path.resolve(this.locksDir, "..", "..");
|
|
254
|
+
this.loadWorkspace(root);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
getLock(file, workspaceId) {
|
|
259
|
+
const ws = workspaceRoot(workspaceId) || workspaceId;
|
|
260
|
+
if (!ws) return null;
|
|
261
|
+
this.loadWorkspace(ws);
|
|
262
|
+
return this.locks.get(lockKey(ws, file)) || null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
publicEntry(entry) {
|
|
266
|
+
return {
|
|
267
|
+
filePath: entry.filePath,
|
|
268
|
+
agentId: entry.agentId,
|
|
269
|
+
mode: entry.mode || "write",
|
|
270
|
+
claimedAt: entry.claimedAt,
|
|
271
|
+
lastHeartbeat: entry.lastHeartbeat,
|
|
272
|
+
ttlMs: entry.ttlMs,
|
|
273
|
+
expiresAt: (entry.lastHeartbeat || entry.claimedAt) + entry.ttlMs,
|
|
274
|
+
workspaceId: entry.workspaceId || null,
|
|
275
|
+
source: entry.source || "coordinator",
|
|
276
|
+
holder: entry.holder || null,
|
|
277
|
+
readers: [...(entry.readers || [])],
|
|
278
|
+
escalated: Boolean(entry.escalated),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
status(filter) {
|
|
283
|
+
const keys = filter ? filterKeys(filter) : null;
|
|
284
|
+
const locks = {};
|
|
285
|
+
for (const entry of this.locks.values()) {
|
|
286
|
+
if (keys && !belongsTo(entry, keys)) continue;
|
|
287
|
+
locks[`${normWorkspace(entry.workspaceId)}::${entry.filePath}`] = this.publicEntry(entry);
|
|
288
|
+
}
|
|
289
|
+
return { locks, uptime: process.uptime() };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
list(workspaceId) {
|
|
293
|
+
const keys = filterKeys(workspaceId);
|
|
294
|
+
const root =
|
|
295
|
+
workspaceRoot(typeof workspaceId === "object" ? workspaceId?.path || workspaceId?.workspaceId : workspaceId) ||
|
|
296
|
+
(typeof workspaceId === "object" ? workspaceRoot(workspaceId?.path) : null);
|
|
297
|
+
if (root) this.loadWorkspace(root);
|
|
298
|
+
const out = [];
|
|
299
|
+
for (const entry of this.locks.values()) {
|
|
300
|
+
if (!belongsTo(entry, keys)) continue;
|
|
301
|
+
out.push(this.publicEntry(entry));
|
|
302
|
+
}
|
|
303
|
+
out.push(...this.recentFor(keys));
|
|
304
|
+
return out.sort((a, b) => (b.releasedAt || b.claimedAt) - (a.releasedAt || a.claimedAt));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
heartbeat({ agentId, file, workspaceId }) {
|
|
308
|
+
const now = Date.now();
|
|
309
|
+
const ws = workspaceRoot(workspaceId);
|
|
310
|
+
let n = 0;
|
|
311
|
+
for (const entry of this.locks.values()) {
|
|
312
|
+
if (ws && !sameWorkspace(entry.workspaceId, ws)) continue;
|
|
313
|
+
if (!ws) continue;
|
|
314
|
+
const matchFile = !file || normalizeFile(file) === entry.filePath;
|
|
315
|
+
const matchAgent =
|
|
316
|
+
sameOwner(entry.agentId, agentId) ||
|
|
317
|
+
(entry.readers && [...entry.readers].some((r) => sameOwner(r, agentId)));
|
|
318
|
+
if (matchFile && matchAgent) {
|
|
319
|
+
entry.lastHeartbeat = now;
|
|
320
|
+
this.persist(entry);
|
|
321
|
+
n += 1;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (n) this.emit("change", { type: "heartbeat", agentId, workspaceId: ws });
|
|
325
|
+
return { ok: true, renewed: n };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
escalateRegister(entry) {
|
|
329
|
+
const filePath = normalizeFile(entry.filePath);
|
|
330
|
+
const ws = workspaceRoot(entry.workspaceId) || entry.workspaceId;
|
|
331
|
+
if (!ws) return { ok: false, reason: "workspace_required" };
|
|
332
|
+
const key = lockKey(ws, filePath);
|
|
333
|
+
const existing = this.locks.get(key);
|
|
334
|
+
if (existing && alive(existing) && !sameOwner(existing.agentId, entry.agentId)) {
|
|
335
|
+
return { ok: false, winner: existing.agentId };
|
|
336
|
+
}
|
|
337
|
+
const now = Date.now();
|
|
338
|
+
const next = {
|
|
339
|
+
...entry,
|
|
340
|
+
filePath,
|
|
341
|
+
workspaceId: ws,
|
|
342
|
+
readers: new Set(entry.readers || []),
|
|
343
|
+
claimedAt: entry.claimedAt || now,
|
|
344
|
+
lastHeartbeat: now,
|
|
345
|
+
ttlMs: clampTtl(entry.ttlMs),
|
|
346
|
+
escalated: true,
|
|
347
|
+
source: "escalated",
|
|
348
|
+
};
|
|
349
|
+
this.locks.set(key, next);
|
|
350
|
+
this.persist(next);
|
|
351
|
+
this.emit("change", { type: "escalate", entry: next });
|
|
352
|
+
return { ok: true, lock: this.publicEntry(next) };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
claim({ agentId, file, ttl, workspaceId, holder, source, dependsOn, files, mode }) {
|
|
356
|
+
const ws = workspaceRoot(workspaceId) || workspaceId;
|
|
357
|
+
if (!ws || !/[\\/]/.test(String(ws))) {
|
|
358
|
+
return { allowed: false, reason: "workspace path is required so locks stay per repository" };
|
|
359
|
+
}
|
|
360
|
+
this.loadWorkspace(ws);
|
|
361
|
+
|
|
362
|
+
const batch = Array.isArray(files) && files.length ? files.map(normalizeFile).filter(Boolean) : null;
|
|
363
|
+
if (batch) {
|
|
364
|
+
const sorted = [...new Set(batch)].sort();
|
|
365
|
+
const held = [];
|
|
366
|
+
for (const f of sorted) {
|
|
367
|
+
const result = this.claim({ agentId, file: f, ttl, workspaceId: ws, holder, source, dependsOn, mode });
|
|
368
|
+
if (!result.allowed) {
|
|
369
|
+
for (const h of held) this.release({ agentId, file: h, workspaceId: ws });
|
|
370
|
+
return result;
|
|
371
|
+
}
|
|
372
|
+
held.push(f);
|
|
373
|
+
}
|
|
374
|
+
return { allowed: true, batch: sorted };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const filePath = normalizeFile(file);
|
|
378
|
+
if (!filePath || !agentId) return { allowed: false, reason: "agentId and file are required" };
|
|
379
|
+
const ttlMs = clampTtl(ttl);
|
|
380
|
+
const now = Date.now();
|
|
381
|
+
const lockMode = mode === "read" ? "read" : "write";
|
|
382
|
+
const key = lockKey(ws, filePath);
|
|
383
|
+
|
|
384
|
+
const deps = Array.isArray(dependsOn) ? dependsOn.map(normalizeFile) : [];
|
|
385
|
+
let warning = null;
|
|
386
|
+
for (const dep of deps) {
|
|
387
|
+
const held = this.locks.get(lockKey(ws, dep));
|
|
388
|
+
if (held && held.mode !== "read" && !sameOwner(held.agentId, agentId) && alive(held, now)) {
|
|
389
|
+
warning = `\`${filePath}\` depends on \`${dep}\`, which ${held.holder?.label || held.agentId} is editing`;
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// A read is never a lock. It is recorded so the dashboard can show who is
|
|
395
|
+
// looking at what, and it is always allowed — including while someone else
|
|
396
|
+
// holds the write lock, since observing a file cannot corrupt it.
|
|
397
|
+
if (lockMode === "read") {
|
|
398
|
+
this.noteRead({ agentId, filePath, workspaceId: ws, holder });
|
|
399
|
+
return { allowed: true, reading: true };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
let existing = this.locks.get(key);
|
|
403
|
+
if (existing && !alive(existing, now)) {
|
|
404
|
+
this.unpersist(existing.filePath, existing.workspaceId);
|
|
405
|
+
this.locks.delete(key);
|
|
406
|
+
existing = null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (existing) {
|
|
410
|
+
if (sameOwner(existing.agentId, agentId) && existing.mode === "write") {
|
|
411
|
+
existing.claimedAt = now;
|
|
412
|
+
existing.lastHeartbeat = now;
|
|
413
|
+
existing.ttlMs = ttlMs;
|
|
414
|
+
existing.workspaceId = ws;
|
|
415
|
+
this.persist(existing);
|
|
416
|
+
this.emit("change", { type: "renew", entry: existing });
|
|
417
|
+
return { allowed: true, renewed: true, warning, lock: this.publicEntry(existing) };
|
|
418
|
+
}
|
|
419
|
+
// Readers never block a writer. Reading a file is not a claim on it: an
|
|
420
|
+
// agent that merely looked at a file has nothing to lose if someone else
|
|
421
|
+
// edits it, and denying the edit meant one agent browsing the repo could
|
|
422
|
+
// stall everyone else. Only a live write lock arbitrates.
|
|
423
|
+
if (existing.mode === "read") {
|
|
424
|
+
this.locks.delete(key);
|
|
425
|
+
this.unpersist(filePath, ws);
|
|
426
|
+
existing = null;
|
|
427
|
+
}
|
|
428
|
+
if (existing && existing.mode === "write") {
|
|
429
|
+
// This agent wanted the file and could not have it, so it is the one
|
|
430
|
+
// that most needs the resulting patch the moment the holder is done.
|
|
431
|
+
this.noteWaiter({ agentId, filePath, workspaceId: ws, holder });
|
|
432
|
+
return {
|
|
433
|
+
allowed: false,
|
|
434
|
+
holder: existing.agentId,
|
|
435
|
+
reason: `File \`${filePath}\` is locked by ${existing.holder?.label || existing.agentId}. Pick a different task.`,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const entry = {
|
|
441
|
+
filePath,
|
|
442
|
+
agentId,
|
|
443
|
+
mode: lockMode,
|
|
444
|
+
claimedAt: now,
|
|
445
|
+
lastHeartbeat: now,
|
|
446
|
+
ttlMs,
|
|
447
|
+
workspaceId: ws,
|
|
448
|
+
holder: holder || null,
|
|
449
|
+
source: source || "coordinator",
|
|
450
|
+
readers: lockMode === "read" ? new Set([agentId]) : new Set(),
|
|
451
|
+
escalated: false,
|
|
452
|
+
};
|
|
453
|
+
this.locks.set(key, entry);
|
|
454
|
+
this.persist(entry);
|
|
455
|
+
this.emit("change", { type: "claim", entry });
|
|
456
|
+
return { allowed: true, warning, lock: this.publicEntry(entry) };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
release({ agentId, file, workspaceId }) {
|
|
460
|
+
const ws = workspaceRoot(workspaceId) || workspaceId;
|
|
461
|
+
if (!ws) return { ok: false, reason: "workspace_required" };
|
|
462
|
+
const filePath = normalizeFile(file);
|
|
463
|
+
const key = lockKey(ws, filePath);
|
|
464
|
+
const existing = this.locks.get(key);
|
|
465
|
+
if (!existing) return { ok: true, released: false };
|
|
466
|
+
if (existing.mode === "read") {
|
|
467
|
+
existing.readers = existing.readers || new Set();
|
|
468
|
+
existing.readers.delete(agentId);
|
|
469
|
+
if (existing.readers.size === 0) {
|
|
470
|
+
this.locks.delete(key);
|
|
471
|
+
this.unpersist(filePath, ws);
|
|
472
|
+
} else {
|
|
473
|
+
this.persist(existing);
|
|
474
|
+
}
|
|
475
|
+
this.emit("change", { type: "release", entry: existing });
|
|
476
|
+
return { ok: true, released: true };
|
|
477
|
+
}
|
|
478
|
+
if (!sameOwner(existing.agentId, agentId)) {
|
|
479
|
+
return { ok: false, reason: "not_holder", holder: existing.agentId };
|
|
480
|
+
}
|
|
481
|
+
this.locks.delete(key);
|
|
482
|
+
this.unpersist(filePath, ws);
|
|
483
|
+
this.remember(existing);
|
|
484
|
+
this.emit("change", { type: "release", entry: existing });
|
|
485
|
+
return { ok: true, released: true };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Keep a released lock visible for a short while after it is gone.
|
|
490
|
+
*
|
|
491
|
+
* A pre-tool claim and its post-tool release can be ~100ms apart — the fast
|
|
492
|
+
* models turn an edit around faster than any UI samples state. The lock did
|
|
493
|
+
* its job, but the Coordinator board only ever renders locks that are held
|
|
494
|
+
* right now, so those edits looked like nothing happened at all. These rows
|
|
495
|
+
* are display-only: `getLock` (and so every arbitration path) never sees them.
|
|
496
|
+
*/
|
|
497
|
+
remember(entry) {
|
|
498
|
+
if (!entry?.filePath) return;
|
|
499
|
+
if (!this.recent) this.recent = new Map();
|
|
500
|
+
this.recent.set(lockKey(entry.workspaceId, entry.filePath), {
|
|
501
|
+
...entry,
|
|
502
|
+
readers: [...(entry.readers || [])],
|
|
503
|
+
releasedAt: entry.releasedAt || Date.now(),
|
|
504
|
+
});
|
|
505
|
+
if (this.recent.size > RECENT_MAX) this.recent.delete(this.recent.keys().next().value);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Record that an agent looked at a file.
|
|
510
|
+
*
|
|
511
|
+
* Purely observational: nothing here is consulted when arbitrating a claim.
|
|
512
|
+
* It exists so the dashboard can show the whole room's attention — who is
|
|
513
|
+
* reading what — instead of only the handful of files being written.
|
|
514
|
+
*/
|
|
515
|
+
noteRead({ agentId, filePath, workspaceId, holder }) {
|
|
516
|
+
const file = normalizeFile(filePath);
|
|
517
|
+
const ws = workspaceRoot(workspaceId) || workspaceId;
|
|
518
|
+
if (!file || !agentId || !ws) return null;
|
|
519
|
+
if (!this.reads) this.reads = new Map();
|
|
520
|
+
const key = `${normWorkspace(ws)}::${file}::${agentId}`;
|
|
521
|
+
const entry = { filePath: file, agentId, workspaceId: ws, holder: holder || null, at: Date.now() };
|
|
522
|
+
this.reads.delete(key);
|
|
523
|
+
this.reads.set(key, entry);
|
|
524
|
+
if (this.reads.size > READ_MAX) this.reads.delete(this.reads.keys().next().value);
|
|
525
|
+
this.emit("change", { type: "read", entry });
|
|
526
|
+
return entry;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
readsFor(keys, now = Date.now()) {
|
|
530
|
+
const out = [];
|
|
531
|
+
for (const [key, entry] of this.reads || []) {
|
|
532
|
+
if (now - entry.at > READ_TTL_MS) {
|
|
533
|
+
this.reads.delete(key);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (keys && !belongsTo(entry, keys)) continue;
|
|
537
|
+
out.push({
|
|
538
|
+
...entry,
|
|
539
|
+
mode: "read",
|
|
540
|
+
reading: true,
|
|
541
|
+
ttlMs: READ_TTL_MS,
|
|
542
|
+
expiresAt: entry.at + READ_TTL_MS,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
return out.sort((a, b) => b.at - a.at);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Remember that an agent was turned away from a file, so that when the holder
|
|
550
|
+
* finishes we know exactly who is still waiting for that file's new content.
|
|
551
|
+
*/
|
|
552
|
+
noteWaiter({ agentId, filePath, workspaceId, holder }) {
|
|
553
|
+
const file = normalizeFile(filePath);
|
|
554
|
+
const ws = workspaceRoot(workspaceId) || workspaceId;
|
|
555
|
+
if (!file || !agentId || !ws) return;
|
|
556
|
+
if (!this.waiters) this.waiters = new Map();
|
|
557
|
+
const key = lockKey(ws, file);
|
|
558
|
+
const list = this.waiters.get(key) || new Map();
|
|
559
|
+
list.set(agentId, { agentId, holder: holder || null, at: Date.now() });
|
|
560
|
+
this.waiters.set(key, list);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Waiters for a file, newest first; expired entries are dropped. */
|
|
564
|
+
takeWaiters(filePath, workspaceId) {
|
|
565
|
+
const file = normalizeFile(filePath);
|
|
566
|
+
const ws = workspaceRoot(workspaceId) || workspaceId;
|
|
567
|
+
if (!file || !ws || !this.waiters) return [];
|
|
568
|
+
const key = lockKey(ws, file);
|
|
569
|
+
const list = this.waiters.get(key);
|
|
570
|
+
if (!list) return [];
|
|
571
|
+
const now = Date.now();
|
|
572
|
+
const out = [];
|
|
573
|
+
for (const [agentId, row] of list) {
|
|
574
|
+
if (now - row.at > WAITER_TTL_MS) list.delete(agentId);
|
|
575
|
+
else out.push(row);
|
|
576
|
+
}
|
|
577
|
+
if (!list.size) this.waiters.delete(key);
|
|
578
|
+
return out;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
recentFor(keys, now = Date.now()) {
|
|
582
|
+
const out = [];
|
|
583
|
+
for (const [key, entry] of this.recent || []) {
|
|
584
|
+
if (now - entry.releasedAt > RECENT_TTL_MS) {
|
|
585
|
+
this.recent.delete(key);
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
if (this.locks.has(key)) continue;
|
|
589
|
+
if (keys && !belongsTo(entry, keys)) continue;
|
|
590
|
+
out.push({ ...this.publicEntry(entry), released: true, releasedAt: entry.releasedAt });
|
|
591
|
+
}
|
|
592
|
+
return out;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
releaseAll(agentId, workspaceId) {
|
|
596
|
+
const ws = workspaceRoot(workspaceId) || workspaceId;
|
|
597
|
+
const released = [];
|
|
598
|
+
for (const [key, entry] of [...this.locks]) {
|
|
599
|
+
if (ws && !sameWorkspace(entry.workspaceId, ws)) continue;
|
|
600
|
+
if (!ws) continue;
|
|
601
|
+
if (sameOwner(entry.agentId, agentId) || (entry.readers && [...entry.readers].some((r) => sameOwner(r, agentId)))) {
|
|
602
|
+
this.release({ agentId, file: entry.filePath, workspaceId: entry.workspaceId });
|
|
603
|
+
released.push(entry.filePath);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return { released };
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Workspaces to re-scan on the cleanup tick, so fail-open locks surface. */
|
|
610
|
+
track(workspacePath) {
|
|
611
|
+
const root = workspaceRoot(workspacePath) || workspacePath;
|
|
612
|
+
if (!root) return;
|
|
613
|
+
if (!this._tracked) this._tracked = new Set();
|
|
614
|
+
this._tracked.add(root);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
sweepExpired() {
|
|
618
|
+
for (const root of this._tracked || []) {
|
|
619
|
+
try {
|
|
620
|
+
this.loadWorkspace(root);
|
|
621
|
+
} catch {
|
|
622
|
+
/* keep sweeping */
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
const now = Date.now();
|
|
626
|
+
let changed = false;
|
|
627
|
+
for (const [key, entry] of [...this.locks]) {
|
|
628
|
+
if (!alive(entry, now)) {
|
|
629
|
+
this.locks.delete(key);
|
|
630
|
+
this.unpersist(entry.filePath, entry.workspaceId);
|
|
631
|
+
this.remember(entry);
|
|
632
|
+
changed = true;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
if (changed) this.emit("change");
|
|
636
|
+
return changed;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
module.exports = {
|
|
641
|
+
LockTable,
|
|
642
|
+
DEFAULT_TTL_MS,
|
|
643
|
+
normalizeFile,
|
|
644
|
+
lockHash,
|
|
645
|
+
sameOwner,
|
|
646
|
+
normWorkspace,
|
|
647
|
+
workspaceRoot,
|
|
648
|
+
filterKeys,
|
|
649
|
+
};
|