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,364 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const http = require("http");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const { coordinatorStatePath } = require("../lib/paths");
|
|
5
|
+
const { loadRoom } = require("../lib/room");
|
|
6
|
+
|
|
7
|
+
const TOTAL_BUDGET_MS = 3000;
|
|
8
|
+
const HTTP_TIMEOUT_MS = 2000;
|
|
9
|
+
|
|
10
|
+
function readState(workspacePath) {
|
|
11
|
+
const files = [coordinatorStatePath(workspacePath), coordinatorStatePath(null)];
|
|
12
|
+
for (const file of files) {
|
|
13
|
+
try {
|
|
14
|
+
const state = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
15
|
+
if (state.port || state.apiPort) return state;
|
|
16
|
+
} catch {
|
|
17
|
+
/* next */
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readPort(workspacePath) {
|
|
24
|
+
const state = readState(workspacePath);
|
|
25
|
+
return state?.port ? Number(state.port) : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The API server owns the same in-memory lock table the dashboard renders, and
|
|
29
|
+
// its port is fixed, so it is the one target that cannot go stale. The
|
|
30
|
+
// coordinator's port is ephemeral and its state file is trusted blindly by
|
|
31
|
+
// readPort — a `relay serve` that dies after binding the coordinator but before
|
|
32
|
+
// binding the API leaves a pointer to a dead port behind.
|
|
33
|
+
function apiPort(workspacePath) {
|
|
34
|
+
if (process.env.RELAY_PORT) return Number(process.env.RELAY_PORT);
|
|
35
|
+
const state = readState(workspacePath);
|
|
36
|
+
return Number(state?.apiPort) || 3001;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A room whose host has gone away (closed laptop, expired tunnel) otherwise
|
|
40
|
+
// costs every hook a full HTTP timeout on each of claim/heartbeat/release, which
|
|
41
|
+
// reads to the user as "the agent froze".
|
|
42
|
+
const ROOM_COOLDOWN_MS = 30_000;
|
|
43
|
+
const roomFailedAt = new Map();
|
|
44
|
+
|
|
45
|
+
// Only a guest talks to the room. The host *is* the coordinator: routing its own
|
|
46
|
+
// hooks out through its ngrok tunnel and back adds a full internet round trip to
|
|
47
|
+
// every claim, and stalls the agent whenever the tunnel hiccups.
|
|
48
|
+
function roomUsable(room) {
|
|
49
|
+
if (!room?.url || room.role !== "guest") return false;
|
|
50
|
+
const failed = roomFailedAt.get(room.url);
|
|
51
|
+
return !failed || Date.now() - failed > ROOM_COOLDOWN_MS;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function markRoomDown(room) {
|
|
55
|
+
if (room?.url) roomFailedAt.set(room.url, Date.now());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The lock table namespaces every lock by absolute workspace path, so a guest
|
|
59
|
+
// claiming `src/auth.ts` under `D:\work\repo` and a host holding it under
|
|
60
|
+
// `/home/ana/repo` land in two different namespaces and never collide — the
|
|
61
|
+
// coordinator looks shared while arbitrating nothing. Rewriting the workspace to
|
|
62
|
+
// the host's path is what puts both users on one mutex.
|
|
63
|
+
// `path.resolve` alone is not enough on Windows: the same folder reaches us as
|
|
64
|
+
// both `C:\Users\PRAKRI~1\...` and `C:\Users\Prakrititz Borah\...` depending on
|
|
65
|
+
// who spawned the agent, and a mismatch here silently sends the claim to the
|
|
66
|
+
// guest's own namespace instead of the shared one.
|
|
67
|
+
function canonicalPath(value) {
|
|
68
|
+
if (!value) return "";
|
|
69
|
+
try {
|
|
70
|
+
return fs.realpathSync.native(value).replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
71
|
+
} catch {
|
|
72
|
+
return path.resolve(String(value)).replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function roomPayload(room, workspacePath, payload) {
|
|
77
|
+
if (room?.role !== "guest" || !room.hostWorkspacePath) return payload;
|
|
78
|
+
const local = canonicalPath(room.projectPath);
|
|
79
|
+
const here = canonicalPath(workspacePath);
|
|
80
|
+
// Antigravity (and some other agents) report a cwd that is the same repo on
|
|
81
|
+
// a different drive letter than the clone Relay joined. Still rewrite onto
|
|
82
|
+
// the host path so both machines share one lock namespace. Skip only when
|
|
83
|
+
// this is clearly a different folder with no room of its own.
|
|
84
|
+
if (local && here && local !== here && !here.startsWith(`${local}/`)) {
|
|
85
|
+
const hereRoom = loadRoom(workspacePath);
|
|
86
|
+
if (!hereRoom?.url || hereRoom.url !== room.url) return payload;
|
|
87
|
+
}
|
|
88
|
+
return { ...payload, workspaceId: room.hostWorkspacePath, peerWorkspacePath: workspacePath };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function postJson(port, pathname, body, timeoutMs) {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const data = JSON.stringify(body || {});
|
|
94
|
+
const req = http.request(
|
|
95
|
+
{
|
|
96
|
+
host: "127.0.0.1",
|
|
97
|
+
port,
|
|
98
|
+
path: pathname,
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: {
|
|
101
|
+
"Content-Type": "application/json",
|
|
102
|
+
"Content-Length": Buffer.byteLength(data),
|
|
103
|
+
},
|
|
104
|
+
timeout: timeoutMs,
|
|
105
|
+
},
|
|
106
|
+
(res) => {
|
|
107
|
+
let raw = "";
|
|
108
|
+
res.on("data", (c) => (raw += c));
|
|
109
|
+
res.on("end", () => {
|
|
110
|
+
try {
|
|
111
|
+
resolve({ status: res.statusCode, body: JSON.parse(raw || "{}") });
|
|
112
|
+
} catch (err) {
|
|
113
|
+
reject(err);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
);
|
|
118
|
+
req.on("timeout", () => {
|
|
119
|
+
req.destroy(new Error("timeout"));
|
|
120
|
+
});
|
|
121
|
+
req.on("error", reject);
|
|
122
|
+
req.write(data);
|
|
123
|
+
req.end();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function getJson(port, pathname, timeoutMs) {
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
const req = http.request(
|
|
130
|
+
{
|
|
131
|
+
host: "127.0.0.1",
|
|
132
|
+
port,
|
|
133
|
+
path: pathname,
|
|
134
|
+
method: "GET",
|
|
135
|
+
timeout: timeoutMs,
|
|
136
|
+
},
|
|
137
|
+
(res) => {
|
|
138
|
+
let raw = "";
|
|
139
|
+
res.on("data", (c) => (raw += c));
|
|
140
|
+
res.on("end", () => {
|
|
141
|
+
try {
|
|
142
|
+
resolve(JSON.parse(raw || "{}"));
|
|
143
|
+
} catch (err) {
|
|
144
|
+
reject(err);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
);
|
|
149
|
+
req.on("timeout", () => req.destroy(new Error("timeout")));
|
|
150
|
+
req.on("error", reject);
|
|
151
|
+
req.end();
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function lockfilePath(workspacePath, file) {
|
|
156
|
+
const crypto = require("crypto");
|
|
157
|
+
const { locksDirFor } = require("../lib/paths");
|
|
158
|
+
const hash = crypto.createHash("sha256").update(file).digest("hex").slice(0, 16);
|
|
159
|
+
return path.join(locksDirFor(workspacePath), `${hash}.lock`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function filesystemClaim(workspacePath, agentId, file, ttlMs) {
|
|
163
|
+
const lockPath = lockfilePath(workspacePath, file);
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
try {
|
|
166
|
+
fs.writeFileSync(
|
|
167
|
+
lockPath,
|
|
168
|
+
JSON.stringify({ filePath: file, agentId, claimedAt: now, ttlMs, source: "filesystem", workspaceId: workspacePath }),
|
|
169
|
+
{ flag: "wx" }
|
|
170
|
+
);
|
|
171
|
+
return { allowed: true, source: "filesystem" };
|
|
172
|
+
} catch {
|
|
173
|
+
try {
|
|
174
|
+
const existing = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
175
|
+
const { sameOwner } = require("./lockTable");
|
|
176
|
+
if (sameOwner(existing.agentId, agentId) || now >= existing.claimedAt + (existing.ttlMs || 15000)) {
|
|
177
|
+
fs.writeFileSync(
|
|
178
|
+
lockPath,
|
|
179
|
+
JSON.stringify({ filePath: file, agentId, claimedAt: now, ttlMs, source: "filesystem", workspaceId: workspacePath })
|
|
180
|
+
);
|
|
181
|
+
return { allowed: true, source: "filesystem" };
|
|
182
|
+
}
|
|
183
|
+
return { allowed: false, holder: existing.agentId, source: "filesystem" };
|
|
184
|
+
} catch {
|
|
185
|
+
return { allowed: true, source: "fail-open" };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function postRemote(url, pathname, body, timeoutMs, token) {
|
|
191
|
+
const ctrl = new AbortController();
|
|
192
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
193
|
+
try {
|
|
194
|
+
const res = await fetch(`${url.replace(/\/$/, "")}${pathname}`, {
|
|
195
|
+
method: "POST",
|
|
196
|
+
headers: {
|
|
197
|
+
"Content-Type": "application/json",
|
|
198
|
+
"ngrok-skip-browser-warning": "relay",
|
|
199
|
+
// An invite-only host rejects unauthenticated lock traffic; without the
|
|
200
|
+
// token every claim would silently fall back to local-only locking.
|
|
201
|
+
...(token ? { "x-relay-room-token": token } : {}),
|
|
202
|
+
},
|
|
203
|
+
body: JSON.stringify(body || {}),
|
|
204
|
+
signal: ctrl.signal,
|
|
205
|
+
});
|
|
206
|
+
return { status: res.status, body: await res.json().catch(() => ({})) };
|
|
207
|
+
} finally {
|
|
208
|
+
clearTimeout(timer);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function claimFile(workspacePath, agentId, file, ttlMs = 15000, opts = {}) {
|
|
213
|
+
const started = Date.now();
|
|
214
|
+
const room = loadRoom(workspacePath);
|
|
215
|
+
const payload = {
|
|
216
|
+
agentId,
|
|
217
|
+
file,
|
|
218
|
+
ttl: ttlMs,
|
|
219
|
+
workspaceId: workspacePath,
|
|
220
|
+
dependsOn: opts.dependsOn,
|
|
221
|
+
files: opts.files,
|
|
222
|
+
mode: opts.mode,
|
|
223
|
+
holder: opts.holder || null,
|
|
224
|
+
};
|
|
225
|
+
// Local API owns the control-plane socket. Guests must not wait on ngrok HTTP
|
|
226
|
+
// while chat snapshots are in flight — claims hop loopback, then a tiny WS.
|
|
227
|
+
// A `degraded` answer means the daemon could not reach the host, so it is not
|
|
228
|
+
// an answer at all — it must not short-circuit the ladder the way a real
|
|
229
|
+
// verdict does. Before this, the daemon's local-table grant came back as a
|
|
230
|
+
// plain `allowed: true` and stopped the search here, shadowing the room HTTP
|
|
231
|
+
// rung below: a dead control socket with a perfectly healthy tunnel never
|
|
232
|
+
// fell through to the wire that was still up.
|
|
233
|
+
let degraded = null;
|
|
234
|
+
try {
|
|
235
|
+
const remaining = Math.max(80, 800 - (Date.now() - started));
|
|
236
|
+
const { body } = await postJson(apiPort(workspacePath), "/api/coord/claim", roomPayload(room, workspacePath, payload), remaining);
|
|
237
|
+
if (body?.degraded) degraded = { ...body, source: body.source || "local_fallback" };
|
|
238
|
+
else if (body && typeof body.allowed === "boolean") return { ...body, source: body.source || "control" };
|
|
239
|
+
} catch {
|
|
240
|
+
/* coordinator / remote fallback */
|
|
241
|
+
}
|
|
242
|
+
if (roomUsable(room)) {
|
|
243
|
+
try {
|
|
244
|
+
const { body } = await postRemote(room.url, "/api/coord/claim", roomPayload(room, workspacePath, payload), HTTP_TIMEOUT_MS, room.memberToken);
|
|
245
|
+
return { ...body, source: body.source || "room" };
|
|
246
|
+
} catch {
|
|
247
|
+
markRoomDown(room);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// Every rung below this point is local to *this* machine: the coordinator
|
|
251
|
+
// process, the lock directory on this disk, and finally fail-open. None of
|
|
252
|
+
// them can adjudicate a lock that belongs to the room, so once we know we are
|
|
253
|
+
// a guest whose host is unreachable, consulting them would only launder an
|
|
254
|
+
// unanswered question into a confident yes. Refuse with the reason instead.
|
|
255
|
+
if (degraded) {
|
|
256
|
+
console.warn("[relay] HOST_UNREACHABLE — claim refused rather than granted locally");
|
|
257
|
+
return degraded;
|
|
258
|
+
}
|
|
259
|
+
const port = readPort(workspacePath);
|
|
260
|
+
if (port) {
|
|
261
|
+
try {
|
|
262
|
+
const remaining = Math.max(50, HTTP_TIMEOUT_MS - (Date.now() - started));
|
|
263
|
+
const { body } = await postJson(port, "/claim", payload, remaining);
|
|
264
|
+
return { ...body, source: body.source || "coordinator" };
|
|
265
|
+
} catch {
|
|
266
|
+
/* fall through */
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const remaining = TOTAL_BUDGET_MS - (Date.now() - started);
|
|
270
|
+
if (remaining > 0) {
|
|
271
|
+
try {
|
|
272
|
+
return filesystemClaim(workspacePath, agentId, file, ttlMs);
|
|
273
|
+
} catch {
|
|
274
|
+
/* fail open */
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
console.warn("[relay] COORDINATOR_UNAVAILABLE — fail-open");
|
|
278
|
+
return { allowed: true, source: "fail-open" };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function releaseFile(workspacePath, agentId, file) {
|
|
282
|
+
const room = loadRoom(workspacePath);
|
|
283
|
+
const payload = { agentId, file, workspaceId: workspacePath };
|
|
284
|
+
try {
|
|
285
|
+
const { body } = await postJson(apiPort(workspacePath), "/api/coord/release", roomPayload(room, workspacePath, payload), 600);
|
|
286
|
+
// A degraded release is already queued for replay by the daemon, but the
|
|
287
|
+
// tunnel may still be up even when the control socket is not — try it, and
|
|
288
|
+
// let the replay be a harmless no-op if this succeeds.
|
|
289
|
+
if (body && !body.degraded) return body;
|
|
290
|
+
} catch {
|
|
291
|
+
/* fallback */
|
|
292
|
+
}
|
|
293
|
+
if (roomUsable(room)) {
|
|
294
|
+
try {
|
|
295
|
+
const { body } = await postRemote(room.url, "/api/coord/release", roomPayload(room, workspacePath, payload), HTTP_TIMEOUT_MS, room.memberToken);
|
|
296
|
+
return body;
|
|
297
|
+
} catch {
|
|
298
|
+
markRoomDown(room);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return { ok: true };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function status(workspacePath) {
|
|
305
|
+
const port = readPort(workspacePath);
|
|
306
|
+
if (!port) return { locks: {} };
|
|
307
|
+
try {
|
|
308
|
+
return await getJson(port, "/status", HTTP_TIMEOUT_MS);
|
|
309
|
+
} catch {
|
|
310
|
+
return { locks: {} };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function releaseAll(workspacePath, agentId) {
|
|
315
|
+
const room = loadRoom(workspacePath);
|
|
316
|
+
const payload = { agentId, workspaceId: workspacePath };
|
|
317
|
+
if (roomUsable(room)) {
|
|
318
|
+
try {
|
|
319
|
+
const { body } = await postRemote(room.url, "/api/coord/release-all", roomPayload(room, workspacePath, payload), HTTP_TIMEOUT_MS, room.memberToken);
|
|
320
|
+
return body;
|
|
321
|
+
} catch {
|
|
322
|
+
markRoomDown(room);
|
|
323
|
+
/* local fallback */
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const port = readPort(workspacePath);
|
|
327
|
+
if (port) {
|
|
328
|
+
try {
|
|
329
|
+
const { body } = await postJson(port, "/release-all", payload, HTTP_TIMEOUT_MS);
|
|
330
|
+
return body;
|
|
331
|
+
} catch {
|
|
332
|
+
/* ignore */
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
const { body } = await postJson(apiPort(workspacePath), "/api/coord/release-all", payload, HTTP_TIMEOUT_MS);
|
|
337
|
+
return body;
|
|
338
|
+
} catch {
|
|
339
|
+
/* ignore */
|
|
340
|
+
}
|
|
341
|
+
return { released: [] };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function heartbeat(workspacePath, agentId, file) {
|
|
345
|
+
const room = loadRoom(workspacePath);
|
|
346
|
+
const payload = { agentId, file, workspaceId: workspacePath };
|
|
347
|
+
try {
|
|
348
|
+
const { body } = await postJson(apiPort(workspacePath), "/api/coord/heartbeat", roomPayload(room, workspacePath, payload), 400);
|
|
349
|
+
if (body) return body;
|
|
350
|
+
} catch {
|
|
351
|
+
/* fallback */
|
|
352
|
+
}
|
|
353
|
+
if (roomUsable(room)) {
|
|
354
|
+
try {
|
|
355
|
+
const { body } = await postRemote(room.url, "/api/coord/heartbeat", roomPayload(room, workspacePath, payload), HTTP_TIMEOUT_MS, room.memberToken);
|
|
356
|
+
return body;
|
|
357
|
+
} catch {
|
|
358
|
+
markRoomDown(room);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return { ok: false };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
module.exports = { claimFile, releaseFile, releaseAll, status, readPort, heartbeat, apiPort, postJson };
|