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,3894 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const http = require("http");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const express = require("express");
|
|
6
|
+
const cors = require("cors");
|
|
7
|
+
|
|
8
|
+
const { createCoordinator, listen, writeState } = require("./coordinator/server");
|
|
9
|
+
const { SseHub } = require("./lib/sseHub");
|
|
10
|
+
const {
|
|
11
|
+
loadRegistry,
|
|
12
|
+
saveRegistry,
|
|
13
|
+
loadSession,
|
|
14
|
+
saveSession,
|
|
15
|
+
loadMemory,
|
|
16
|
+
saveMemory,
|
|
17
|
+
id,
|
|
18
|
+
projectDir,
|
|
19
|
+
} = require("./lib/store");
|
|
20
|
+
const { seedIfEmpty, emptyMemory, initials } = require("./lib/seed");
|
|
21
|
+
const { buildDashboard } = require("./lib/dashboard");
|
|
22
|
+
const { homeRelayDir } = require("./lib/paths");
|
|
23
|
+
const { appendStopTurn } = require("./lib/memorySync");
|
|
24
|
+
const { detectConflicts } = require("./lib/conflicts");
|
|
25
|
+
const { createProject, loadProjects, resolveAuth } = require("./lib/centralAuth");
|
|
26
|
+
const { appendEvent, listEvents, jsonContext } = require("./lib/centralStore");
|
|
27
|
+
const { pushLocalEdits, pullCentralChanges } = require("./lib/centralSync");
|
|
28
|
+
const { graphFor, rebuild } = require("./lib/depGraph");
|
|
29
|
+
const { initTreeSitter } = require("./lib/treeSitterImports");
|
|
30
|
+
const { WorkspaceWatcher } = require("./lib/watchWorkspace");
|
|
31
|
+
const os = require("os");
|
|
32
|
+
const { computePatch, recordPatch, applyIncoming, applyPeerFile, rewindTo } = require("./lib/patches");
|
|
33
|
+
const { harvest } = require("./lib/harvest");
|
|
34
|
+
const { syncTranscriptsQueued, latestTranscriptMtime } = require("./lib/transcripts/sync");
|
|
35
|
+
const { transcriptWatchRoots, discoverAll } = require("./lib/transcripts/discover");
|
|
36
|
+
const { normalizeWorkspaceRoot, normalizeCompare } = require("./lib/transcripts/util");
|
|
37
|
+
const { listDir, listGithubRepos } = require("./lib/localBrowse");
|
|
38
|
+
const { ingest: ingestSupergraph } = require("./lib/supergraph");
|
|
39
|
+
const { cloneRepo, addLocal, detectRemoteUrl } = require("./lib/cloneRepo");
|
|
40
|
+
const { installProjectHooks, installGlobalHooks } = require("./lib/installHooks");
|
|
41
|
+
const { loadRoom, saveRoom, clearRoom, share: shareRoom, ensureTunnel, activeTunnelUrl, killCloudflared, upsertMember, markLeft, dropMember, membersView, detectProviders } = require("./lib/room");
|
|
42
|
+
const { localSnapshot, savePeerSnapshot, mergeRoomViews, clearPeers } = require("./lib/roomSync");
|
|
43
|
+
const { compileRoomAsk } = require("./lib/roomAsk");
|
|
44
|
+
const { recordCollision, mergeCollisionStats, loadLocalCollisions } = require("./lib/collisionMetrics");
|
|
45
|
+
const {
|
|
46
|
+
createInvite,
|
|
47
|
+
revokeInvite,
|
|
48
|
+
inviteView,
|
|
49
|
+
redeemInvite,
|
|
50
|
+
markAccepted,
|
|
51
|
+
issueMemberToken,
|
|
52
|
+
authorizeMember,
|
|
53
|
+
revokeMember,
|
|
54
|
+
publicRoom,
|
|
55
|
+
roomToken,
|
|
56
|
+
isRemoteRequest,
|
|
57
|
+
normLogin,
|
|
58
|
+
} = require("./lib/roomAuth");
|
|
59
|
+
const {
|
|
60
|
+
repoKeyFromRemote,
|
|
61
|
+
publishSignal,
|
|
62
|
+
findSignal,
|
|
63
|
+
discoverRooms,
|
|
64
|
+
createProof,
|
|
65
|
+
deleteGist,
|
|
66
|
+
verifyProof,
|
|
67
|
+
GIST_SCOPE_HINT,
|
|
68
|
+
} = require("./lib/roomSignal");
|
|
69
|
+
const { RoomLockMirror } = require("./lib/roomLocks");
|
|
70
|
+
const { RoomControl, sendSocket, broadcast } = require("./lib/roomControl");
|
|
71
|
+
const { handleRpc: mcpRpc } = require("./mcp/tools");
|
|
72
|
+
const { treeFiles, writeTree } = require("./lib/repoSnapshot");
|
|
73
|
+
const { whoami } = require("./lib/githubLogin");
|
|
74
|
+
const { fetchCollaborators: fetchGithubCollaborators, peekCollaborators } = require("./lib/githubRepo");
|
|
75
|
+
const { skipPublish, applyClash, aliveLock } = require("./lib/peerClash");
|
|
76
|
+
const { dirtyFiles } = require("./lib/dirtyFiles");
|
|
77
|
+
const { authorizeUrl, exchangeCode } = require("./lib/githubOAuth");
|
|
78
|
+
const {
|
|
79
|
+
findNotice,
|
|
80
|
+
listNotices,
|
|
81
|
+
unreadCount,
|
|
82
|
+
pushNotice,
|
|
83
|
+
markRead,
|
|
84
|
+
markAllRead,
|
|
85
|
+
dismissNotice,
|
|
86
|
+
dismissByKey,
|
|
87
|
+
} = require("./lib/notices");
|
|
88
|
+
const { WebSocketServer, WebSocket } = require("ws");
|
|
89
|
+
|
|
90
|
+
const VERSION = "0.1.0";
|
|
91
|
+
const UI_ORIGIN = process.env.RELAY_UI_ORIGIN || "http://localhost:3002";
|
|
92
|
+
|
|
93
|
+
function parsePort(argv) {
|
|
94
|
+
const idx = argv.indexOf("--port");
|
|
95
|
+
if (idx >= 0) return Number(argv[idx + 1]);
|
|
96
|
+
return Number(process.env.RELAY_PORT || 3001);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function requireCentral(req, res, next) {
|
|
100
|
+
const auth = resolveAuth(req.headers.authorization);
|
|
101
|
+
if (!auth.ok) return res.status(401).json({ error: "unauthorized" });
|
|
102
|
+
req.central = auth;
|
|
103
|
+
next();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function main() {
|
|
107
|
+
await initTreeSitter();
|
|
108
|
+
seedIfEmpty();
|
|
109
|
+
const app = express();
|
|
110
|
+
app.use(
|
|
111
|
+
cors({
|
|
112
|
+
origin: [
|
|
113
|
+
UI_ORIGIN,
|
|
114
|
+
"http://127.0.0.1:3002",
|
|
115
|
+
"http://localhost:3000",
|
|
116
|
+
"http://127.0.0.1:3000",
|
|
117
|
+
/\.trycloudflare\.com$/,
|
|
118
|
+
/\.ts\.net$/,
|
|
119
|
+
/\.vercel\.app$/,
|
|
120
|
+
/\.up\.railway\.app$/,
|
|
121
|
+
/\.onrender\.com$/,
|
|
122
|
+
/\.fly\.dev$/,
|
|
123
|
+
/\.ngrok(-free)?\.app$/,
|
|
124
|
+
/\.ngrok\.io$/,
|
|
125
|
+
],
|
|
126
|
+
credentials: true,
|
|
127
|
+
})
|
|
128
|
+
);
|
|
129
|
+
// Hosted public demo guard (#14): when RELAY_PUBLIC_READONLY=1 is set, any
|
|
130
|
+
// non-loopback request may only read (GET/HEAD/OPTIONS, read-only /api/graph/query/*,
|
|
131
|
+
// or POST /mcp which already enforces READ_ONLY for remote callers).
|
|
132
|
+
if (process.env.RELAY_PUBLIC_READONLY === "1" || process.env.RELAY_PUBLIC_READONLY === "true") {
|
|
133
|
+
app.use((req, res, next) => {
|
|
134
|
+
if (!isRemoteRequest(req)) return next();
|
|
135
|
+
if (req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS") return next();
|
|
136
|
+
if (req.path === "/mcp" || req.path.startsWith("/api/graph/query/")) return next();
|
|
137
|
+
return res.status(403).json({ ok: false, error: "public_readonly", reason: "Hosted demo instance is read-only for remote callers." });
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
// A peer's chat history is far larger than any other request body. Mounting
|
|
141
|
+
// its parser first means the 2mb default below sees an already-parsed body and
|
|
142
|
+
// skips it, so one big route does not raise the ceiling for every other.
|
|
143
|
+
app.use("/api/room/history", express.json({ limit: "32mb" }));
|
|
144
|
+
app.use(express.json({ limit: "2mb" }));
|
|
145
|
+
|
|
146
|
+
// Visibility into hook traffic: without this, a hook that fails silently
|
|
147
|
+
// (fail-open by design) leaves zero trace that it ever fired, which makes
|
|
148
|
+
// "did the coordinator even see this?" impossible to answer from the
|
|
149
|
+
// terminal running `relay serve`.
|
|
150
|
+
const HOOK_ROUTES = new Set([
|
|
151
|
+
"/api/ensure-workspace",
|
|
152
|
+
"/api/ingest-stop",
|
|
153
|
+
"/api/flush-file",
|
|
154
|
+
"/api/flush-owned",
|
|
155
|
+
"/api/coord/claim",
|
|
156
|
+
"/api/coord/release",
|
|
157
|
+
"/api/coord/release-all",
|
|
158
|
+
"/api/coord/heartbeat",
|
|
159
|
+
]);
|
|
160
|
+
app.use((req, res, next) => {
|
|
161
|
+
if (!HOOK_ROUTES.has(req.path)) return next();
|
|
162
|
+
const body = req.body || {};
|
|
163
|
+
const ws = body.workspacePath || body.workspaceId || "";
|
|
164
|
+
const detail = [
|
|
165
|
+
body.agentId ? `agent=${body.agentId}` : "",
|
|
166
|
+
body.file ? `file=${body.file}` : "",
|
|
167
|
+
Array.isArray(body.files) && body.files.length ? `files=${body.files.join(",")}` : "",
|
|
168
|
+
]
|
|
169
|
+
.filter(Boolean)
|
|
170
|
+
.join(" ");
|
|
171
|
+
const started = Date.now();
|
|
172
|
+
res.on("finish", () => {
|
|
173
|
+
console.log(
|
|
174
|
+
`[relay-hook] ${req.method} ${req.path} -> ${res.statusCode} (${Date.now() - started}ms) ` +
|
|
175
|
+
`workspace=${ws}${detail ? ` ${detail}` : ""}`
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
next();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const sse = new SseHub();
|
|
182
|
+
try {
|
|
183
|
+
if (process.env.RELAY_SKIP_GLOBAL_HOOKS !== "1") installGlobalHooks();
|
|
184
|
+
} catch {
|
|
185
|
+
/* global configs are best-effort */
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const { app: coordApp, server: coordServer, lockTable, presence } = createCoordinator({
|
|
189
|
+
workspacePath: process.cwd(),
|
|
190
|
+
preferredPort: process.env.RELAY_COORDINATOR_PORT || 0,
|
|
191
|
+
});
|
|
192
|
+
const coordPort = await listen(coordServer, Number(process.env.RELAY_COORDINATOR_PORT || 0));
|
|
193
|
+
coordApp.set("port", coordPort);
|
|
194
|
+
require("./lib/falkor").init(app, {
|
|
195
|
+
lockTable,
|
|
196
|
+
sse,
|
|
197
|
+
projectForPath: (p) => projectForPath(p),
|
|
198
|
+
currentUser: () => currentUser(),
|
|
199
|
+
requireRoomMember: (req, res, next) => requireRoomMember(req, res, next),
|
|
200
|
+
fetchHost: (url, pathname, init) => fetchHost(url, pathname, init),
|
|
201
|
+
});
|
|
202
|
+
// NOTE: coordinator state is published only once the API port is bound (see
|
|
203
|
+
// server.listen below). Writing it here means a second `relay serve` that
|
|
204
|
+
// dies on EADDRINUSE still overwrites the live instance's pointer with a port
|
|
205
|
+
// that is about to disappear, and every hook then silently degrades to
|
|
206
|
+
// filesystem-only locking.
|
|
207
|
+
function lockScope(projectId) {
|
|
208
|
+
const project =
|
|
209
|
+
loadRegistry().projects.find(
|
|
210
|
+
(p) => p.id === projectId || normalizeCompare(p.path) === normalizeCompare(projectId)
|
|
211
|
+
) || (projectId ? null : currentProject());
|
|
212
|
+
if (!project) return null;
|
|
213
|
+
const room = loadRoom(project.path) || loadRoom();
|
|
214
|
+
const aliases = [];
|
|
215
|
+
if (
|
|
216
|
+
room &&
|
|
217
|
+
(room.hostProjectId === project.id ||
|
|
218
|
+
room.projectId === project.id ||
|
|
219
|
+
normalizeCompare(room.projectPath) === normalizeCompare(project.path) ||
|
|
220
|
+
normalizeCompare(room.hostWorkspacePath) === normalizeCompare(project.path))
|
|
221
|
+
) {
|
|
222
|
+
aliases.push(room.hostWorkspacePath, room.projectPath, room.hostProjectId, room.projectId);
|
|
223
|
+
}
|
|
224
|
+
return { id: project.id, path: project.path, aliases: aliases.filter(Boolean) };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
lockTable.on("change", (evt) => {
|
|
228
|
+
const ws = evt?.entry?.workspaceId || evt?.workspaceId;
|
|
229
|
+
const room = loadRoom();
|
|
230
|
+
const project = ws
|
|
231
|
+
? loadRegistry().projects.find((p) => normalizeCompare(p.path) === normalizeCompare(ws)) ||
|
|
232
|
+
loadRegistry().projects.find((p) => p.id === room?.hostProjectId)
|
|
233
|
+
: null;
|
|
234
|
+
// A guest's locks are namespaced under the HOST's workspace path, so no
|
|
235
|
+
// local project matches and this fell through to `locks: []` — with no
|
|
236
|
+
// workspaceId to scope it, Mission Control applied that blank to whatever
|
|
237
|
+
// it was showing and the Coordinator board emptied itself. Resolve the room
|
|
238
|
+
// workspace instead, and publish the same merged view every other reader
|
|
239
|
+
// gets rather than the raw local table.
|
|
240
|
+
const scope = project
|
|
241
|
+
? lockScope(project.id)
|
|
242
|
+
: room?.role === "guest" && room.projectId
|
|
243
|
+
? lockScope(room.projectId) || { id: room.projectId, path: room.projectPath, aliases: [room.hostWorkspacePath] }
|
|
244
|
+
: null;
|
|
245
|
+
if (!scope?.id) return;
|
|
246
|
+
const locks = locksFor(scope);
|
|
247
|
+
const reads = readsFor(scope);
|
|
248
|
+
sse.emit("locks", { workspaceId: scope.id, locks, reads });
|
|
249
|
+
// Control plane only — never share a TCP stream with chat snapshots.
|
|
250
|
+
broadcastLocks(locks, reads);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const watcher = new WorkspaceWatcher();
|
|
254
|
+
const applyingRemote = new Set();
|
|
255
|
+
let wss = null;
|
|
256
|
+
let wssControl = null;
|
|
257
|
+
const roomControl = new RoomControl();
|
|
258
|
+
|
|
259
|
+
function publishGraph(project) {
|
|
260
|
+
if (!project?.path) return;
|
|
261
|
+
const g = rebuild(project.path);
|
|
262
|
+
const snap = g ? g.snapshot() : { edges: [] };
|
|
263
|
+
ingestSupergraph(project.id, { userId: loadSession().login, edges: snap.edges || [] });
|
|
264
|
+
sse.emit("graph", { workspaceId: project.id, graph: snap });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Returns null rather than falling back to the currently selected project: a
|
|
268
|
+
// hook that could not report its workspace must not have its edits filed
|
|
269
|
+
// under whichever workspace happens to be open in Mission Control.
|
|
270
|
+
function ensureWorkspace(workspacePath) {
|
|
271
|
+
const resolved = normalizeWorkspaceRoot(workspacePath) || workspacePath;
|
|
272
|
+
if (!resolved) return null;
|
|
273
|
+
const reg = loadRegistry();
|
|
274
|
+
let project = reg.projects.find((p) => normalizeCompare(p.path) === normalizeCompare(resolved));
|
|
275
|
+
if (!project) {
|
|
276
|
+
project = reg.projects.find(
|
|
277
|
+
(p) => normalizeCompare(normalizeWorkspaceRoot(p.path) || p.path) === normalizeCompare(resolved)
|
|
278
|
+
);
|
|
279
|
+
if (project && project.path !== resolved) {
|
|
280
|
+
project.path = resolved;
|
|
281
|
+
saveRegistry(reg);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (!project) {
|
|
285
|
+
project = {
|
|
286
|
+
id: id("proj"),
|
|
287
|
+
name: path.basename(resolved),
|
|
288
|
+
initials: initials(resolved),
|
|
289
|
+
color: "#34d399",
|
|
290
|
+
path: resolved,
|
|
291
|
+
remoteUrl: null,
|
|
292
|
+
apiKey: "rl_" + require("crypto").randomBytes(12).toString("hex"),
|
|
293
|
+
createdAt: Date.now(),
|
|
294
|
+
lastSyncAt: Date.now(),
|
|
295
|
+
};
|
|
296
|
+
try {
|
|
297
|
+
fs.mkdirSync(path.join(resolved, ".relay"), { recursive: true });
|
|
298
|
+
installProjectHooks(resolved);
|
|
299
|
+
} catch {
|
|
300
|
+
/* ignore */
|
|
301
|
+
}
|
|
302
|
+
reg.projects.push(project);
|
|
303
|
+
saveRegistry(reg);
|
|
304
|
+
saveMemory(project.id, emptyMemory());
|
|
305
|
+
sse.emit("projects", { projects: reg.projects });
|
|
306
|
+
}
|
|
307
|
+
watcher.watch(project.path, project.id);
|
|
308
|
+
lockTable.track(project.path);
|
|
309
|
+
watchTranscripts(project);
|
|
310
|
+
return project;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
let hostSocket = null;
|
|
314
|
+
// A guest that stops retrying stops receiving the host's edits, silently — and
|
|
315
|
+
// one dropped tunnel request is all it takes. Back off instead of giving up,
|
|
316
|
+
// and cap so a host that is genuinely gone is not hammered.
|
|
317
|
+
let hostBackoffMs = 200;
|
|
318
|
+
let hostRetryTimer = null;
|
|
319
|
+
const HOST_BACKOFF_MAX_MS = 8_000;
|
|
320
|
+
// Filled in once the room helpers below exist. Until then a retry just
|
|
321
|
+
// reconnects to the URL already on disk.
|
|
322
|
+
let refreshGuestRoom = async (room) => room;
|
|
323
|
+
|
|
324
|
+
/** An existing socket already open, or still connecting, to this exact URL. */
|
|
325
|
+
function socketLive(sock, wsUrl) {
|
|
326
|
+
return Boolean(
|
|
327
|
+
sock && sock.relayUrl === wsUrl && (sock.readyState === WebSocket.OPEN || sock.readyState === WebSocket.CONNECTING)
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function connectHostPatches(room) {
|
|
332
|
+
if (!room?.url || room.role !== "guest") return;
|
|
333
|
+
const wsUrl = `${room.url.replace(/^http/, "ws").replace(/\/$/, "")}/ws/patches${
|
|
334
|
+
room.memberToken ? `?token=${encodeURIComponent(room.memberToken)}` : ""
|
|
335
|
+
}`;
|
|
336
|
+
if (socketLive(hostSocket, wsUrl)) return;
|
|
337
|
+
if (hostRetryTimer) {
|
|
338
|
+
clearTimeout(hostRetryTimer);
|
|
339
|
+
hostRetryTimer = null;
|
|
340
|
+
}
|
|
341
|
+
if (hostSocket) {
|
|
342
|
+
try {
|
|
343
|
+
hostSocket.close();
|
|
344
|
+
} catch {
|
|
345
|
+
/* ignore */
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
let socket;
|
|
349
|
+
try {
|
|
350
|
+
socket = new WebSocket(wsUrl, {
|
|
351
|
+
headers: {
|
|
352
|
+
"ngrok-skip-browser-warning": "relay",
|
|
353
|
+
...(room.memberToken ? { "x-relay-room-token": room.memberToken } : {}),
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
socket.relayUrl = wsUrl;
|
|
357
|
+
hostSocket = socket;
|
|
358
|
+
} catch (err) {
|
|
359
|
+
console.warn(`[relay-room] could not open ${wsUrl}: ${err.message || err}`);
|
|
360
|
+
scheduleRetry();
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
let retried = false;
|
|
364
|
+
function scheduleRetry() {
|
|
365
|
+
if (retried || (socket && hostSocket !== socket)) return;
|
|
366
|
+
retried = true;
|
|
367
|
+
const wait = hostBackoffMs;
|
|
368
|
+
hostBackoffMs = Math.min(HOST_BACKOFF_MAX_MS, Math.round(hostBackoffMs * 1.8));
|
|
369
|
+
hostRetryTimer = setTimeout(() => {
|
|
370
|
+
hostRetryTimer = null;
|
|
371
|
+
const retry = async () => {
|
|
372
|
+
let latest = loadRoom();
|
|
373
|
+
if (latest?.role !== "guest") return;
|
|
374
|
+
latest = (await refreshGuestRoom(latest)) || latest;
|
|
375
|
+
connectHostPatches(latest);
|
|
376
|
+
connectHostControl(latest);
|
|
377
|
+
};
|
|
378
|
+
retry().catch(() => undefined);
|
|
379
|
+
}, wait);
|
|
380
|
+
if (hostRetryTimer.unref) hostRetryTimer.unref();
|
|
381
|
+
}
|
|
382
|
+
hostSocket.on("open", () => {
|
|
383
|
+
hostBackoffMs = 200;
|
|
384
|
+
try {
|
|
385
|
+
hostSocket.send(JSON.stringify({ type: "join" }));
|
|
386
|
+
} catch {
|
|
387
|
+
/* first snapshot arrives on connect from the host anyway */
|
|
388
|
+
}
|
|
389
|
+
console.log(`[relay-room] streaming room from ${room.url}`);
|
|
390
|
+
});
|
|
391
|
+
// Without an 'error' listener, ws's EventEmitter rethrows connection
|
|
392
|
+
// failures (e.g. a stale/offline host returning a non-101 response)
|
|
393
|
+
// as an uncaught exception and kills the whole `relay serve` process.
|
|
394
|
+
hostSocket.on("error", (err) => {
|
|
395
|
+
if (hostSocket !== socket) return;
|
|
396
|
+
console.warn(`[relay-room] host connection failed (${wsUrl}): ${err.message || err}`);
|
|
397
|
+
scheduleRetry();
|
|
398
|
+
});
|
|
399
|
+
hostSocket.on("message", (raw) => {
|
|
400
|
+
try {
|
|
401
|
+
const msg = JSON.parse(String(raw));
|
|
402
|
+
if (msg.type === "pong" || msg.type === "ping") {
|
|
403
|
+
if (msg.type === "ping") {
|
|
404
|
+
try {
|
|
405
|
+
hostSocket.send(JSON.stringify({ type: "pong" }));
|
|
406
|
+
} catch {
|
|
407
|
+
/* closed */
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (msg.type === "tunnel" && msg.url) {
|
|
413
|
+
const current = loadRoom();
|
|
414
|
+
if (current?.role === "guest" && msg.url !== current.url) {
|
|
415
|
+
saveRoom({ ...current, url: msg.url }, current.projectPath);
|
|
416
|
+
connectHostPatches(loadRoom());
|
|
417
|
+
connectHostControl(loadRoom());
|
|
418
|
+
}
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (msg.type === "locks") {
|
|
422
|
+
const current = loadRoom();
|
|
423
|
+
if (msg.workspaceId && current?.hostProjectId && msg.workspaceId !== current.hostProjectId) return;
|
|
424
|
+
roomLocks.accept(msg.locks);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (msg.type === "state" || msg.type === "hello") {
|
|
428
|
+
const project = currentProject();
|
|
429
|
+
const current = loadRoom();
|
|
430
|
+
if (project && current?.hostProjectId) {
|
|
431
|
+
savePeerSnapshot(project.id, "host", msg, msg.hostLogin || current.hostLogin || "host");
|
|
432
|
+
sse.emit("history", {
|
|
433
|
+
workspaceId: project.id,
|
|
434
|
+
lastSyncAt: Date.now(),
|
|
435
|
+
chats: msg.chats,
|
|
436
|
+
timeline: msg.timeline,
|
|
437
|
+
activity: msg.activity,
|
|
438
|
+
edits: msg.edits,
|
|
439
|
+
agents: msg.agents,
|
|
440
|
+
from: msg.hostLogin,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (msg.type !== "patch" || !msg.patch) return;
|
|
446
|
+
const result = acceptPeerPatch(msg.patch, {
|
|
447
|
+
remoteUrl: msg.remoteUrl,
|
|
448
|
+
name: msg.name,
|
|
449
|
+
pathName: msg.pathName,
|
|
450
|
+
}, { skipIfOrigin: true });
|
|
451
|
+
// The guest wrote the file but told nobody: without this the teammate's
|
|
452
|
+
// edit only surfaced on the next full refetch, which is exactly the
|
|
453
|
+
// "live edits never show up" report.
|
|
454
|
+
if (result?.recorded && result.projectId) {
|
|
455
|
+
sse.emit("patch", {
|
|
456
|
+
workspaceId: result.projectId,
|
|
457
|
+
patch: msg.patch,
|
|
458
|
+
incoming: true,
|
|
459
|
+
edit: editRowFor(msg.patch, false),
|
|
460
|
+
originId: msg.patch.originId,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
} catch {
|
|
464
|
+
/* ignore */
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
hostSocket.on("close", scheduleRetry);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
let controlSocket = null;
|
|
471
|
+
let controlRetryTimer = null;
|
|
472
|
+
let controlBackoffMs = 200;
|
|
473
|
+
|
|
474
|
+
function connectHostControl(room) {
|
|
475
|
+
if (!room?.url || room.role !== "guest") return;
|
|
476
|
+
const wsUrl = `${room.url.replace(/^http/, "ws").replace(/\/$/, "")}/ws/control${
|
|
477
|
+
room.memberToken ? `?token=${encodeURIComponent(room.memberToken)}` : ""
|
|
478
|
+
}`;
|
|
479
|
+
// Startup, guest-room refresh and the patches socket's retry all call this. Tearing
|
|
480
|
+
// down a socket that is already open or still connecting to the same host meant
|
|
481
|
+
// the control channel was closed before it could ever finish its handshake.
|
|
482
|
+
if (socketLive(controlSocket, wsUrl)) return;
|
|
483
|
+
if (controlRetryTimer) {
|
|
484
|
+
clearTimeout(controlRetryTimer);
|
|
485
|
+
controlRetryTimer = null;
|
|
486
|
+
}
|
|
487
|
+
if (controlSocket) {
|
|
488
|
+
try {
|
|
489
|
+
controlSocket.close();
|
|
490
|
+
} catch {
|
|
491
|
+
/* ignore */
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
let socket;
|
|
495
|
+
try {
|
|
496
|
+
socket = new WebSocket(wsUrl, {
|
|
497
|
+
headers: {
|
|
498
|
+
"ngrok-skip-browser-warning": "relay",
|
|
499
|
+
...(room.memberToken ? { "x-relay-room-token": room.memberToken } : {}),
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
socket.relayUrl = wsUrl;
|
|
503
|
+
controlSocket = socket;
|
|
504
|
+
} catch (err) {
|
|
505
|
+
console.warn(`[relay-room] control socket failed: ${err.message || err}`);
|
|
506
|
+
scheduleControlRetry();
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
roomControl.attach(controlSocket);
|
|
510
|
+
let retried = false;
|
|
511
|
+
function scheduleControlRetry() {
|
|
512
|
+
// A socket that has already been replaced must not schedule a reconnect: that
|
|
513
|
+
// reconnect would close the current, healthy socket and loop forever.
|
|
514
|
+
if (retried || (socket && controlSocket !== socket)) return;
|
|
515
|
+
retried = true;
|
|
516
|
+
const wait = controlBackoffMs;
|
|
517
|
+
controlBackoffMs = Math.min(HOST_BACKOFF_MAX_MS, Math.round(controlBackoffMs * 1.8));
|
|
518
|
+
controlRetryTimer = setTimeout(() => {
|
|
519
|
+
controlRetryTimer = null;
|
|
520
|
+
const latest = loadRoom();
|
|
521
|
+
if (latest?.role === "guest") {
|
|
522
|
+
refreshGuestRoom(latest)
|
|
523
|
+
.then((next) => connectHostControl(next || latest))
|
|
524
|
+
.catch(() => connectHostControl(latest));
|
|
525
|
+
}
|
|
526
|
+
}, wait);
|
|
527
|
+
if (controlRetryTimer.unref) controlRetryTimer.unref();
|
|
528
|
+
}
|
|
529
|
+
controlSocket.on("open", () => {
|
|
530
|
+
if (controlSocket !== socket) return;
|
|
531
|
+
controlBackoffMs = 200;
|
|
532
|
+
roomControl.send({ type: "join" });
|
|
533
|
+
console.log(`[relay-room] control stream ${room.url}`);
|
|
534
|
+
// Join first, then settle up: the host has to know who we are before it
|
|
535
|
+
// will accept a release from us.
|
|
536
|
+
if (roomControl.queued) {
|
|
537
|
+
const pending = roomControl.queued;
|
|
538
|
+
roomControl
|
|
539
|
+
.flush()
|
|
540
|
+
.then(({ replayed, rejected, requeued }) => {
|
|
541
|
+
console.log(
|
|
542
|
+
`[relay-room] replayed ${replayed.length}/${pending} queued lock ${
|
|
543
|
+
pending === 1 ? "mutation" : "mutations"
|
|
544
|
+
}${rejected.length ? `, ${rejected.length} rejected` : ""}${
|
|
545
|
+
requeued.length ? `, ${requeued.length} held for the next reconnect` : ""
|
|
546
|
+
}`
|
|
547
|
+
);
|
|
548
|
+
})
|
|
549
|
+
.catch((err) => console.warn(`[relay-room] replay failed: ${err.message || err}`));
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
controlSocket.on("error", (err) => {
|
|
553
|
+
if (controlSocket !== socket) return;
|
|
554
|
+
console.warn(`[relay-room] control failed: ${err.message || err}`);
|
|
555
|
+
scheduleControlRetry();
|
|
556
|
+
});
|
|
557
|
+
controlSocket.on("message", (raw) => {
|
|
558
|
+
try {
|
|
559
|
+
const msg = JSON.parse(String(raw));
|
|
560
|
+
if (msg.type === "ping") {
|
|
561
|
+
roomControl.send({ type: "pong" });
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (roomControl.handle(msg)) return;
|
|
565
|
+
} catch {
|
|
566
|
+
/* ignore */
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
controlSocket.on("close", scheduleControlRetry);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Push the room's lock table to every joined guest.
|
|
574
|
+
*
|
|
575
|
+
* Only a host has anything to say here: it owns the one table the whole room
|
|
576
|
+
* arbitrates against. Guests receive this and hand it to their mirror, so the
|
|
577
|
+
* board they draw is the host's table itself rather than a periodic guess at
|
|
578
|
+
* it.
|
|
579
|
+
*/
|
|
580
|
+
function broadcastLocks(locks, reads) {
|
|
581
|
+
const room = loadRoom();
|
|
582
|
+
if (room?.role !== "host" || !room.url) return;
|
|
583
|
+
const project = currentProject();
|
|
584
|
+
const scope = lockScope(room.hostProjectId || project?.id);
|
|
585
|
+
broadcast(wssControl, {
|
|
586
|
+
type: "locks",
|
|
587
|
+
workspaceId: room.hostProjectId || project?.id || null,
|
|
588
|
+
locks,
|
|
589
|
+
reads: Array.isArray(reads) ? reads : readsFor(scope),
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function broadcastRoom(obj) {
|
|
594
|
+
broadcast(wss, obj);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function hostWireOpen() {
|
|
598
|
+
return Boolean(hostSocket && hostSocket.readyState === WebSocket.OPEN);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function sendToHost(obj) {
|
|
602
|
+
if (!hostWireOpen()) return false;
|
|
603
|
+
try {
|
|
604
|
+
hostSocket.send(JSON.stringify(obj));
|
|
605
|
+
return true;
|
|
606
|
+
} catch {
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function roomStatePayload(project, type = "state") {
|
|
612
|
+
const memory = loadMemory(project.id);
|
|
613
|
+
const view = mergeRoomViews(memory);
|
|
614
|
+
const login = currentUser().login || "local";
|
|
615
|
+
return {
|
|
616
|
+
type,
|
|
617
|
+
hostLogin: login,
|
|
618
|
+
projectId: project.id,
|
|
619
|
+
lastTranscriptSyncAt: memory.lastTranscriptSyncAt || 0,
|
|
620
|
+
chats: view.chats,
|
|
621
|
+
timeline: view.timeline,
|
|
622
|
+
activity: view.activity,
|
|
623
|
+
edits: view.edits,
|
|
624
|
+
agents: view.agents,
|
|
625
|
+
stats: memory.stats || {},
|
|
626
|
+
roomCollisions: mergeCollisionStats(memory, { localLogin: login, projectId: project.id }),
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
let stateTick = null;
|
|
631
|
+
let lastChatBulkAt = 0;
|
|
632
|
+
function queueRoomState(project) {
|
|
633
|
+
if (!project) return;
|
|
634
|
+
clearTimeout(stateTick);
|
|
635
|
+
stateTick = setTimeout(() => {
|
|
636
|
+
setImmediate(() => {
|
|
637
|
+
const room = loadRoom();
|
|
638
|
+
if (room?.role !== "host" || !room.url) return;
|
|
639
|
+
const payload = roomStatePayload(project);
|
|
640
|
+
const includeChats = Date.now() - lastChatBulkAt > 900;
|
|
641
|
+
if (includeChats) lastChatBulkAt = Date.now();
|
|
642
|
+
else delete payload.chats;
|
|
643
|
+
broadcastRoom(payload);
|
|
644
|
+
setImmediate(() => sse.emit("history", { workspaceId: project.id, lastSyncAt: Date.now(), ...payload }));
|
|
645
|
+
});
|
|
646
|
+
}, 40);
|
|
647
|
+
if (stateTick.unref) stateTick.unref();
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function ingestPeerHistory(login, snapshot, project) {
|
|
651
|
+
if (!login || !project) return;
|
|
652
|
+
savePeerSnapshot(project.id, login, snapshot, login);
|
|
653
|
+
queueRoomState(project);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* The activity row a patch produces, shaped exactly like the ones
|
|
658
|
+
* `recordPatch`/`applyPeerFile` write into memory.edits.
|
|
659
|
+
*
|
|
660
|
+
* The dashboard renders `edits`, not `patches`, so a stream that carried only
|
|
661
|
+
* the patch left the feed frozen until the next full refetch — an agent's work
|
|
662
|
+
* was landing on disk while the UI insisted nothing had happened.
|
|
663
|
+
*/
|
|
664
|
+
function editRowFor(patch, mine) {
|
|
665
|
+
return {
|
|
666
|
+
id: patch.id,
|
|
667
|
+
agent: patch.agent,
|
|
668
|
+
ownerLogin: patch.ownerLogin,
|
|
669
|
+
mine,
|
|
670
|
+
file: patch.file,
|
|
671
|
+
ts: patch.ts,
|
|
672
|
+
diff: patch.diff || `sha256:${patch.sha256}`,
|
|
673
|
+
lamport: patch.lamport,
|
|
674
|
+
binary: patch.binary,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* The dashboard's patch list, without the payload nobody renders.
|
|
680
|
+
*
|
|
681
|
+
* A stored patch carries `content` — the whole file body — so it can be
|
|
682
|
+
* replayed onto disk. Mission Control only ever reads the row fields (it types
|
|
683
|
+
* `patches` as CodeEdit), but the route shipped the raw records: 2.2 MB of the
|
|
684
|
+
* 2.9 MB response, taking seconds to serialise and send. Every streamed
|
|
685
|
+
* `history` event asks the UI to refetch that, so the transport was live while
|
|
686
|
+
* the thing it triggered was not — which is what made streaming look broken.
|
|
687
|
+
*/
|
|
688
|
+
const MAX_DIFF_BYTES = 8000;
|
|
689
|
+
|
|
690
|
+
function patchRows(patches) {
|
|
691
|
+
const rows = [];
|
|
692
|
+
for (const p of patches.slice(-PATCH_ROWS)) {
|
|
693
|
+
const diff = typeof p.diff === "string" ? p.diff : "";
|
|
694
|
+
rows.push({
|
|
695
|
+
id: p.id,
|
|
696
|
+
agent: p.agent,
|
|
697
|
+
ownerLogin: p.ownerLogin,
|
|
698
|
+
mine: p.mine,
|
|
699
|
+
file: p.file,
|
|
700
|
+
ts: p.ts,
|
|
701
|
+
lamport: p.lamport,
|
|
702
|
+
binary: p.binary,
|
|
703
|
+
diff: diff.length > MAX_DIFF_BYTES ? `${diff.slice(0, MAX_DIFF_BYTES)}\n… truncated` : diff,
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
return rows;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const PATCH_ROWS = 100;
|
|
710
|
+
|
|
711
|
+
function broadcastPatch(payload) {
|
|
712
|
+
const edit = payload.patch ? editRowFor(payload.patch, !payload.incoming) : null;
|
|
713
|
+
sse.emit("patch", edit ? { ...payload, edit } : payload);
|
|
714
|
+
broadcastRoom({ type: "patch", ...payload });
|
|
715
|
+
if (payload.patch) notifyWaiters(payload);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Hand the finished file to whoever was told to wait for it.
|
|
720
|
+
*
|
|
721
|
+
* An agent refused a claim was left to poll or simply give up, so it carried
|
|
722
|
+
* on against a stale copy of a file that had since changed underneath it —
|
|
723
|
+
* the exact divergence locking exists to prevent. The moment the edit lands,
|
|
724
|
+
* everyone who wanted that file learns it is free and what changed.
|
|
725
|
+
*/
|
|
726
|
+
function notifyWaiters({ workspaceId, patch }) {
|
|
727
|
+
const project = loadRegistry().projects.find((p) => p.id === workspaceId);
|
|
728
|
+
if (!project || !patch?.file) return;
|
|
729
|
+
const waiters = lockTable.takeWaiters(patch.file, project.path);
|
|
730
|
+
if (!waiters.length) return;
|
|
731
|
+
const item = {
|
|
732
|
+
workspaceId: project.id,
|
|
733
|
+
file: patch.file,
|
|
734
|
+
lamport: patch.lamport,
|
|
735
|
+
sha256: patch.sha256,
|
|
736
|
+
by: patch.agent,
|
|
737
|
+
waiters: waiters.map((w) => w.agentId),
|
|
738
|
+
ts: Date.now(),
|
|
739
|
+
};
|
|
740
|
+
sse.emit("file-ready", item);
|
|
741
|
+
broadcastRoom({ type: "file-ready", ...item });
|
|
742
|
+
emitNotice(
|
|
743
|
+
{
|
|
744
|
+
type: "file-ready",
|
|
745
|
+
key: `file-ready:${project.id}:${patch.file}`,
|
|
746
|
+
title: `${patch.file} is free`,
|
|
747
|
+
body: `${patch.agent} finished editing it — ${waiters.length} agent(s) were waiting.`,
|
|
748
|
+
workspaceId: project.id,
|
|
749
|
+
payload: { file: patch.file, lamport: patch.lamport },
|
|
750
|
+
},
|
|
751
|
+
{ revive: true }
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function fanOutLocalFile(project, file, originAgentId) {
|
|
756
|
+
if (!project?.path || !file) return { flushed: false };
|
|
757
|
+
const rel = file.replace(/\\/g, "/");
|
|
758
|
+
const session = loadSession();
|
|
759
|
+
const patch = computePatch({
|
|
760
|
+
workspacePath: project.path,
|
|
761
|
+
file: rel,
|
|
762
|
+
agent: "fs.watch",
|
|
763
|
+
ownerLogin: session.login || "local",
|
|
764
|
+
workspaceId: project.id,
|
|
765
|
+
});
|
|
766
|
+
if (!patch) return { flushed: false };
|
|
767
|
+
// Second half of the echo guard in applyPatchToDisk: a watcher event whose
|
|
768
|
+
// content matches the newest patch we already know for this file is our own
|
|
769
|
+
// write coming back, not a new edit worth sending to the room.
|
|
770
|
+
const known = (loadMemory(project.id).patches || []).filter((p) => p.file === rel).pop();
|
|
771
|
+
if (known && known.sha256 === patch.sha256) return { flushed: false, unchanged: true, file: rel };
|
|
772
|
+
patch.originId = originId();
|
|
773
|
+
patch.originAgentId = originAgentId || originId();
|
|
774
|
+
recordPatch(project.id, patch);
|
|
775
|
+
const envelope = {
|
|
776
|
+
workspaceId: project.id,
|
|
777
|
+
patch,
|
|
778
|
+
remoteUrl: project.remoteUrl,
|
|
779
|
+
name: project.name,
|
|
780
|
+
pathName: path.basename(project.path),
|
|
781
|
+
originId: patch.originId,
|
|
782
|
+
};
|
|
783
|
+
broadcastPatch(envelope);
|
|
784
|
+
const room = loadRoom(project.path);
|
|
785
|
+
if (room?.url) {
|
|
786
|
+
const who = patch.ownerLogin ? `@${patch.ownerLogin}` : "local";
|
|
787
|
+
console.log(`[relay-patch] sent to room (${room.role}): ${rel} from ${who}`);
|
|
788
|
+
}
|
|
789
|
+
if (room?.url && room.role !== "host") {
|
|
790
|
+
const body = { ...envelope, workspaceId: room.hostProjectId || envelope.workspaceId, type: "patch" };
|
|
791
|
+
if (!sendToHost(body)) {
|
|
792
|
+
fetch(`${room.url.replace(/\/$/, "")}/api/patches`, {
|
|
793
|
+
method: "POST",
|
|
794
|
+
headers: {
|
|
795
|
+
"Content-Type": "application/json",
|
|
796
|
+
"ngrok-skip-browser-warning": "relay",
|
|
797
|
+
...(room.memberToken ? { "x-relay-room-token": room.memberToken } : {}),
|
|
798
|
+
},
|
|
799
|
+
body: JSON.stringify(body),
|
|
800
|
+
})
|
|
801
|
+
.then((r) => {
|
|
802
|
+
if (!r.ok) console.warn(`[relay-room] host rejected patch for ${rel}: ${r.status}`);
|
|
803
|
+
})
|
|
804
|
+
.catch((err) => console.warn(`[relay-room] could not send patch for ${rel}: ${err.message || err}`));
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
const memory = loadMemory(project.id);
|
|
808
|
+
pushLocalEdits(project.path, project.id, memory).then(() => pullCentralChanges(project.path)).catch(() => undefined);
|
|
809
|
+
publishGraph(project);
|
|
810
|
+
return { flushed: true, file: rel };
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function projectForPath(workspacePath) {
|
|
814
|
+
if (!workspacePath) return null;
|
|
815
|
+
return (
|
|
816
|
+
loadRegistry().projects.find((p) => normalizeCompare(p.path) === normalizeCompare(workspacePath)) ||
|
|
817
|
+
ensureWorkspace(workspacePath)
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Watcher events parked behind a live local lock, replayed once the holder
|
|
822
|
+
// lets go. fanOutLocalFile is idempotent (it drops a patch whose sha matches
|
|
823
|
+
// the newest one recorded for the file), so a post-tool flush that did arrive
|
|
824
|
+
// simply makes the replay a no-op.
|
|
825
|
+
const deferredEdits = new Map();
|
|
826
|
+
const DEFERRED_MAX_MS = 5 * 60 * 1000;
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Trailing-edge, not leading-edge.
|
|
830
|
+
*
|
|
831
|
+
* This used to publish the first event for a file and drop every further one
|
|
832
|
+
* inside a 40ms window. An agent that writes a file in more than one chunk —
|
|
833
|
+
* which the fast Gemini-class models do constantly — therefore published a
|
|
834
|
+
* snapshot taken mid-write and then threw away the event carrying the
|
|
835
|
+
* finished content. There is no later event to correct it, so the edit either
|
|
836
|
+
* never appeared or appeared truncated. Waiting for the file to stop changing
|
|
837
|
+
* costs one debounce interval and always publishes the final state.
|
|
838
|
+
*/
|
|
839
|
+
const WATCH_SETTLE_MS = 60;
|
|
840
|
+
const watchTimers = new Map();
|
|
841
|
+
|
|
842
|
+
watcher.on("change", (evt) => {
|
|
843
|
+
const key = `${evt.workspacePath}:${evt.file}`;
|
|
844
|
+
clearTimeout(watchTimers.get(key));
|
|
845
|
+
watchTimers.set(
|
|
846
|
+
key,
|
|
847
|
+
setTimeout(() => {
|
|
848
|
+
watchTimers.delete(key);
|
|
849
|
+
onFileSettled(evt);
|
|
850
|
+
}, WATCH_SETTLE_MS)
|
|
851
|
+
);
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
function onFileSettled({ workspacePath, workspaceId, file }) {
|
|
855
|
+
const now = Date.now();
|
|
856
|
+
if (workspaceId && applyingRemote.has(workspaceId)) return;
|
|
857
|
+
const project =
|
|
858
|
+
loadRegistry().projects.find((p) => p.id === workspaceId || p.path === workspacePath) ||
|
|
859
|
+
ensureWorkspace(workspacePath);
|
|
860
|
+
if (!project) return;
|
|
861
|
+
if (applyingRemote.has(project.id) || applyingRemote.has(`${project.id}:${file}`)) return;
|
|
862
|
+
const rel = file.replace(/\\/g, "/");
|
|
863
|
+
const publish = skipPublish(lockTable, project.path, rel);
|
|
864
|
+
if (publish.skip) {
|
|
865
|
+
bumpCollision(project.id, "patch_skipped", {
|
|
866
|
+
file: rel,
|
|
867
|
+
holder: publish.held?.agentId,
|
|
868
|
+
reason: publish.reason,
|
|
869
|
+
});
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
const held = publish.held;
|
|
873
|
+
if (held) lockTable.heartbeat({ agentId: held.agentId, file: rel, workspaceId: project.path });
|
|
874
|
+
if (publish.defer) {
|
|
875
|
+
// Deferring is correct — a file being written under a live local lock is
|
|
876
|
+
// still mid-edit, and the agent's post-tool hook publishes the finished
|
|
877
|
+
// result. But that made publication conditional on a hook we do not
|
|
878
|
+
// control: if PostToolUse never fires (or fires with a payload shape we
|
|
879
|
+
// cannot read a path out of), the watcher event is consumed here, the
|
|
880
|
+
// lock quietly expires, and the edit is never published at all. Remember
|
|
881
|
+
// it instead and let the lock's own release or expiry release the edit.
|
|
882
|
+
deferredEdits.set(`${project.id}:${rel}`, { project, rel, agentId: held?.agentId || null, at: now });
|
|
883
|
+
bumpCollision(project.id, "patch_deferred", { file: rel, agentId: held?.agentId });
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
fanOutLocalFile(project, rel, held?.agentId);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const deferredSweep = setInterval(() => {
|
|
890
|
+
if (!deferredEdits.size) return;
|
|
891
|
+
const now = Date.now();
|
|
892
|
+
for (const [key, entry] of [...deferredEdits]) {
|
|
893
|
+
const stale = now - entry.at > DEFERRED_MAX_MS;
|
|
894
|
+
if (!stale && aliveLock(lockTable, entry.rel, entry.project.path)) continue;
|
|
895
|
+
deferredEdits.delete(key);
|
|
896
|
+
if (stale) continue;
|
|
897
|
+
try {
|
|
898
|
+
fanOutLocalFile(entry.project, entry.rel, entry.agentId);
|
|
899
|
+
} catch (err) {
|
|
900
|
+
console.warn(`[relay] deferred publish ${entry.rel}: ${err.message || err}`);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}, 750);
|
|
904
|
+
if (deferredSweep.unref) deferredSweep.unref();
|
|
905
|
+
|
|
906
|
+
const transcriptWatchers = new Map();
|
|
907
|
+
|
|
908
|
+
// A transcript sync is fully synchronous and parses every transcript the
|
|
909
|
+
// workspace owns — tens of megabytes for a long-lived project. Agents rewrite
|
|
910
|
+
// their transcript continuously while a turn is in flight, so without a floor
|
|
911
|
+
// between passes the server spends every tick re-parsing the same files and
|
|
912
|
+
// never gets back to serving HTTP or flushing sockets.
|
|
913
|
+
const INGEST_COOLDOWN_MS = 2500;
|
|
914
|
+
const lastIngest = new Map();
|
|
915
|
+
const ingestAgain = new Set();
|
|
916
|
+
|
|
917
|
+
// Activity is a transcript log, so it appears as soon as the jsonl is parsed.
|
|
918
|
+
// Coordinator only painted live claims, and PostToolUse releases those in
|
|
919
|
+
// ~100ms — the board looked empty while the feed was already full. Mirror
|
|
920
|
+
// recent transcript edits onto the same afterglow the lock table keeps for
|
|
921
|
+
// released claims, so both tabs share one trail. This is display-only:
|
|
922
|
+
// claim() never sees remembered rows.
|
|
923
|
+
function observeTranscriptEdits(project, memory) {
|
|
924
|
+
const cutoff = Date.now() - 45_000;
|
|
925
|
+
let touched = 0;
|
|
926
|
+
for (const edit of memory?.edits || []) {
|
|
927
|
+
if (!edit?.file || (edit.ts || 0) < cutoff) continue;
|
|
928
|
+
lockTable.remember({
|
|
929
|
+
filePath: String(edit.file).replace(/\\/g, "/"),
|
|
930
|
+
agentId: `${edit.agent}:${os.hostname()}:transcript`,
|
|
931
|
+
mode: "write",
|
|
932
|
+
claimedAt: edit.ts,
|
|
933
|
+
lastHeartbeat: edit.ts,
|
|
934
|
+
ttlMs: 15_000,
|
|
935
|
+
workspaceId: project.path,
|
|
936
|
+
holder: { label: edit.agent, login: edit.ownerLogin || null },
|
|
937
|
+
source: "transcript",
|
|
938
|
+
releasedAt: edit.ts,
|
|
939
|
+
});
|
|
940
|
+
touched += 1;
|
|
941
|
+
}
|
|
942
|
+
if (touched) lockTable.emit("change");
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function ingestTranscripts(project) {
|
|
946
|
+
const since = Date.now() - (lastIngest.get(project.id) || 0);
|
|
947
|
+
if (since < INGEST_COOLDOWN_MS) {
|
|
948
|
+
// Coalesce rather than drop: the newest write still gets ingested, just on
|
|
949
|
+
// the trailing edge instead of once per filesystem event.
|
|
950
|
+
if (!ingestAgain.has(project.id)) {
|
|
951
|
+
ingestAgain.add(project.id);
|
|
952
|
+
setTimeout(() => {
|
|
953
|
+
ingestAgain.delete(project.id);
|
|
954
|
+
ingestTranscripts(project);
|
|
955
|
+
}, INGEST_COOLDOWN_MS - since).unref?.();
|
|
956
|
+
}
|
|
957
|
+
return Promise.resolve();
|
|
958
|
+
}
|
|
959
|
+
lastIngest.set(project.id, Date.now());
|
|
960
|
+
return syncTranscriptsQueued(project, { ownerLogin: loadSession().login || "local" })
|
|
961
|
+
.then((memory) => {
|
|
962
|
+
observeTranscriptEdits(project, memory);
|
|
963
|
+
// Carry the refreshed views on the event itself. A bare notification
|
|
964
|
+
// makes the client turn around and refetch the whole dashboard, so the
|
|
965
|
+
// "stream" was really just a trigger for a multi-megabyte poll.
|
|
966
|
+
sse.emit("history", {
|
|
967
|
+
workspaceId: project.id,
|
|
968
|
+
lastSyncAt: Date.now(),
|
|
969
|
+
chats: memory?.chats,
|
|
970
|
+
timeline: memory?.timeline,
|
|
971
|
+
activity: memory?.activity,
|
|
972
|
+
edits: memory?.edits,
|
|
973
|
+
agents: memory?.agents,
|
|
974
|
+
});
|
|
975
|
+
// Push as soon as the transcript settles rather than waiting for the
|
|
976
|
+
// next dashboard poll, so a teammate sees the turn land in seconds.
|
|
977
|
+
const room = loadRoom(project.path);
|
|
978
|
+
if (room?.role === "host") queueRoomState(project);
|
|
979
|
+
return pushRoomHistory(room, project);
|
|
980
|
+
})
|
|
981
|
+
.catch(() => undefined);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* A brand-new workspace has no transcript directory yet: Cursor and
|
|
986
|
+
* Antigravity only create theirs on the first turn. fs.watch cannot watch a
|
|
987
|
+
* path that does not exist, so a one-shot arm at boot leaves that workspace
|
|
988
|
+
* permanently silent — the "my edits never show up on the new repo" symptom.
|
|
989
|
+
* Re-arming lets a root that appears later still get picked up.
|
|
990
|
+
*/
|
|
991
|
+
function watchTranscripts(project) {
|
|
992
|
+
if (!project?.path) return;
|
|
993
|
+
const state = transcriptWatchers.get(project.id) || { roots: new Set(), timer: null };
|
|
994
|
+
transcriptWatchers.set(project.id, state);
|
|
995
|
+
for (const root of transcriptWatchRoots(project.path)) {
|
|
996
|
+
state.roots.add(root);
|
|
997
|
+
armTranscriptRoot(root);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* One recursive watch per directory, shared by every workspace interested in
|
|
1003
|
+
* it.
|
|
1004
|
+
*
|
|
1005
|
+
* Most transcript roots are not workspace-specific: `~/.codex/sessions` and
|
|
1006
|
+
* Antigravity's `brain/` hold every conversation on the machine, so all eight
|
|
1007
|
+
* registered workspaces resolved to the same two directories and each armed
|
|
1008
|
+
* its own watcher — 23 handles over 9 directories. A single Antigravity write
|
|
1009
|
+
* then fanned out to eight independent transcript syncs, each re-parsing that
|
|
1010
|
+
* workspace's transcripts from scratch. That is the stall: Antigravity writes
|
|
1011
|
+
* its transcript continuously while answering, so the server spent all of its
|
|
1012
|
+
* time re-parsing and none of it serving the dashboard or the lock stream.
|
|
1013
|
+
*/
|
|
1014
|
+
const transcriptRootHandles = new Map();
|
|
1015
|
+
|
|
1016
|
+
function armTranscriptRoot(root) {
|
|
1017
|
+
if (transcriptRootHandles.has(root)) return;
|
|
1018
|
+
let handle;
|
|
1019
|
+
try {
|
|
1020
|
+
handle = fs.watch(root, { recursive: true }, (_evt, filename) => {
|
|
1021
|
+
onTranscriptRootChange(root, filename);
|
|
1022
|
+
});
|
|
1023
|
+
} catch {
|
|
1024
|
+
return; // directory not there yet — the sweep retries
|
|
1025
|
+
}
|
|
1026
|
+
handle.on("error", () => {
|
|
1027
|
+
try {
|
|
1028
|
+
handle.close();
|
|
1029
|
+
} catch {
|
|
1030
|
+
/* already gone */
|
|
1031
|
+
}
|
|
1032
|
+
transcriptRootHandles.delete(root);
|
|
1033
|
+
});
|
|
1034
|
+
transcriptRootHandles.set(root, handle);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function onTranscriptRootChange(root, filename) {
|
|
1038
|
+
const changed = filename ? path.resolve(root, filename) : null;
|
|
1039
|
+
for (const [projectId, state] of transcriptWatchers) {
|
|
1040
|
+
if (!state.roots.has(root)) continue;
|
|
1041
|
+
const project = loadRegistry().projects.find((p) => p.id === projectId);
|
|
1042
|
+
if (!project) continue;
|
|
1043
|
+
// A shared root fires for conversations belonging to other workspaces.
|
|
1044
|
+
// Ingesting all of them on every event is what multiplied the cost;
|
|
1045
|
+
// ownsTranscript keeps a write charged to the workspace it belongs to.
|
|
1046
|
+
if (changed && !ownsTranscript(project, changed)) continue;
|
|
1047
|
+
clearTimeout(state.timer);
|
|
1048
|
+
state.timer = setTimeout(() => ingestTranscripts(project), 150);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Cheap membership test against the transcript set discovery already computed,
|
|
1053
|
+
// refreshed lazily so a brand-new conversation still gets picked up.
|
|
1054
|
+
const OWNERSHIP_TTL_MS = 10000;
|
|
1055
|
+
const ownership = new Map();
|
|
1056
|
+
|
|
1057
|
+
function ownsTranscript(project, absFile) {
|
|
1058
|
+
const hit = ownership.get(project.id);
|
|
1059
|
+
let files = hit && Date.now() - hit.at < OWNERSHIP_TTL_MS ? hit.files : null;
|
|
1060
|
+
if (!files) {
|
|
1061
|
+
try {
|
|
1062
|
+
files = new Set(
|
|
1063
|
+
discoverAll(normalizeWorkspaceRoot(project.path) || project.path).map((f) => path.resolve(f.file))
|
|
1064
|
+
);
|
|
1065
|
+
} catch {
|
|
1066
|
+
files = new Set();
|
|
1067
|
+
}
|
|
1068
|
+
ownership.set(project.id, { files, at: Date.now() });
|
|
1069
|
+
}
|
|
1070
|
+
// Unknown file: it may be a conversation that started moments ago, so let it
|
|
1071
|
+
// through once and let the refreshed set decide next time.
|
|
1072
|
+
if (files.has(absFile)) return true;
|
|
1073
|
+
if (hit && Date.now() - hit.at < OWNERSHIP_TTL_MS) return false;
|
|
1074
|
+
return true;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
for (const p of loadRegistry().projects) {
|
|
1078
|
+
if (p.path) {
|
|
1079
|
+
watcher.watch(p.path, p.id);
|
|
1080
|
+
lockTable.track(p.path);
|
|
1081
|
+
}
|
|
1082
|
+
watchTranscripts(p);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// Watchers are the fast path, not the only one. This sweep re-arms roots that
|
|
1086
|
+
// have since been created and ingests when a transcript moved but no watch
|
|
1087
|
+
// event arrived (network drives, editors that write via rename, a agent whose
|
|
1088
|
+
// storage directory did not exist when the workspace was registered).
|
|
1089
|
+
// Every pass costs a full transcript discovery per workspace — hundreds of
|
|
1090
|
+
// milliseconds of synchronous stat/read work across the registry. At 3s that
|
|
1091
|
+
// was a fifth of the event loop spent on a path that only exists to cover
|
|
1092
|
+
// events fs.watch missed. The deduped watchers above are the fast path; this
|
|
1093
|
+
// only has to be eventually-correct.
|
|
1094
|
+
const transcriptSweep = setInterval(() => {
|
|
1095
|
+
for (const project of loadRegistry().projects) {
|
|
1096
|
+
if (!project.path) continue;
|
|
1097
|
+
lockTable.track(project.path);
|
|
1098
|
+
watchTranscripts(project);
|
|
1099
|
+
try {
|
|
1100
|
+
const seen = loadMemory(project.id).lastTranscriptSyncAt || 0;
|
|
1101
|
+
const root = normalizeWorkspaceRoot(project.path) || project.path;
|
|
1102
|
+
if (latestTranscriptMtime(root) > seen) ingestTranscripts(project);
|
|
1103
|
+
} catch {
|
|
1104
|
+
/* keep sweeping the other workspaces */
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}, 10000);
|
|
1108
|
+
if (transcriptSweep.unref) transcriptSweep.unref();
|
|
1109
|
+
try {
|
|
1110
|
+
installProjectHooks(process.cwd());
|
|
1111
|
+
} catch {
|
|
1112
|
+
/* ignore */
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function originId() {
|
|
1116
|
+
const session = loadSession();
|
|
1117
|
+
return `${os.hostname()}:${session.login || "local"}`;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function peerProject(hint = {}) {
|
|
1121
|
+
const reg = loadRegistry();
|
|
1122
|
+
// A joined room pins the workspace incoming patches belong to. Without this
|
|
1123
|
+
// the chain below falls through to currentProject(), and a guest whose
|
|
1124
|
+
// dashboard happens to be on a different repo has the host's files written
|
|
1125
|
+
// into it.
|
|
1126
|
+
const room = loadRoom();
|
|
1127
|
+
if (room?.projectId) {
|
|
1128
|
+
const pinned = reg.projects.find((p) => p.id === room.projectId);
|
|
1129
|
+
if (pinned) return pinned;
|
|
1130
|
+
}
|
|
1131
|
+
const byRemote = hint.remoteUrl && reg.projects.find((p) => p.remoteUrl && p.remoteUrl === hint.remoteUrl);
|
|
1132
|
+
if (byRemote) return byRemote;
|
|
1133
|
+
const byName = hint.name && reg.projects.find((p) => p.name === hint.name);
|
|
1134
|
+
if (byName) return byName;
|
|
1135
|
+
if (hint.pathName) {
|
|
1136
|
+
const byBase = reg.projects.find((p) => path.basename(p.path) === hint.pathName);
|
|
1137
|
+
if (byBase) return byBase;
|
|
1138
|
+
}
|
|
1139
|
+
return currentProject();
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function logPeerPatch(patch, result) {
|
|
1143
|
+
if (result?.self) return;
|
|
1144
|
+
const who = patch.ownerLogin ? `@${patch.ownerLogin}` : patch.originId || "teammate";
|
|
1145
|
+
const via = patch.agent && patch.agent !== "fs.watch" ? ` via ${patch.agent}` : "";
|
|
1146
|
+
if (result?.applied) {
|
|
1147
|
+
console.log(`[relay-patch] teammate edit applied: ${patch.file} from ${who}${via} — written to disk`);
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
const why = result?.duplicate
|
|
1151
|
+
? "already applied"
|
|
1152
|
+
: result?.reason === "identical"
|
|
1153
|
+
? "already on disk (same content)"
|
|
1154
|
+
: result?.reason || "not written";
|
|
1155
|
+
console.log(`[relay-patch] teammate edit received, not written: ${patch.file} from ${who}${via} — ${why}`);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function acceptPeerPatch(patch, hint, { skipIfOrigin } = {}) {
|
|
1159
|
+
if (!patch?.file) return { applied: false };
|
|
1160
|
+
if (skipIfOrigin && patch.originId && patch.originId === originId()) {
|
|
1161
|
+
return { applied: false, self: true };
|
|
1162
|
+
}
|
|
1163
|
+
const project = peerProject(hint);
|
|
1164
|
+
if (!project) {
|
|
1165
|
+
const result = { applied: false, reason: "no_local_repo" };
|
|
1166
|
+
logPeerPatch(patch, result);
|
|
1167
|
+
return result;
|
|
1168
|
+
}
|
|
1169
|
+
const clash = applyClash(lockTable, project.path, patch.file, patch);
|
|
1170
|
+
if (clash) {
|
|
1171
|
+
if (project?.id) {
|
|
1172
|
+
bumpCollision(project.id, "patch_blocked", {
|
|
1173
|
+
file: patch.file,
|
|
1174
|
+
holder: clash.holder,
|
|
1175
|
+
agent: patch.agent,
|
|
1176
|
+
reason: clash.reason,
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
const result = { applied: false, projectId: project.id, ...clash };
|
|
1180
|
+
logPeerPatch(patch, result);
|
|
1181
|
+
return result;
|
|
1182
|
+
}
|
|
1183
|
+
applyingRemote.add(`${project.id}:${patch.file}`);
|
|
1184
|
+
const result = { ...applyPeerFile(project.id, patch, project.path), projectId: project.id };
|
|
1185
|
+
applyingRemote.delete(`${project.id}:${patch.file}`);
|
|
1186
|
+
logPeerPatch(patch, result);
|
|
1187
|
+
return result;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
const currentProject = () => {
|
|
1191
|
+
const reg = loadRegistry();
|
|
1192
|
+
const session = loadSession();
|
|
1193
|
+
return reg.projects.find((p) => p.id === session.projectId) || reg.projects[0] || null;
|
|
1194
|
+
};
|
|
1195
|
+
|
|
1196
|
+
function currentUser() {
|
|
1197
|
+
const { session, user } = whoami();
|
|
1198
|
+
if (user) {
|
|
1199
|
+
return {
|
|
1200
|
+
id: user.id,
|
|
1201
|
+
login: user.login,
|
|
1202
|
+
name: user.name || user.login,
|
|
1203
|
+
avatarUrl: user.avatarUrl || `https://github.com/${user.login}.png`,
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
const login = session.login || os.userInfo().username || "local";
|
|
1207
|
+
return { id: `local_${login}`, login, name: login, avatarUrl: "" };
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// Every dashboard poll fans out to the room host. Without a deadline, one
|
|
1211
|
+
// unreachable tunnel stalls the request the UI refreshes on every 8s, and the
|
|
1212
|
+
// whole dashboard reads as "not loading".
|
|
1213
|
+
const HOST_TIMEOUT_MS = 2500;
|
|
1214
|
+
// A full-tree seed is megabytes over a tunnel; the dashboard deadline is far
|
|
1215
|
+
// too short for it.
|
|
1216
|
+
const SEED_TIMEOUT_MS = 120_000;
|
|
1217
|
+
|
|
1218
|
+
async function fetchHost(url, pathname, init = {}) {
|
|
1219
|
+
// Every room request carries the member token the host minted at redeem
|
|
1220
|
+
// time; without it an invite-only host answers 401.
|
|
1221
|
+
const token = init.token === null ? null : init.token || loadRoom()?.memberToken;
|
|
1222
|
+
const res = await fetch(`${String(url).replace(/\/$/, "")}${pathname}`, {
|
|
1223
|
+
...init,
|
|
1224
|
+
signal: AbortSignal.timeout(init.timeoutMs || HOST_TIMEOUT_MS),
|
|
1225
|
+
headers: {
|
|
1226
|
+
"Content-Type": "application/json",
|
|
1227
|
+
"ngrok-skip-browser-warning": "relay",
|
|
1228
|
+
...(token ? { "x-relay-room-token": token } : {}),
|
|
1229
|
+
...(init.headers || {}),
|
|
1230
|
+
},
|
|
1231
|
+
});
|
|
1232
|
+
if (!res.ok) {
|
|
1233
|
+
const err = new Error(`${pathname} ${res.status}`);
|
|
1234
|
+
err.status = res.status;
|
|
1235
|
+
throw err;
|
|
1236
|
+
}
|
|
1237
|
+
return res;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
const sameLogin = (a, b) => Boolean(a) && Boolean(b) && String(a).toLowerCase() === String(b).toLowerCase();
|
|
1241
|
+
|
|
1242
|
+
/**
|
|
1243
|
+
* Team tab roster: GitHub collaborators are the stable rows. Room presence
|
|
1244
|
+
* overlays on top by login. Kick/leave drop the room overlay, never the
|
|
1245
|
+
* GitHub identity.
|
|
1246
|
+
*/
|
|
1247
|
+
function overlayTeam(ghList, roomMembers) {
|
|
1248
|
+
const roomByLogin = new Map();
|
|
1249
|
+
for (const m of roomMembers || []) {
|
|
1250
|
+
const key = String(m?.login || "").toLowerCase();
|
|
1251
|
+
if (key) roomByLogin.set(key, m);
|
|
1252
|
+
}
|
|
1253
|
+
const seen = new Set();
|
|
1254
|
+
const rows = [];
|
|
1255
|
+
for (const gh of ghList || []) {
|
|
1256
|
+
const key = String(gh.login || "").toLowerCase();
|
|
1257
|
+
if (!key || seen.has(key)) continue;
|
|
1258
|
+
seen.add(key);
|
|
1259
|
+
const live = roomByLogin.get(key);
|
|
1260
|
+
const inRoom = Boolean(live);
|
|
1261
|
+
rows.push({
|
|
1262
|
+
id: gh.id || live?.id || `gh_${gh.login}`,
|
|
1263
|
+
login: gh.login,
|
|
1264
|
+
name: (inRoom && live.name) || gh.name || gh.login,
|
|
1265
|
+
avatarUrl: (inRoom && live.avatarUrl) || gh.avatarUrl || "",
|
|
1266
|
+
permission: gh.permission || null,
|
|
1267
|
+
source: "github",
|
|
1268
|
+
inRoom,
|
|
1269
|
+
role: inRoom ? live.role : undefined,
|
|
1270
|
+
online: inRoom ? Boolean(live.online) : false,
|
|
1271
|
+
agentActive: inRoom ? Boolean(live.agentActive) : false,
|
|
1272
|
+
agentLabel: inRoom ? live.agentLabel || null : null,
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
for (const live of roomMembers || []) {
|
|
1276
|
+
const key = String(live.login || "").toLowerCase();
|
|
1277
|
+
if (!key || seen.has(key)) continue;
|
|
1278
|
+
seen.add(key);
|
|
1279
|
+
rows.push({
|
|
1280
|
+
...live,
|
|
1281
|
+
source: "room",
|
|
1282
|
+
inRoom: true,
|
|
1283
|
+
online: Boolean(live.online),
|
|
1284
|
+
agentActive: Boolean(live.agentActive),
|
|
1285
|
+
agentLabel: live.agentLabel || null,
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
return rows;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/**
|
|
1292
|
+
* Is this person a collaborator on the repo being shared?
|
|
1293
|
+
*
|
|
1294
|
+
* `verified: false` means we could not find out — no GitHub remote, `gh` not
|
|
1295
|
+
* installed or not authed. It does NOT mean "not a collaborator", and the
|
|
1296
|
+
* caller must not treat it as a rejection: the invite code is itself a
|
|
1297
|
+
* host-issued, single-use, expiring credential, so an unverifiable repo
|
|
1298
|
+
* degrades to invite-only rather than becoming unjoinable.
|
|
1299
|
+
*
|
|
1300
|
+
* The public contributors fallback in githubRepo is deliberately not accepted
|
|
1301
|
+
* as proof here — it is activity-derived public data, not an access list.
|
|
1302
|
+
*/
|
|
1303
|
+
async function collaboratorCheck(project, login) {
|
|
1304
|
+
try {
|
|
1305
|
+
const list = await fetchGithubCollaborators(project?.remoteUrl);
|
|
1306
|
+
const authoritative = list.filter((c) => c.source === "collaborators");
|
|
1307
|
+
if (!authoritative.length) return { verified: false, allowed: true, reason: "unverifiable" };
|
|
1308
|
+
const match = authoritative.find((c) => sameLogin(c.login, login));
|
|
1309
|
+
return match
|
|
1310
|
+
? { verified: true, allowed: true, permission: match.permission || null }
|
|
1311
|
+
: { verified: true, allowed: false, reason: "not_a_collaborator" };
|
|
1312
|
+
} catch {
|
|
1313
|
+
return { verified: false, allowed: true, reason: "lookup_failed" };
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// Guards everything a room exposes through the tunnel. Local Mission Control
|
|
1318
|
+
// and local hooks reach the same routes over loopback and are left alone, so
|
|
1319
|
+
// solo use is unaffected by any of this.
|
|
1320
|
+
function requireRoomMember(req, res, next) {
|
|
1321
|
+
const room = loadRoom();
|
|
1322
|
+
const hosting = Boolean(room?.url) && room.role === "host";
|
|
1323
|
+
// Identify the caller from its credential whenever one is present, even on a
|
|
1324
|
+
// loopback request. Downstream scoping depends on knowing "this is a peer",
|
|
1325
|
+
// and inferring that from proxy headers alone is a guess; the token is proof.
|
|
1326
|
+
if (hosting) {
|
|
1327
|
+
const member = authorizeMember(room, roomToken(req));
|
|
1328
|
+
if (member) req.roomMember = member;
|
|
1329
|
+
}
|
|
1330
|
+
if (!isRemoteRequest(req)) return next();
|
|
1331
|
+
if (!hosting) return next();
|
|
1332
|
+
if (room.open) return next();
|
|
1333
|
+
if (!req.roomMember) {
|
|
1334
|
+
return res.status(401).json({
|
|
1335
|
+
error: "not_a_member",
|
|
1336
|
+
hint: "This room is invite-only. Ask the host to send you an invite link.",
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
return next();
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function activeAgent(projectId) {
|
|
1343
|
+
const memory = loadMemory(projectId);
|
|
1344
|
+
const live = (memory.agents || [])
|
|
1345
|
+
.filter((a) => a.status === "connected" && Date.now() - (a.lastActiveAt || 0) < 120_000)
|
|
1346
|
+
.sort((a, b) => (b.lastActiveAt || 0) - (a.lastActiveAt || 0))[0];
|
|
1347
|
+
return live ? { agentActive: true, agentLabel: live.label } : { agentActive: false, agentLabel: null };
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
async function announceToHost(room, user = currentUser()) {
|
|
1351
|
+
if (!room?.url || room.role !== "guest") return null;
|
|
1352
|
+
const res = await fetchHost(room.url, "/api/room/hello", {
|
|
1353
|
+
method: "POST",
|
|
1354
|
+
body: JSON.stringify({
|
|
1355
|
+
user: { ...user, ...(room.projectId ? activeAgent(room.projectId) : {}) },
|
|
1356
|
+
role: "guest",
|
|
1357
|
+
// Without this the host resolved the room from whichever project it
|
|
1358
|
+
// happens to have selected, so a guest's hello could land on the wrong
|
|
1359
|
+
// workspace's roster — or 409 and leave the guest with no roster at all.
|
|
1360
|
+
hostProjectId: room.hostProjectId,
|
|
1361
|
+
}),
|
|
1362
|
+
});
|
|
1363
|
+
return res.json();
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// The roster a guest last heard from the host, cached in its own room.json.
|
|
1367
|
+
// The Team tab is rendered from a live round trip, so without a cache a single
|
|
1368
|
+
// slow or dropped tunnel request blanks the whole collaborator list.
|
|
1369
|
+
function rememberRoster(workspacePath, roster) {
|
|
1370
|
+
const room = loadRoom(workspacePath);
|
|
1371
|
+
if (!room) return;
|
|
1372
|
+
const now = Date.now();
|
|
1373
|
+
saveRoom(
|
|
1374
|
+
{
|
|
1375
|
+
...room,
|
|
1376
|
+
// membersView recomputes online from lastSeen, so stamping only the
|
|
1377
|
+
// members the host reported as online lets presence decay naturally if
|
|
1378
|
+
// the host becomes unreachable, instead of freezing everyone online.
|
|
1379
|
+
members: roster.map((m) => ({ ...m, lastSeen: m.online ? now : m.lastSeen || 0 })),
|
|
1380
|
+
},
|
|
1381
|
+
workspacePath
|
|
1382
|
+
);
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// Locks live on the host while a room is joined. Mirroring them locally keeps
|
|
1386
|
+
// /api/locks, the Coordinator tab's SSE stream and the dashboard consistent
|
|
1387
|
+
// instead of each reporting a different (and mostly empty) answer.
|
|
1388
|
+
const roomLocks = new RoomLockMirror({
|
|
1389
|
+
fetchLocks: async (room) => {
|
|
1390
|
+
const body = await fetchHost(room.url, `/api/locks?projectId=${encodeURIComponent(room.hostProjectId)}`).then((r) =>
|
|
1391
|
+
r.json()
|
|
1392
|
+
);
|
|
1393
|
+
return { locks: body.locks || [], reads: body.reads || [] };
|
|
1394
|
+
},
|
|
1395
|
+
});
|
|
1396
|
+
/**
|
|
1397
|
+
* A replayed release the host would not take. The agent that queued it has
|
|
1398
|
+
* long since moved on and is not watching the board, so this has to be a
|
|
1399
|
+
* notice rather than a socket event nobody is listening for.
|
|
1400
|
+
*/
|
|
1401
|
+
roomControl.onReplay = ({ rejected }) => {
|
|
1402
|
+
// Only a host verdict is worth a notice. A transport failure has been put
|
|
1403
|
+
// back on the queue and will go out on the next reconnect, so reporting it
|
|
1404
|
+
// would be crying wolf about work that is still in hand.
|
|
1405
|
+
if (!rejected?.length) return;
|
|
1406
|
+
const room = loadRoom();
|
|
1407
|
+
const files = [...new Set(rejected.map((r) => r.body?.file).filter(Boolean))];
|
|
1408
|
+
emitNotice(
|
|
1409
|
+
{
|
|
1410
|
+
type: "replay_rejected",
|
|
1411
|
+
key: `replay_rejected:${room?.roomId || "room"}`,
|
|
1412
|
+
title: `${rejected.length} offline ${rejected.length === 1 ? "release" : "releases"} did not reach the host`,
|
|
1413
|
+
body: files.length
|
|
1414
|
+
? `Still held room-wide until they expire: ${files.slice(0, 3).join(", ")}${
|
|
1415
|
+
files.length > 3 ? ` and ${files.length - 3} more` : ""
|
|
1416
|
+
}.`
|
|
1417
|
+
: "They will expire on their own TTL.",
|
|
1418
|
+
workspaceId: room?.projectId || currentProject()?.id || null,
|
|
1419
|
+
},
|
|
1420
|
+
{ revive: true }
|
|
1421
|
+
);
|
|
1422
|
+
};
|
|
1423
|
+
|
|
1424
|
+
roomControl.onLocks = (msg) => {
|
|
1425
|
+
const current = loadRoom();
|
|
1426
|
+
if (msg.workspaceId && current?.hostProjectId && msg.workspaceId !== current.hostProjectId) return;
|
|
1427
|
+
roomLocks.accept(msg.locks, { reads: msg.reads });
|
|
1428
|
+
};
|
|
1429
|
+
roomLocks.on("change", ({ locks, reads }) => {
|
|
1430
|
+
const room = loadRoom();
|
|
1431
|
+
const workspaceId = room?.projectId || currentProject()?.id;
|
|
1432
|
+
sse.emit("locks", {
|
|
1433
|
+
workspaceId,
|
|
1434
|
+
locks,
|
|
1435
|
+
reads: Array.isArray(reads) ? reads : readsFor(lockScope(workspaceId)),
|
|
1436
|
+
});
|
|
1437
|
+
const health = roomLocks.health;
|
|
1438
|
+
if (health.joined && !health.reachable && roomLocks.failures >= 3) {
|
|
1439
|
+
emitNotice(
|
|
1440
|
+
{
|
|
1441
|
+
type: "host_offline",
|
|
1442
|
+
key: `host_offline:${room?.roomId || "room"}`,
|
|
1443
|
+
title: `@${room?.hostLogin || "host"} went offline`,
|
|
1444
|
+
body: "Waiting to reconnect to the shared room.",
|
|
1445
|
+
workspaceId: room?.projectId || currentProject()?.id || null,
|
|
1446
|
+
},
|
|
1447
|
+
{ revive: true }
|
|
1448
|
+
);
|
|
1449
|
+
}
|
|
1450
|
+
if (health.joined && health.reachable) {
|
|
1451
|
+
const key = `host_offline:${room?.roomId || "room"}`;
|
|
1452
|
+
if (dismissByKey(key)) {
|
|
1453
|
+
emitNotice(
|
|
1454
|
+
{
|
|
1455
|
+
type: "host_online",
|
|
1456
|
+
key: `host_online:${room?.roomId || "room"}`,
|
|
1457
|
+
title: `@${room?.hostLogin || "host"} is back`,
|
|
1458
|
+
body: "Reconnected to the shared room.",
|
|
1459
|
+
workspaceId: room?.projectId || currentProject()?.id || null,
|
|
1460
|
+
},
|
|
1461
|
+
{ revive: true }
|
|
1462
|
+
);
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
function syncRoomLocks() {
|
|
1468
|
+
roomLocks.start(loadRoom());
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
function shareExtras(project) {
|
|
1472
|
+
const user = currentUser();
|
|
1473
|
+
return {
|
|
1474
|
+
hostProjectId: project?.id,
|
|
1475
|
+
workspacePath: project?.path,
|
|
1476
|
+
projectName: project?.name,
|
|
1477
|
+
hostUser: user,
|
|
1478
|
+
hostLogin: user.login,
|
|
1479
|
+
repoKey: repoKeyFromRemote(project?.remoteUrl),
|
|
1480
|
+
};
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
const discoverState = { at: 0, key: "", rooms: [] };
|
|
1484
|
+
|
|
1485
|
+
async function refreshOpenRooms(project, { force } = {}) {
|
|
1486
|
+
const key = repoKeyFromRemote(project?.remoteUrl);
|
|
1487
|
+
if (!key) return [];
|
|
1488
|
+
if (!force && discoverState.key === key && Date.now() - discoverState.at < 12_000) return discoverState.rooms;
|
|
1489
|
+
const me = currentUser().login;
|
|
1490
|
+
let collabs = [];
|
|
1491
|
+
try {
|
|
1492
|
+
collabs = await fetchGithubCollaborators(project.remoteUrl);
|
|
1493
|
+
} catch {
|
|
1494
|
+
collabs = [];
|
|
1495
|
+
}
|
|
1496
|
+
const rooms = await discoverRooms({
|
|
1497
|
+
repoKey: key,
|
|
1498
|
+
logins: collabs.map((c) => c.login),
|
|
1499
|
+
myLogin: me,
|
|
1500
|
+
});
|
|
1501
|
+
discoverState.at = Date.now();
|
|
1502
|
+
discoverState.key = key;
|
|
1503
|
+
discoverState.rooms = rooms;
|
|
1504
|
+
syncInviteNotices(project, rooms);
|
|
1505
|
+
return rooms;
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
function emitNotice(input, opts) {
|
|
1509
|
+
const notice = pushNotice(input, opts);
|
|
1510
|
+
if (notice) sse.emit("notice", notice);
|
|
1511
|
+
return notice;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
function syncInviteNotices(project, rooms) {
|
|
1515
|
+
const current = loadRoom(project?.path);
|
|
1516
|
+
const live = new Set();
|
|
1517
|
+
for (const room of rooms || []) {
|
|
1518
|
+
if (!room?.roomId) continue;
|
|
1519
|
+
const key = `invite:${room.roomId}`;
|
|
1520
|
+
live.add(key);
|
|
1521
|
+
if (current?.roomId && current.roomId === room.roomId) continue;
|
|
1522
|
+
// A host keeps one roomId across sessions, so the invite key repeats. A new
|
|
1523
|
+
// tunnel URL means the host started hosting again: bring the invite back
|
|
1524
|
+
// even if the last one was dismissed (otherwise it stays hidden for a week).
|
|
1525
|
+
const prior = findNotice(key);
|
|
1526
|
+
const revive = Boolean(prior && room.url && prior.payload?.url !== room.url);
|
|
1527
|
+
emitNotice({
|
|
1528
|
+
type: "invite",
|
|
1529
|
+
key,
|
|
1530
|
+
title: `@${room.hostLogin} invited you`,
|
|
1531
|
+
body: `Join the shared room${room.projectName ? ` for ${room.projectName}` : ""}.`,
|
|
1532
|
+
action: "join",
|
|
1533
|
+
workspaceId: project?.id || null,
|
|
1534
|
+
payload: {
|
|
1535
|
+
hostLogin: room.hostLogin,
|
|
1536
|
+
roomId: room.roomId,
|
|
1537
|
+
gistId: room.gistId || null,
|
|
1538
|
+
url: room.url,
|
|
1539
|
+
projectName: room.projectName || project?.name || null,
|
|
1540
|
+
projectId: project?.id || null,
|
|
1541
|
+
},
|
|
1542
|
+
}, { revive });
|
|
1543
|
+
}
|
|
1544
|
+
for (const n of listNotices()) {
|
|
1545
|
+
if (n.type === "invite" && n.key && !live.has(n.key) && !(current?.roomId && n.key === `invite:${current.roomId}`)) {
|
|
1546
|
+
dismissByKey(n.key);
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
let tunnelWatchTimer = null;
|
|
1552
|
+
let tunnelMaintainInFlight = null;
|
|
1553
|
+
let lastTunnelProbeAt = 0;
|
|
1554
|
+
|
|
1555
|
+
function stopTunnelWatch() {
|
|
1556
|
+
if (tunnelWatchTimer) clearInterval(tunnelWatchTimer);
|
|
1557
|
+
tunnelWatchTimer = null;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
function startTunnelWatch() {
|
|
1561
|
+
if (tunnelWatchTimer) return;
|
|
1562
|
+
tunnelWatchTimer = setInterval(() => maintainHostTunnel().catch(() => undefined), 12000);
|
|
1563
|
+
if (tunnelWatchTimer.unref) tunnelWatchTimer.unref();
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
async function publishAndWatch(room, project) {
|
|
1567
|
+
let warning = null;
|
|
1568
|
+
try {
|
|
1569
|
+
const published = await publishSignal(room, {
|
|
1570
|
+
repoKey: repoKeyFromRemote(project?.remoteUrl) || room.repoKey,
|
|
1571
|
+
hostLogin: currentUser().login,
|
|
1572
|
+
});
|
|
1573
|
+
if (published?.ok && published.gistId && published.gistId !== room.signalGistId) {
|
|
1574
|
+
room.signalGistId = published.gistId;
|
|
1575
|
+
saveRoom(room, project?.path || room.projectPath);
|
|
1576
|
+
} else if (!published?.ok) {
|
|
1577
|
+
warning = GIST_SCOPE_HINT;
|
|
1578
|
+
}
|
|
1579
|
+
} catch {
|
|
1580
|
+
warning = GIST_SCOPE_HINT;
|
|
1581
|
+
}
|
|
1582
|
+
startTunnelWatch();
|
|
1583
|
+
return warning;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
async function maintainHostTunnel() {
|
|
1587
|
+
if (tunnelMaintainInFlight) return tunnelMaintainInFlight;
|
|
1588
|
+
tunnelMaintainInFlight = maintainHostTunnelOnce().finally(() => {
|
|
1589
|
+
tunnelMaintainInFlight = null;
|
|
1590
|
+
});
|
|
1591
|
+
return tunnelMaintainInFlight;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
async function maintainHostTunnelOnce() {
|
|
1595
|
+
const project = currentProject();
|
|
1596
|
+
const room = loadRoom(project?.path);
|
|
1597
|
+
if (room?.role !== "host") {
|
|
1598
|
+
stopTunnelWatch();
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
const port = parsePort(process.argv);
|
|
1602
|
+
let listedUrl = activeTunnelUrl();
|
|
1603
|
+
if (!listedUrl) {
|
|
1604
|
+
try {
|
|
1605
|
+
const res = await fetch("http://127.0.0.1:4040/api/tunnels");
|
|
1606
|
+
if (res.ok) {
|
|
1607
|
+
const tunnels = (await res.json()).tunnels || [];
|
|
1608
|
+
listedUrl =
|
|
1609
|
+
(tunnels.find((t) => String(t.public_url || "").startsWith("https://")) || tunnels[0] || {}).public_url || null;
|
|
1610
|
+
}
|
|
1611
|
+
} catch {
|
|
1612
|
+
listedUrl = null;
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
// Active tunnel (cloudflared / RELAY_PUBLIC_URL / inspector) already live: reuse it.
|
|
1616
|
+
let url = listedUrl;
|
|
1617
|
+
if (!listedUrl) {
|
|
1618
|
+
const now = Date.now();
|
|
1619
|
+
if (now - lastTunnelProbeAt < 8000) return;
|
|
1620
|
+
lastTunnelProbeAt = now;
|
|
1621
|
+
url = await ensureTunnel(port);
|
|
1622
|
+
}
|
|
1623
|
+
if (!url) return;
|
|
1624
|
+
if (url === room.url && room.live !== false) {
|
|
1625
|
+
if (!room.signalGistId) await publishAndWatch(room, project);
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
const previous = room.url;
|
|
1629
|
+
room.url = url;
|
|
1630
|
+
room.live = true;
|
|
1631
|
+
saveRoom(room, project?.path);
|
|
1632
|
+
const warning = await publishAndWatch(room, project);
|
|
1633
|
+
if (previous !== url) console.log(`[relay-room] tunnel is ${url}`);
|
|
1634
|
+
if (warning) console.warn(`[relay-room] ${warning}`);
|
|
1635
|
+
if (previous !== url) {
|
|
1636
|
+
sse.emit("presence", { workspaceId: project?.id, tunnel: url });
|
|
1637
|
+
broadcastRoom({ type: "tunnel", url });
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
refreshGuestRoom = async function refreshGuestRoomNow(room) {
|
|
1642
|
+
if (room?.role !== "guest") return room;
|
|
1643
|
+
const project = currentProject();
|
|
1644
|
+
const repoKey = room.repoKey || repoKeyFromRemote(project?.remoteUrl);
|
|
1645
|
+
try {
|
|
1646
|
+
const signal = await findSignal({
|
|
1647
|
+
gistId: room.signalGistId,
|
|
1648
|
+
hostLogin: room.hostLogin,
|
|
1649
|
+
repoKey,
|
|
1650
|
+
roomId: room.roomId,
|
|
1651
|
+
});
|
|
1652
|
+
if (!signal?.url || signal.live === false) return room;
|
|
1653
|
+
if (signal.url === room.url) return room;
|
|
1654
|
+
const next = saveRoom(
|
|
1655
|
+
{
|
|
1656
|
+
...room,
|
|
1657
|
+
url: signal.url,
|
|
1658
|
+
roomId: signal.roomId || room.roomId,
|
|
1659
|
+
signalGistId: signal.gistId || room.signalGistId,
|
|
1660
|
+
repoKey: signal.repoKey || repoKey,
|
|
1661
|
+
hostLogin: signal.hostLogin || room.hostLogin,
|
|
1662
|
+
},
|
|
1663
|
+
room.projectPath || project?.path
|
|
1664
|
+
);
|
|
1665
|
+
console.log(`[relay-room] host moved to ${signal.url}`);
|
|
1666
|
+
sse.emit("presence", { workspaceId: room.projectId || project?.id, reconnect: true });
|
|
1667
|
+
emitNotice(
|
|
1668
|
+
{
|
|
1669
|
+
type: "host_online",
|
|
1670
|
+
key: `host_online:${room.roomId || "room"}`,
|
|
1671
|
+
title: `@${room.hostLogin || "host"} is back`,
|
|
1672
|
+
body: "Reconnected to the shared room.",
|
|
1673
|
+
workspaceId: room.projectId || project?.id || null,
|
|
1674
|
+
},
|
|
1675
|
+
{ revive: true }
|
|
1676
|
+
);
|
|
1677
|
+
dismissByKey(`host_offline:${room.roomId || "room"}`);
|
|
1678
|
+
syncRoomLocks();
|
|
1679
|
+
return next;
|
|
1680
|
+
} catch {
|
|
1681
|
+
return room;
|
|
1682
|
+
}
|
|
1683
|
+
};
|
|
1684
|
+
|
|
1685
|
+
/**
|
|
1686
|
+
* What this machine should show as "the locks".
|
|
1687
|
+
*
|
|
1688
|
+
* A guest's own table is normally empty — its hooks claim against the host —
|
|
1689
|
+
* so the mirror leads. But a hook that hit the room while the tunnel was down
|
|
1690
|
+
* falls back to the local coordinator (see coordinator/client.js), and those
|
|
1691
|
+
* claims exist nowhere else: returning the mirror alone made the guest's own
|
|
1692
|
+
* in-progress edits invisible on its own board.
|
|
1693
|
+
*
|
|
1694
|
+
* Merge instead. A local row is dropped only when the host already has the
|
|
1695
|
+
* same file under the same agent — that is the same lock seen twice. A local
|
|
1696
|
+
* row held by a DIFFERENT agent is the genuine double-hold a tunnel outage
|
|
1697
|
+
* opens up (both sides fail open by design), and dropping it is what made
|
|
1698
|
+
* that conflict invisible on the one screen that should be shouting about it.
|
|
1699
|
+
*/
|
|
1700
|
+
function readsFor(scope) {
|
|
1701
|
+
try {
|
|
1702
|
+
if (!scope) return [];
|
|
1703
|
+
const { filterKeys } = require("./coordinator/lockTable");
|
|
1704
|
+
const local = lockTable.readsFor(filterKeys(scope));
|
|
1705
|
+
const mirrored = typeof roomLocks.listReads === "function" ? roomLocks.listReads() : null;
|
|
1706
|
+
if (!mirrored) return local;
|
|
1707
|
+
const onHost = new Set(mirrored.map((r) => `${r.filePath}::${r.agentId}`));
|
|
1708
|
+
return [...mirrored, ...local.filter((r) => !onHost.has(`${r.filePath}::${r.agentId}`))];
|
|
1709
|
+
} catch (err) {
|
|
1710
|
+
console.warn(`[relay] readsFor: ${err.message || err}`);
|
|
1711
|
+
return [];
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
function locksFor(scope) {
|
|
1716
|
+
try {
|
|
1717
|
+
const mirrored = roomLocks.list();
|
|
1718
|
+
const local = scope ? lockTable.list(scope) : [];
|
|
1719
|
+
if (!mirrored) return local;
|
|
1720
|
+
const onHost = new Set(mirrored.map((l) => `${l.filePath}::${l.agentId}`));
|
|
1721
|
+
return [...mirrored, ...local.filter((l) => !onHost.has(`${l.filePath}::${l.agentId}`))];
|
|
1722
|
+
} catch (err) {
|
|
1723
|
+
console.warn(`[relay] locksFor: ${err.message || err}`);
|
|
1724
|
+
return [];
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
// Only pushed when local transcripts actually moved. The live WebSocket is the
|
|
1729
|
+
// delivery path; HTTP is the fallback when the tunnel is down.
|
|
1730
|
+
const lastPushedSync = new Map();
|
|
1731
|
+
|
|
1732
|
+
// Only guests push. The host is already the aggregator — sending its own
|
|
1733
|
+
// history out through its tunnel and back would be a pointless round trip.
|
|
1734
|
+
async function pushRoomSnapshot(room, project, { force } = {}) {
|
|
1735
|
+
if (room?.role !== "guest" || !room.url || !project) return;
|
|
1736
|
+
const target = room.hostProjectId;
|
|
1737
|
+
if (!target) return;
|
|
1738
|
+
const memory = loadMemory(project.id);
|
|
1739
|
+
const stamp = memory.lastTranscriptSyncAt || 0;
|
|
1740
|
+
const collisionStamp = loadLocalCollisions(project.id).updatedAt || 0;
|
|
1741
|
+
const pushKey = `${stamp}:${collisionStamp}`;
|
|
1742
|
+
if (!force && lastPushedSync.get(project.id) === pushKey) return;
|
|
1743
|
+
lastPushedSync.set(project.id, pushKey);
|
|
1744
|
+
const login = currentUser().login;
|
|
1745
|
+
const snapshot = localSnapshot(memory, login, project.id);
|
|
1746
|
+
const payload = { type: "history", projectId: target, login, snapshot };
|
|
1747
|
+
if (sendToHost(payload)) return;
|
|
1748
|
+
try {
|
|
1749
|
+
await fetchHost(room.url, "/api/room/history", {
|
|
1750
|
+
method: "POST",
|
|
1751
|
+
timeoutMs: 8000,
|
|
1752
|
+
body: JSON.stringify(payload),
|
|
1753
|
+
});
|
|
1754
|
+
} catch {
|
|
1755
|
+
lastPushedSync.delete(project.id);
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
async function pushRoomHistory(room, project) {
|
|
1760
|
+
return pushRoomSnapshot(room, project, { force: false });
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
function notifyCollisionMetrics(project) {
|
|
1764
|
+
if (!project?.id) return;
|
|
1765
|
+
const room = loadRoom(project.path);
|
|
1766
|
+
const login = currentUser().login || "local";
|
|
1767
|
+
const merged = mergeCollisionStats(loadMemory(project.id), { localLogin: login, projectId: project.id });
|
|
1768
|
+
sse.emit("metrics", { workspaceId: project.id, collisions: merged });
|
|
1769
|
+
if (room?.role === "host" && room.url) queueRoomState(project);
|
|
1770
|
+
else if (room?.role === "guest" && room.url) pushRoomSnapshot(room, project, { force: true }).catch(() => undefined);
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
function bumpCollision(projectId, kind, detail = {}) {
|
|
1774
|
+
recordCollision(projectId, kind, detail);
|
|
1775
|
+
const project = loadRegistry().projects.find((p) => p.id === projectId);
|
|
1776
|
+
if (project) notifyCollisionMetrics(project);
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
async function pullRoomHistory(room, project) {
|
|
1780
|
+
if (room?.role !== "guest" || !room.url || !room.hostProjectId || !project) return;
|
|
1781
|
+
const login = currentUser().login;
|
|
1782
|
+
const body = await fetchHost(
|
|
1783
|
+
room.url,
|
|
1784
|
+
`/api/room/state?projectId=${encodeURIComponent(room.hostProjectId)}&exclude=${encodeURIComponent(login)}`
|
|
1785
|
+
).then((r) => r.json());
|
|
1786
|
+
if (body?.ok) savePeerSnapshot(project.id, "host", body, body.hostLogin || "host");
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
app.get("/api/health", (_req, res) => {
|
|
1790
|
+
const session = loadSession();
|
|
1791
|
+
res.json({
|
|
1792
|
+
ok: true,
|
|
1793
|
+
version: VERSION,
|
|
1794
|
+
coordinatorPort: coordPort,
|
|
1795
|
+
auth: Boolean(session.userId),
|
|
1796
|
+
room: publicRoom(loadRoom()),
|
|
1797
|
+
});
|
|
1798
|
+
});
|
|
1799
|
+
|
|
1800
|
+
app.get("/api/session", (_req, res) => {
|
|
1801
|
+
const reg = loadRegistry();
|
|
1802
|
+
const session = loadSession();
|
|
1803
|
+
const user = session.userId ? reg.users.find((u) => u.id === session.userId) || null : null;
|
|
1804
|
+
res.json({
|
|
1805
|
+
user,
|
|
1806
|
+
users: reg.users,
|
|
1807
|
+
projectId: session.projectId || currentProject()?.id,
|
|
1808
|
+
oauthConfigured: Boolean(process.env.GITHUB_CLIENT_ID),
|
|
1809
|
+
});
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
app.post("/api/auth/login", (req, res) => {
|
|
1813
|
+
const reg = loadRegistry();
|
|
1814
|
+
const login = req.body?.login;
|
|
1815
|
+
const user = reg.users.find((u) => u.login === login) || reg.users[0];
|
|
1816
|
+
const session = { ...loadSession(), userId: user.id, login: user.login };
|
|
1817
|
+
saveSession(session);
|
|
1818
|
+
sse.emit("presence", { workspaceId: currentProject()?.id, user });
|
|
1819
|
+
res.json({ user });
|
|
1820
|
+
});
|
|
1821
|
+
|
|
1822
|
+
app.get("/api/auth/github", (req, res) => {
|
|
1823
|
+
const redirect = `${req.protocol}://${req.get("host")}/api/auth/github/callback`;
|
|
1824
|
+
const url = authorizeUrl(redirect);
|
|
1825
|
+
if (!url) return res.status(501).json({ error: "oauth_not_configured", hint: "Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET" });
|
|
1826
|
+
res.redirect(url);
|
|
1827
|
+
});
|
|
1828
|
+
|
|
1829
|
+
app.get("/api/auth/github/callback", async (req, res) => {
|
|
1830
|
+
const redirect = `${req.protocol}://${req.get("host")}/api/auth/github/callback`;
|
|
1831
|
+
try {
|
|
1832
|
+
const result = await exchangeCode(req.query.code, redirect);
|
|
1833
|
+
if (result.error) return res.status(400).json(result);
|
|
1834
|
+
const reg = loadRegistry();
|
|
1835
|
+
let user = reg.users.find((u) => u.login === result.user.login);
|
|
1836
|
+
if (!user) {
|
|
1837
|
+
user = result.user;
|
|
1838
|
+
reg.users.push(user);
|
|
1839
|
+
saveRegistry(reg);
|
|
1840
|
+
}
|
|
1841
|
+
saveSession({ ...loadSession(), userId: user.id, login: user.login });
|
|
1842
|
+
res.redirect(UI_ORIGIN);
|
|
1843
|
+
} catch (err) {
|
|
1844
|
+
res.status(500).json({ error: String(err.message || err) });
|
|
1845
|
+
}
|
|
1846
|
+
});
|
|
1847
|
+
|
|
1848
|
+
app.post("/api/auth/logout", (_req, res) => {
|
|
1849
|
+
saveSession({ userId: null, login: null, projectId: loadSession().projectId });
|
|
1850
|
+
res.json({ ok: true });
|
|
1851
|
+
});
|
|
1852
|
+
|
|
1853
|
+
app.get("/api/projects", (_req, res) => {
|
|
1854
|
+
res.json({ projects: loadRegistry().projects });
|
|
1855
|
+
});
|
|
1856
|
+
|
|
1857
|
+
app.post("/api/projects", async (req, res) => {
|
|
1858
|
+
const { name, path: workspacePath, remoteUrl, mode } = req.body || {};
|
|
1859
|
+
try {
|
|
1860
|
+
let project;
|
|
1861
|
+
if (mode === "clone") {
|
|
1862
|
+
if (!remoteUrl) return res.status(400).json({ error: "remoteUrl required" });
|
|
1863
|
+
const out = await cloneRepo(remoteUrl, workspacePath);
|
|
1864
|
+
project = out.project;
|
|
1865
|
+
if (name && name !== project.name) {
|
|
1866
|
+
project.name = name;
|
|
1867
|
+
const reg = loadRegistry();
|
|
1868
|
+
const row = reg.projects.find((p) => p.id === project.id);
|
|
1869
|
+
if (row) row.name = name;
|
|
1870
|
+
saveRegistry(reg);
|
|
1871
|
+
}
|
|
1872
|
+
} else {
|
|
1873
|
+
if (!workspacePath) return res.status(400).json({ error: "path required" });
|
|
1874
|
+
project = addLocal(workspacePath, name);
|
|
1875
|
+
}
|
|
1876
|
+
watcher.watch(project.path, project.id);
|
|
1877
|
+
sse.emit("projects", { projects: loadRegistry().projects });
|
|
1878
|
+
res.status(201).json({ project });
|
|
1879
|
+
} catch (err) {
|
|
1880
|
+
res.status(400).json({ error: String(err.message || err) });
|
|
1881
|
+
}
|
|
1882
|
+
});
|
|
1883
|
+
|
|
1884
|
+
app.get("/api/projects/:id", (req, res) => {
|
|
1885
|
+
const project = loadRegistry().projects.find((p) => p.id === req.params.id);
|
|
1886
|
+
if (!project) return res.status(404).json({ error: "not_found" });
|
|
1887
|
+
res.json({ project });
|
|
1888
|
+
});
|
|
1889
|
+
|
|
1890
|
+
app.patch("/api/projects/:id", (req, res) => {
|
|
1891
|
+
const reg = loadRegistry();
|
|
1892
|
+
const project = reg.projects.find((p) => p.id === req.params.id);
|
|
1893
|
+
if (!project) return res.status(404).json({ error: "not_found" });
|
|
1894
|
+
if (req.body?.name) project.name = req.body.name;
|
|
1895
|
+
saveRegistry(reg);
|
|
1896
|
+
res.json({ project });
|
|
1897
|
+
});
|
|
1898
|
+
|
|
1899
|
+
function stopTranscriptWatch(projectId) {
|
|
1900
|
+
const state = transcriptWatchers.get(projectId);
|
|
1901
|
+
if (state && typeof state === "object" && state.timer) {
|
|
1902
|
+
try {
|
|
1903
|
+
clearTimeout(state.timer);
|
|
1904
|
+
} catch {
|
|
1905
|
+
/* ignore */
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
transcriptWatchers.delete(projectId);
|
|
1909
|
+
lastIngest.delete(projectId);
|
|
1910
|
+
ingestAgain.delete(projectId);
|
|
1911
|
+
ownership.delete(projectId);
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
app.delete("/api/projects/:id", (req, res) => {
|
|
1915
|
+
const mode = String(req.query.mode || req.body?.mode || "leave");
|
|
1916
|
+
const reply = (nextId) => res.json({ ok: true, mode, nextId: nextId || null });
|
|
1917
|
+
try {
|
|
1918
|
+
const reg = loadRegistry();
|
|
1919
|
+
const idx = reg.projects.findIndex((p) => p.id === req.params.id);
|
|
1920
|
+
if (idx < 0) return res.status(404).json({ ok: false, error: "not_found", nextId: null });
|
|
1921
|
+
const project = reg.projects[idx];
|
|
1922
|
+
try {
|
|
1923
|
+
if (project.path) watcher.unwatch(project.path);
|
|
1924
|
+
} catch {
|
|
1925
|
+
/* ignore */
|
|
1926
|
+
}
|
|
1927
|
+
stopTranscriptWatch(project.id);
|
|
1928
|
+
// Leave keeps workspace files on disk. Remove only drops Relay's own
|
|
1929
|
+
// metadata — never the checkout — so a missing path/room cannot 500.
|
|
1930
|
+
reg.projects.splice(idx, 1);
|
|
1931
|
+
saveRegistry(reg);
|
|
1932
|
+
if (mode === "remove") {
|
|
1933
|
+
try {
|
|
1934
|
+
fs.rmSync(projectDir(project.id), { recursive: true, force: true });
|
|
1935
|
+
} catch {
|
|
1936
|
+
/* ignore */
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
const session = loadSession();
|
|
1940
|
+
if (session.projectId === project.id) {
|
|
1941
|
+
session.projectId = reg.projects[0]?.id || null;
|
|
1942
|
+
saveSession(session);
|
|
1943
|
+
}
|
|
1944
|
+
try {
|
|
1945
|
+
sse.emit("projects", { projects: reg.projects });
|
|
1946
|
+
} catch {
|
|
1947
|
+
/* ignore */
|
|
1948
|
+
}
|
|
1949
|
+
return reply(session.projectId || reg.projects[0]?.id || null);
|
|
1950
|
+
} catch {
|
|
1951
|
+
// Last-ditch unregister so leave/remove never surface as a 500.
|
|
1952
|
+
try {
|
|
1953
|
+
const reg = loadRegistry();
|
|
1954
|
+
const idx = reg.projects.findIndex((p) => p.id === req.params.id);
|
|
1955
|
+
if (idx >= 0) {
|
|
1956
|
+
const project = reg.projects[idx];
|
|
1957
|
+
stopTranscriptWatch(project.id);
|
|
1958
|
+
try {
|
|
1959
|
+
if (project.path) watcher.unwatch(project.path);
|
|
1960
|
+
} catch {
|
|
1961
|
+
/* ignore */
|
|
1962
|
+
}
|
|
1963
|
+
reg.projects.splice(idx, 1);
|
|
1964
|
+
saveRegistry(reg);
|
|
1965
|
+
const session = loadSession();
|
|
1966
|
+
if (session.projectId === project.id) {
|
|
1967
|
+
session.projectId = reg.projects[0]?.id || null;
|
|
1968
|
+
saveSession(session);
|
|
1969
|
+
}
|
|
1970
|
+
return reply(session.projectId || reg.projects[0]?.id || null);
|
|
1971
|
+
}
|
|
1972
|
+
return reply(loadSession().projectId || loadRegistry().projects[0]?.id || null);
|
|
1973
|
+
} catch {
|
|
1974
|
+
return reply(null);
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
});
|
|
1978
|
+
|
|
1979
|
+
app.post("/api/projects/:id/select", (req, res) => {
|
|
1980
|
+
const project = loadRegistry().projects.find((p) => p.id === req.params.id);
|
|
1981
|
+
if (!project) return res.status(404).json({ error: "not_found" });
|
|
1982
|
+
saveSession({ ...loadSession(), projectId: project.id });
|
|
1983
|
+
res.json({ project, dashboard: buildDashboard(project) });
|
|
1984
|
+
});
|
|
1985
|
+
|
|
1986
|
+
app.post("/api/projects/:id/sync", async (req, res) => {
|
|
1987
|
+
const project = loadRegistry().projects.find((p) => p.id === req.params.id);
|
|
1988
|
+
if (!project) return res.status(404).json({ error: "not_found" });
|
|
1989
|
+
const memory = await syncTranscriptsQueued(project, {
|
|
1990
|
+
ownerLogin: loadSession().login || "local",
|
|
1991
|
+
});
|
|
1992
|
+
project.lastSyncAt = Date.now();
|
|
1993
|
+
const reg = loadRegistry();
|
|
1994
|
+
const idx = reg.projects.findIndex((p) => p.id === project.id);
|
|
1995
|
+
reg.projects[idx] = project;
|
|
1996
|
+
saveRegistry(reg);
|
|
1997
|
+
Promise.all([
|
|
1998
|
+
pushLocalEdits(project.path, project.id, memory),
|
|
1999
|
+
pullCentralChanges(project.path),
|
|
2000
|
+
])
|
|
2001
|
+
.then(() => sse.emit("history", { workspaceId: project.id, lastSyncAt: project.lastSyncAt }))
|
|
2002
|
+
.catch(() => sse.emit("history", { workspaceId: project.id, lastSyncAt: project.lastSyncAt }));
|
|
2003
|
+
publishGraph(project);
|
|
2004
|
+
res.json({
|
|
2005
|
+
ok: true,
|
|
2006
|
+
lastSyncAt: project.lastSyncAt,
|
|
2007
|
+
chats: (memory.chats || []).length,
|
|
2008
|
+
history: (memory.history || []).length,
|
|
2009
|
+
});
|
|
2010
|
+
});
|
|
2011
|
+
|
|
2012
|
+
app.get("/api/projects/:id/dashboard", async (req, res) => {
|
|
2013
|
+
const project = loadRegistry().projects.find((p) => p.id === req.params.id);
|
|
2014
|
+
if (!project) return res.status(404).json({ error: "not_found" });
|
|
2015
|
+
const room = loadRoom(project.path);
|
|
2016
|
+
// A room is the only thing that makes this workspace anything other than
|
|
2017
|
+
// local: everything below (peer history, shared locks, remote presence) is
|
|
2018
|
+
// skipped entirely when no tunnel is joined.
|
|
2019
|
+
if (room?.url) {
|
|
2020
|
+
syncRoomLocks();
|
|
2021
|
+
const live = room.role === "guest" ? hostWireOpen() : Boolean(wss && [...wss.clients].some((c) => c.readyState === 1));
|
|
2022
|
+
if (!live) {
|
|
2023
|
+
// Do not block the dashboard on ngrok. Sync already owns transcripts;
|
|
2024
|
+
// waiting here is what made Coordinator freeze and then go blank.
|
|
2025
|
+
pullRoomHistory(room, project).catch(() => undefined);
|
|
2026
|
+
pushRoomHistory(room, project).catch(() => undefined);
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
const dashboard = buildDashboard(project);
|
|
2030
|
+
dashboard.locks = locksFor({ id: project.id, path: project.path });
|
|
2031
|
+
dashboard.reads = readsFor({ id: project.id, path: project.path });
|
|
2032
|
+
const graph = fs.existsSync(project.path) ? graphFor(project.path) : null;
|
|
2033
|
+
// Released rows are on the list for the activity trail only — treating them
|
|
2034
|
+
// as held would paint the dependency graph with locks nobody is holding.
|
|
2035
|
+
const lockedFiles = dashboard.locks
|
|
2036
|
+
.filter((l) => !l.released)
|
|
2037
|
+
.map((l) => (l.filePath.includes("::") ? l.filePath.split("::")[1] : l.filePath));
|
|
2038
|
+
dashboard.graph = graph
|
|
2039
|
+
? graph.snapshot(lockedFiles)
|
|
2040
|
+
: { nodes: [], edges: [], cycles: [], unresolvable: [], fileCount: 0, edgeCount: 0, lockDepth: 1 };
|
|
2041
|
+
const mem = loadMemory(project.id);
|
|
2042
|
+
dashboard.patches = patchRows(mem.patches || mem.edits || []);
|
|
2043
|
+
dashboard.lastAppliedLamport = mem.lastAppliedLamport || 0;
|
|
2044
|
+
let roomMembers = [];
|
|
2045
|
+
if (room?.role === "host") {
|
|
2046
|
+
// Re-upserting on every poll is what keeps the host's own lastSeen fresh;
|
|
2047
|
+
// membersView reads it as online/offline, so skipping it when the row
|
|
2048
|
+
// already exists made the host show as offline in its own room after 45s.
|
|
2049
|
+
saveRoom(upsertMember(room, { ...currentUser(), ...activeAgent(project.id) }, "host"), project.path);
|
|
2050
|
+
roomMembers = membersView(loadRoom(project.path));
|
|
2051
|
+
}
|
|
2052
|
+
if (room?.role === "guest" && room.url) {
|
|
2053
|
+
roomMembers = membersView(loadRoom(project.path));
|
|
2054
|
+
announceToHost(room)
|
|
2055
|
+
.then((hello) => {
|
|
2056
|
+
const roster = Array.isArray(hello?.members) && hello.members.length ? hello.members : null;
|
|
2057
|
+
if (roster) rememberRoster(project.path, roster);
|
|
2058
|
+
})
|
|
2059
|
+
.catch(() => {
|
|
2060
|
+
refreshGuestRoom(room)
|
|
2061
|
+
.then((next) => {
|
|
2062
|
+
if (next?.url && next.url !== room.url) {
|
|
2063
|
+
connectHostPatches(next);
|
|
2064
|
+
connectHostControl(next);
|
|
2065
|
+
}
|
|
2066
|
+
})
|
|
2067
|
+
.catch(() => undefined);
|
|
2068
|
+
});
|
|
2069
|
+
if (room.hostProjectId) {
|
|
2070
|
+
fetchHost(room.url, `/api/projects/${room.hostProjectId}/graph`)
|
|
2071
|
+
.then((r) => r.json())
|
|
2072
|
+
.then((remoteGraph) => {
|
|
2073
|
+
if (remoteGraph?.nodes) sse.emit("graph", { workspaceId: project.id, graph: remoteGraph });
|
|
2074
|
+
})
|
|
2075
|
+
.catch(() => undefined);
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
// Projects registered before remoteUrl auto-detection existed (or via
|
|
2079
|
+
// "Add local repository", which used to always store null) never got a
|
|
2080
|
+
// remoteUrl — backfill it once from the workspace's own git remote so
|
|
2081
|
+
// the GitHub-collaborators lookup below has something to work with.
|
|
2082
|
+
if (!project.remoteUrl) {
|
|
2083
|
+
const detected = detectRemoteUrl(project.path);
|
|
2084
|
+
if (detected) {
|
|
2085
|
+
project.remoteUrl = detected;
|
|
2086
|
+
const reg = loadRegistry();
|
|
2087
|
+
const idx = reg.projects.findIndex((p) => p.id === project.id);
|
|
2088
|
+
if (idx >= 0) {
|
|
2089
|
+
reg.projects[idx] = project;
|
|
2090
|
+
saveRegistry(reg);
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
if (room?.url) {
|
|
2095
|
+
const me = currentUser();
|
|
2096
|
+
const myRole = room.role === "host" ? "host" : "guest";
|
|
2097
|
+
const mine = { ...me, role: myRole, online: true, ...activeAgent(project.id) };
|
|
2098
|
+
const rest = roomMembers.filter((c) => !sameLogin(c.login, me.login));
|
|
2099
|
+
const previous = roomMembers.find((c) => sameLogin(c.login, me.login));
|
|
2100
|
+
roomMembers = [{ ...previous, ...mine }, ...rest];
|
|
2101
|
+
}
|
|
2102
|
+
let ghCollaborators = peekCollaborators(project.remoteUrl) || [];
|
|
2103
|
+
fetchGithubCollaborators(project.remoteUrl).catch(() => []);
|
|
2104
|
+
dashboard.collaborators = overlayTeam(ghCollaborators, roomMembers);
|
|
2105
|
+
// hostReachable lets the Coordinator board say "the host is not answering"
|
|
2106
|
+
// instead of rendering the same empty state it shows when simply nobody is
|
|
2107
|
+
// editing — the two look identical from the guest's side otherwise.
|
|
2108
|
+
const health = roomLocks.health;
|
|
2109
|
+
dashboard.room = publicRoom(room)
|
|
2110
|
+
? { ...publicRoom(room), hostReachable: health.reachable, lastHostContactAt: health.lastContactAt }
|
|
2111
|
+
: null;
|
|
2112
|
+
// Only the host manages invites, and only its own Mission Control sees them.
|
|
2113
|
+
dashboard.invites = room?.role === "host" && !isRemoteRequest(req) ? inviteView(room) : [];
|
|
2114
|
+
dashboard.openRooms =
|
|
2115
|
+
!isRemoteRequest(req) && discoverState.key === repoKeyFromRemote(project.remoteUrl) ? discoverState.rooms : [];
|
|
2116
|
+
// Outside a room, presence only knew room members, so everyone — including
|
|
2117
|
+
// the viewer, and a teammate who is visibly hosting — read as offline while
|
|
2118
|
+
// the Team tab simultaneously said "@x is hosting". Mark what we do know.
|
|
2119
|
+
if (!room?.url && !isRemoteRequest(req)) {
|
|
2120
|
+
const me = currentUser();
|
|
2121
|
+
const hosting = new Set(dashboard.openRooms.map((r) => String(r.hostLogin || "").toLowerCase()));
|
|
2122
|
+
dashboard.collaborators = dashboard.collaborators.map((c) => {
|
|
2123
|
+
const key = String(c.login || "").toLowerCase();
|
|
2124
|
+
if (sameLogin(c.login, me.login)) return { ...c, online: true };
|
|
2125
|
+
if (hosting.has(key)) return { ...c, online: true, hosting: true };
|
|
2126
|
+
return c;
|
|
2127
|
+
});
|
|
2128
|
+
}
|
|
2129
|
+
if (!isRemoteRequest(req) && room?.role !== "host") {
|
|
2130
|
+
refreshOpenRooms(project)
|
|
2131
|
+
.then((rooms) => {
|
|
2132
|
+
if (rooms.length) sse.emit("presence", { workspaceId: project.id, openRooms: true });
|
|
2133
|
+
})
|
|
2134
|
+
.catch(() => undefined);
|
|
2135
|
+
}
|
|
2136
|
+
res.json(dashboard);
|
|
2137
|
+
});
|
|
2138
|
+
|
|
2139
|
+
app.get("/api/projects/:id/graph", requireRoomMember, (req, res) => {
|
|
2140
|
+
const project = loadRegistry().projects.find((p) => p.id === req.params.id);
|
|
2141
|
+
if (!project) return res.status(404).json({ error: "not_found" });
|
|
2142
|
+
const g = rebuild(project.path) || graphFor(project.path);
|
|
2143
|
+
const lockedFiles = lockTable.list({ id: project.id, path: project.path }).map((l) =>
|
|
2144
|
+
l.filePath.includes("::") ? l.filePath.split("::")[1] : l.filePath
|
|
2145
|
+
);
|
|
2146
|
+
res.json(g ? g.snapshot(lockedFiles) : { nodes: [], edges: [] });
|
|
2147
|
+
});
|
|
2148
|
+
|
|
2149
|
+
app.get("/api/projects/:id/history", (req, res) => {
|
|
2150
|
+
const project = loadRegistry().projects.find((p) => p.id === req.params.id);
|
|
2151
|
+
if (!project) return res.status(404).json({ error: "not_found" });
|
|
2152
|
+
const memory = loadMemory(project.id);
|
|
2153
|
+
delete memory.ir;
|
|
2154
|
+
res.json({
|
|
2155
|
+
history: memory.history || [],
|
|
2156
|
+
chats: memory.chats || [],
|
|
2157
|
+
timeline: memory.timeline || [],
|
|
2158
|
+
});
|
|
2159
|
+
});
|
|
2160
|
+
|
|
2161
|
+
app.get("/api/projects/:id/conflicts", (req, res) => {
|
|
2162
|
+
const project = loadRegistry().projects.find((p) => p.id === req.params.id);
|
|
2163
|
+
if (!project) return res.status(404).json({ error: "not_found" });
|
|
2164
|
+
res.json({ conflicts: detectConflicts(loadMemory(project.id)) });
|
|
2165
|
+
});
|
|
2166
|
+
|
|
2167
|
+
app.post("/api/ingest-stop", async (req, res) => {
|
|
2168
|
+
const { workspacePath, agent, ownerLogin, sessionId, messages, edits, transcript_path } = req.body || {};
|
|
2169
|
+
const project = ensureWorkspace(workspacePath);
|
|
2170
|
+
if (!project) return res.status(400).json({ error: "workspace_path_required" });
|
|
2171
|
+
const login = ownerLogin || loadSession().login || "local";
|
|
2172
|
+
const memory = await syncTranscriptsQueued(project, {
|
|
2173
|
+
ownerLogin: login,
|
|
2174
|
+
hint: {
|
|
2175
|
+
agent,
|
|
2176
|
+
sessionId,
|
|
2177
|
+
conversationId: req.body?.conversationId,
|
|
2178
|
+
transcript_path,
|
|
2179
|
+
},
|
|
2180
|
+
});
|
|
2181
|
+
if (!(memory.history || []).length && ((messages && messages.length) || (edits && edits.length))) {
|
|
2182
|
+
const harvested = harvest({
|
|
2183
|
+
transcript_path,
|
|
2184
|
+
sessionId,
|
|
2185
|
+
conversationId: req.body?.conversationId,
|
|
2186
|
+
});
|
|
2187
|
+
appendStopTurn(project.id, {
|
|
2188
|
+
agent: agent || "Agent",
|
|
2189
|
+
ownerLogin: login,
|
|
2190
|
+
sessionId,
|
|
2191
|
+
messages: (messages && messages.length ? messages : harvested.messages) || [],
|
|
2192
|
+
edits: (edits && edits.length ? edits : harvested.edits) || [],
|
|
2193
|
+
});
|
|
2194
|
+
}
|
|
2195
|
+
sse.emit("history", { workspaceId: project.id, lastSyncAt: Date.now() });
|
|
2196
|
+
pushLocalEdits(project.path, project.id, loadMemory(project.id))
|
|
2197
|
+
.then(() => pullCentralChanges(project.path))
|
|
2198
|
+
.catch(() => undefined);
|
|
2199
|
+
pushRoomHistory(loadRoom(project.path), project).catch(() => undefined);
|
|
2200
|
+
publishGraph(project);
|
|
2201
|
+
res.json({
|
|
2202
|
+
ok: true,
|
|
2203
|
+
harvested: Boolean((memory.history || []).length),
|
|
2204
|
+
chats: (memory.chats || []).length,
|
|
2205
|
+
history: (memory.history || []).length,
|
|
2206
|
+
});
|
|
2207
|
+
});
|
|
2208
|
+
|
|
2209
|
+
app.post("/api/handshake", (req, res) => {
|
|
2210
|
+
const { projectId, agent } = req.body || {};
|
|
2211
|
+
const project = loadRegistry().projects.find((p) => p.id === projectId) || currentProject();
|
|
2212
|
+
if (!project) return res.status(404).json({ error: "no_project" });
|
|
2213
|
+
const memory = loadMemory(project.id);
|
|
2214
|
+
const row = memory.agents.find((a) => a.label === agent || a.id === agent);
|
|
2215
|
+
if (row) {
|
|
2216
|
+
row.status = "handshaking";
|
|
2217
|
+
row.lastActiveAt = Date.now();
|
|
2218
|
+
}
|
|
2219
|
+
saveMemory(project.id, memory);
|
|
2220
|
+
const token = id("hs");
|
|
2221
|
+
try {
|
|
2222
|
+
fs.writeFileSync(
|
|
2223
|
+
path.join(project.path, ".relay", `.handshake_${(agent || "agent").replace(/\s+/g, "_")}`),
|
|
2224
|
+
token
|
|
2225
|
+
);
|
|
2226
|
+
} catch {
|
|
2227
|
+
fs.mkdirSync(path.join(homeRelayDir(), "handshakes"), { recursive: true });
|
|
2228
|
+
}
|
|
2229
|
+
sse.emit("agents", { workspaceId: project.id, agents: memory.agents });
|
|
2230
|
+
res.json({ ok: true, status: "handshaking", token });
|
|
2231
|
+
});
|
|
2232
|
+
|
|
2233
|
+
app.post("/api/connect", (req, res) => {
|
|
2234
|
+
const { projectId, agent } = req.body || {};
|
|
2235
|
+
const project = loadRegistry().projects.find((p) => p.id === projectId) || currentProject();
|
|
2236
|
+
const memory = loadMemory(project.id);
|
|
2237
|
+
const row = memory.agents.find((a) => a.label === agent || a.id === agent);
|
|
2238
|
+
if (row) {
|
|
2239
|
+
row.status = "connected";
|
|
2240
|
+
row.lastActiveAt = Date.now();
|
|
2241
|
+
row.sessionId = id("sess").slice(-5);
|
|
2242
|
+
}
|
|
2243
|
+
saveMemory(project.id, memory);
|
|
2244
|
+
watcher.watch(project.path, project.id);
|
|
2245
|
+
sse.emit("agents", { workspaceId: project.id, agents: memory.agents });
|
|
2246
|
+
res.json({ ok: true, status: "connected", agent: row });
|
|
2247
|
+
});
|
|
2248
|
+
|
|
2249
|
+
app.get("/api/memory", (req, res) => {
|
|
2250
|
+
const projectId = req.query.projectId || currentProject()?.id;
|
|
2251
|
+
res.json(loadMemory(projectId));
|
|
2252
|
+
});
|
|
2253
|
+
|
|
2254
|
+
app.get("/api/locks", requireRoomMember, (req, res) => {
|
|
2255
|
+
try {
|
|
2256
|
+
const scope = lockScope(req.query.projectId || currentProject()?.id);
|
|
2257
|
+
res.json({
|
|
2258
|
+
locks: locksFor(scope),
|
|
2259
|
+
reads: readsFor(scope),
|
|
2260
|
+
raw: scope ? lockTable.status(scope) : { locks: {} },
|
|
2261
|
+
});
|
|
2262
|
+
} catch (err) {
|
|
2263
|
+
console.warn(`[relay] /api/locks: ${err.message || err}`);
|
|
2264
|
+
res.json({ locks: [], reads: [], raw: { locks: {} } });
|
|
2265
|
+
}
|
|
2266
|
+
});
|
|
2267
|
+
|
|
2268
|
+
app.get("/api/locks/stream", (req, res) => {
|
|
2269
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
2270
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
2271
|
+
res.setHeader("Connection", "keep-alive");
|
|
2272
|
+
res.flushHeaders?.();
|
|
2273
|
+
const workspaceId = req.query.projectId;
|
|
2274
|
+
const send = () => {
|
|
2275
|
+
try {
|
|
2276
|
+
const scope = lockScope(workspaceId);
|
|
2277
|
+
const payload = {
|
|
2278
|
+
locks: locksFor(scope),
|
|
2279
|
+
reads: readsFor(scope),
|
|
2280
|
+
raw: scope ? lockTable.status(scope) : { locks: {} },
|
|
2281
|
+
};
|
|
2282
|
+
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
2283
|
+
} catch (err) {
|
|
2284
|
+
console.warn(`[relay] locks/stream: ${err.message || err}`);
|
|
2285
|
+
}
|
|
2286
|
+
};
|
|
2287
|
+
send();
|
|
2288
|
+
const listener = () => send();
|
|
2289
|
+
lockTable.on("change", listener);
|
|
2290
|
+
roomLocks.on("change", listener);
|
|
2291
|
+
req.on("close", () => {
|
|
2292
|
+
lockTable.off("change", listener);
|
|
2293
|
+
roomLocks.off("change", listener);
|
|
2294
|
+
});
|
|
2295
|
+
});
|
|
2296
|
+
|
|
2297
|
+
app.get("/api/events", (req, res) => {
|
|
2298
|
+
const workspaceId = req.query.projectId;
|
|
2299
|
+
const session = loadSession();
|
|
2300
|
+
const userId = req.headers["x-relay-user"] || session.login;
|
|
2301
|
+
if (workspaceId && userId) {
|
|
2302
|
+
const out = presence.join(workspaceId, userId);
|
|
2303
|
+
if (out.escalated) {
|
|
2304
|
+
const scope = lockScope(workspaceId);
|
|
2305
|
+
for (const lock of lockTable.list(scope || workspaceId)) lockTable.escalateRegister(lock);
|
|
2306
|
+
// Merged view, not the raw table: on a guest the local table is empty
|
|
2307
|
+
// and this would blank the board for every viewer of the workspace.
|
|
2308
|
+
sse.emit("locks", { workspaceId, locks: locksFor(scope), reads: readsFor(scope) });
|
|
2309
|
+
}
|
|
2310
|
+
sse.emit("presence", { workspaceId, userId, count: out.count, escalated: out.escalated });
|
|
2311
|
+
}
|
|
2312
|
+
sse.subscribe(res, { workspaceId });
|
|
2313
|
+
req.on("close", () => {
|
|
2314
|
+
if (workspaceId && userId) presence.leave(workspaceId, userId);
|
|
2315
|
+
});
|
|
2316
|
+
});
|
|
2317
|
+
|
|
2318
|
+
app.post("/api/ensure-workspace", (req, res) => {
|
|
2319
|
+
const project = ensureWorkspace(req.body?.workspacePath);
|
|
2320
|
+
if (!project) return res.status(400).json({ error: "workspace_path_required" });
|
|
2321
|
+
res.json({ ok: true, project });
|
|
2322
|
+
});
|
|
2323
|
+
|
|
2324
|
+
app.post("/api/heartbeat", (req, res) => {
|
|
2325
|
+
res.json(lockTable.heartbeat(req.body || {}));
|
|
2326
|
+
});
|
|
2327
|
+
|
|
2328
|
+
/**
|
|
2329
|
+
* Scopes lock traffic arriving from a remote room member to the host's own
|
|
2330
|
+
* workspace path.
|
|
2331
|
+
*
|
|
2332
|
+
* The lock table keys every entry by absolute workspace path, and no two
|
|
2333
|
+
* machines agree on that path — Alice's `C:/projects/repo` and Bob's
|
|
2334
|
+
* `/home/bob/repo` produce different keys for the same file, so both agents
|
|
2335
|
+
* are granted the same lock and the coordinator arbitrates nothing.
|
|
2336
|
+
*
|
|
2337
|
+
* coordinator/client.js already rewrites the path on the way out, but that
|
|
2338
|
+
* only holds when the peer is on a current build and its agent was launched
|
|
2339
|
+
* in exactly the mirrored root; it no-ops for a subdirectory, or for a room
|
|
2340
|
+
* that predates hostWorkspacePath. A room shares exactly one project, so any
|
|
2341
|
+
* lock traffic from a member is about that project by definition — deciding
|
|
2342
|
+
* that here makes the shared mutex independent of the peer's version and of
|
|
2343
|
+
* where it happened to start its agent.
|
|
2344
|
+
*/
|
|
2345
|
+
function roomScopedBody(req, body) {
|
|
2346
|
+
// A caller holding a member token is a peer no matter which interface it
|
|
2347
|
+
// arrived on; anything else is this machine's own agent, already using the
|
|
2348
|
+
// host's path.
|
|
2349
|
+
if (!req.roomMember && !isRemoteRequest(req)) return body;
|
|
2350
|
+
const room = loadRoom();
|
|
2351
|
+
if (!room?.url || room.role !== "host") return body;
|
|
2352
|
+
const shared = loadRegistry().projects.find((p) => p.id === room.hostProjectId) || currentProject();
|
|
2353
|
+
if (!shared?.path) return body;
|
|
2354
|
+
if (normalizeCompare(body?.workspaceId) === normalizeCompare(shared.path)) return body;
|
|
2355
|
+
return { ...body, workspaceId: shared.path, peerWorkspacePath: body?.workspaceId || null };
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
// The same identity rule the control socket applies, for the HTTP fallback a
|
|
2359
|
+
// guest uses when the tunnel is down. Both paths must stamp a peer's agentId
|
|
2360
|
+
// identically or a failover would let one agent hold the same file twice.
|
|
2361
|
+
function httpLockSession(req) {
|
|
2362
|
+
const remote = Boolean(req.roomMember) || isRemoteRequest(req);
|
|
2363
|
+
return { remote, login: req.roomMember?.login || null, roomId: loadRoom()?.roomId || null };
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
app.post("/api/coord/claim", requireRoomMember, async (req, res) => {
|
|
2367
|
+
const room = loadRoom();
|
|
2368
|
+
const body = roomScopedBody(req, req.body || {});
|
|
2369
|
+
if (room?.role === "guest") {
|
|
2370
|
+
const payload = {
|
|
2371
|
+
...body,
|
|
2372
|
+
workspaceId: room.hostWorkspacePath || body.workspaceId,
|
|
2373
|
+
peerWorkspacePath: body.workspaceId,
|
|
2374
|
+
};
|
|
2375
|
+
try {
|
|
2376
|
+
// Never claim across an in-flight replay. A release queued during the
|
|
2377
|
+
// outage and this claim ride the same socket, and the host's `release`
|
|
2378
|
+
// only checks `sameOwner` — so a claim that overtakes the replay is
|
|
2379
|
+
// granted and then quietly released out from under the agent by its own
|
|
2380
|
+
// stale release. Letting the queue drain first makes the order the one
|
|
2381
|
+
// the agent actually meant.
|
|
2382
|
+
await roomControl.settled();
|
|
2383
|
+
const result = await roomControl.rpc("claim", payload);
|
|
2384
|
+
const answer = { ...(result || { allowed: false }), source: "host" };
|
|
2385
|
+
return res.status(answer.allowed ? 200 : 409).json(answer);
|
|
2386
|
+
} catch {
|
|
2387
|
+
// Tunnel down. The local table is NOT the shared mutex, and it does not
|
|
2388
|
+
// even mirror it: host lock pushes land in `roomLocks` for the board,
|
|
2389
|
+
// never in `lockTable`. Granting a write lock from it would not be
|
|
2390
|
+
// optimistic, it would be uninformed — the file another member is
|
|
2391
|
+
// holding looks free here every time. Say so instead of inventing a
|
|
2392
|
+
// grant the room never issued, and let the caller drop down to room
|
|
2393
|
+
// HTTP, which is a different wire and may well still be up.
|
|
2394
|
+
if ((body.mode || "write") !== "read") {
|
|
2395
|
+
return res.status(503).json({
|
|
2396
|
+
allowed: false,
|
|
2397
|
+
degraded: true,
|
|
2398
|
+
source: "local_fallback",
|
|
2399
|
+
reason: "control_offline",
|
|
2400
|
+
detail: "Host unreachable — this claim was never adjudicated by the room.",
|
|
2401
|
+
});
|
|
2402
|
+
}
|
|
2403
|
+
/* a read is advisory and cannot corrupt anything: record it locally */
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
const scoped = sanitizeLockBody(body, httpLockSession(req));
|
|
2407
|
+
if (!scoped) return res.status(400).json({ allowed: false, error: "agent_required" });
|
|
2408
|
+
const files = Array.isArray(scoped.files) && scoped.files.length ? scoped.files : [scoped.file];
|
|
2409
|
+
const warnings = [];
|
|
2410
|
+
for (const file of files.filter(Boolean)) {
|
|
2411
|
+
const blocked = require("./lib/depGraph").softBlock(lockTable, scoped.workspaceId, scoped.agentId, file);
|
|
2412
|
+
const held = blocked?.file ? lockTable.getLock(blocked.file, scoped.workspaceId) : null;
|
|
2413
|
+
const { sameOwner } = require("./coordinator/lockTable");
|
|
2414
|
+
if (blocked && !(held && sameOwner(held.agentId, scoped.agentId))) {
|
|
2415
|
+
warnings.push(blocked.reason || blocked.holder || String(blocked));
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
const result = lockTable.claim(scoped);
|
|
2419
|
+
if (warnings.length && result.allowed) result.warning = result.warning || warnings[0];
|
|
2420
|
+
if (!result.allowed) {
|
|
2421
|
+
const project = projectForPath(scoped.workspaceId) || currentProject();
|
|
2422
|
+
if (project?.id) {
|
|
2423
|
+
bumpCollision(project.id, "claim_blocked", {
|
|
2424
|
+
file: scoped.file,
|
|
2425
|
+
agentId: scoped.agentId,
|
|
2426
|
+
holder: result.holder,
|
|
2427
|
+
reason: result.reason,
|
|
2428
|
+
});
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
res.status(result.allowed ? 200 : 409).json(result);
|
|
2432
|
+
});
|
|
2433
|
+
app.post("/api/coord/release", requireRoomMember, async (req, res) => {
|
|
2434
|
+
const room = loadRoom();
|
|
2435
|
+
const body = roomScopedBody(req, req.body || {});
|
|
2436
|
+
if (room?.role === "guest") {
|
|
2437
|
+
try {
|
|
2438
|
+
return res.json({ ...(await roomControl.rpc("release", body)), source: "host" });
|
|
2439
|
+
} catch {
|
|
2440
|
+
// Queue it: unlike a claim, a release is a statement of fact that stays
|
|
2441
|
+
// true. Without the replay the host holds the lock until its TTL runs
|
|
2442
|
+
// out, which is up to five minutes of a file nobody is actually editing.
|
|
2443
|
+
const queued = roomControl.enqueue("release", body);
|
|
2444
|
+
const local = runLockRpc("release", body, httpLockSession(req));
|
|
2445
|
+
return res.json({ ...local, source: "local_fallback", degraded: true, queued });
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
res.json(runLockRpc("release", body, httpLockSession(req)));
|
|
2449
|
+
});
|
|
2450
|
+
app.post("/api/coord/heartbeat", requireRoomMember, async (req, res) => {
|
|
2451
|
+
const room = loadRoom();
|
|
2452
|
+
const body = roomScopedBody(req, req.body || {});
|
|
2453
|
+
if (room?.role === "guest") {
|
|
2454
|
+
try {
|
|
2455
|
+
return res.json({ ...(await roomControl.rpc("heartbeat", body, 400)), source: "host" });
|
|
2456
|
+
} catch {
|
|
2457
|
+
// Not queued on purpose: a heartbeat asserts liveness *now*, so a
|
|
2458
|
+
// replayed one would revive a lock the host has already expired.
|
|
2459
|
+
const local = runLockRpc("heartbeat", body, httpLockSession(req));
|
|
2460
|
+
return res.json({ ...local, source: "local_fallback", degraded: true });
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
res.json(runLockRpc("heartbeat", body, httpLockSession(req)));
|
|
2464
|
+
});
|
|
2465
|
+
app.post("/api/coord/release-all", requireRoomMember, (req, res) => {
|
|
2466
|
+
const body = roomScopedBody(req, req.body || {});
|
|
2467
|
+
res.json(runLockRpc("release-all", body, httpLockSession(req)));
|
|
2468
|
+
});
|
|
2469
|
+
/**
|
|
2470
|
+
* A file was read, not claimed.
|
|
2471
|
+
*
|
|
2472
|
+
* Deliberately separate from /claim: this can never fail, never blocks, and
|
|
2473
|
+
* never denies anyone. It exists so the room can see the whole picture of what
|
|
2474
|
+
* every agent is working through, not just the few files being written.
|
|
2475
|
+
*/
|
|
2476
|
+
app.post("/api/coord/read", requireRoomMember, async (req, res) => {
|
|
2477
|
+
const room = loadRoom();
|
|
2478
|
+
const body = roomScopedBody(req, req.body || {});
|
|
2479
|
+
let degradedRead = false;
|
|
2480
|
+
if (room?.role === "guest") {
|
|
2481
|
+
try {
|
|
2482
|
+
return res.json(
|
|
2483
|
+
await roomControl.rpc("read", { ...body, workspaceId: room.hostWorkspacePath || body.workspaceId }, 300)
|
|
2484
|
+
);
|
|
2485
|
+
} catch {
|
|
2486
|
+
/* tunnel down — record locally so this machine's board still lights up */
|
|
2487
|
+
degradedRead = true;
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
const scoped = sanitizeLockBody(body, httpLockSession(req));
|
|
2491
|
+
if (!scoped) return res.json({ ok: false, error: "agent_required" });
|
|
2492
|
+
const files = (Array.isArray(scoped.files) && scoped.files.length ? scoped.files : [scoped.file]).filter(Boolean);
|
|
2493
|
+
const project = projectForPath(scoped.workspaceId);
|
|
2494
|
+
for (const file of files) {
|
|
2495
|
+
lockTable.noteRead({
|
|
2496
|
+
agentId: scoped.agentId,
|
|
2497
|
+
filePath: file,
|
|
2498
|
+
workspaceId: scoped.workspaceId,
|
|
2499
|
+
holder: scoped.holder,
|
|
2500
|
+
});
|
|
2501
|
+
const item = readActivity(scoped, file, project);
|
|
2502
|
+
sse.emit("activity", { workspaceId: project?.id, item });
|
|
2503
|
+
broadcastRoom({ type: "read", workspaceId: project?.id, item });
|
|
2504
|
+
}
|
|
2505
|
+
res.json({ ok: true, files, ...(degradedRead ? { source: "local_fallback", degraded: true } : {}) });
|
|
2506
|
+
});
|
|
2507
|
+
|
|
2508
|
+
function readActivity(scoped, file, project) {
|
|
2509
|
+
const label = scoped.holder?.label || String(scoped.agentId).split(":")[0];
|
|
2510
|
+
return {
|
|
2511
|
+
id: `r_${project?.id || "ws"}_${file}_${scoped.agentId}_${Date.now()}`,
|
|
2512
|
+
kind: "read",
|
|
2513
|
+
agent: label,
|
|
2514
|
+
ownerLogin: scoped.holder?.login || loadSession().login || "local",
|
|
2515
|
+
mine: true,
|
|
2516
|
+
file,
|
|
2517
|
+
text: `${label} read ${file}`,
|
|
2518
|
+
ts: Date.now(),
|
|
2519
|
+
};
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
app.get("/api/coord/status", (req, res) => {
|
|
2523
|
+
const scope = lockScope(req.query.projectId);
|
|
2524
|
+
res.json(scope ? lockTable.status(scope) : lockTable.status());
|
|
2525
|
+
});
|
|
2526
|
+
|
|
2527
|
+
// publicRoom strips invite and member-token hashes: this route answers
|
|
2528
|
+
// through the tunnel, and the raw room file is a credential store.
|
|
2529
|
+
app.get("/api/room", (_req, res) => res.json({ room: publicRoom(loadRoom()) }));
|
|
2530
|
+
app.get("/api/room/providers", (_req, res) => {
|
|
2531
|
+
const port = parsePort(process.argv);
|
|
2532
|
+
res.json({ ok: true, ...detectProviders(port) });
|
|
2533
|
+
});
|
|
2534
|
+
app.post("/api/room/share", async (req, res) => {
|
|
2535
|
+
const project = currentProject();
|
|
2536
|
+
const existing = loadRoom(project?.path);
|
|
2537
|
+
if (existing?.role === "guest") {
|
|
2538
|
+
return res.status(409).json({
|
|
2539
|
+
error: "already_guest",
|
|
2540
|
+
hint: "Leave this room first if you want to host your own.",
|
|
2541
|
+
});
|
|
2542
|
+
}
|
|
2543
|
+
const customUrl = req.body?.url ? String(req.body.url).trim().replace(/\/$/, "") : null;
|
|
2544
|
+
const provider = req.body?.provider ? String(req.body.provider).trim().toLowerCase() : null;
|
|
2545
|
+
const result = await shareRoom(parsePort(process.argv), {
|
|
2546
|
+
...shareExtras(project),
|
|
2547
|
+
...(customUrl ? { url: customUrl } : {}),
|
|
2548
|
+
...(provider ? { provider } : {}),
|
|
2549
|
+
});
|
|
2550
|
+
if (result.ok) {
|
|
2551
|
+
syncRoomLocks();
|
|
2552
|
+
result.warning = await publishAndWatch(result.room, project);
|
|
2553
|
+
result.room = publicRoom(loadRoom(project?.path));
|
|
2554
|
+
}
|
|
2555
|
+
res.status(result.ok ? 200 : 503).json(result);
|
|
2556
|
+
});
|
|
2557
|
+
|
|
2558
|
+
// --- Room membership -----------------------------------------------------
|
|
2559
|
+
// Guests announce themselves on every dashboard poll; membersView treats a
|
|
2560
|
+
// member as online for 45s after its last hello, which is what drives the
|
|
2561
|
+
// online/offline dot in the Team tab on both sides of the tunnel.
|
|
2562
|
+
function hostRoomFor(projectId) {
|
|
2563
|
+
const project = projectId ? loadRegistry().projects.find((p) => p.id === projectId) : currentProject();
|
|
2564
|
+
const room = loadRoom(project?.path);
|
|
2565
|
+
return { project: project || currentProject(), room };
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
app.post("/api/room/hello", requireRoomMember, (req, res) => {
|
|
2569
|
+
const { project, room } = hostRoomFor(req.body?.hostProjectId);
|
|
2570
|
+
if (!room?.url) return res.status(409).json({ error: "no_room" });
|
|
2571
|
+
const user = req.body?.user;
|
|
2572
|
+
if (!user?.login) return res.status(400).json({ error: "user_required" });
|
|
2573
|
+
const next = upsertMember(room, user, req.body?.role === "host" ? "host" : "guest");
|
|
2574
|
+
// The host is a member of its own room; without this it never appears in
|
|
2575
|
+
// the roster a guest renders.
|
|
2576
|
+
upsertMember(next, { ...currentUser(), ...activeAgent(project?.id) }, "host");
|
|
2577
|
+
saveRoom(next, project?.path);
|
|
2578
|
+
sse.emit("presence", { workspaceId: project?.id, userId: user.login });
|
|
2579
|
+
res.json({
|
|
2580
|
+
ok: true,
|
|
2581
|
+
members: membersView(next),
|
|
2582
|
+
hostProjectId: room.hostProjectId || project?.id || null,
|
|
2583
|
+
hostWorkspacePath: room.hostWorkspacePath || project?.path || null,
|
|
2584
|
+
hostLogin: currentUser().login,
|
|
2585
|
+
project: project
|
|
2586
|
+
? { id: project.id, name: project.name, pathName: path.basename(project.path), remoteUrl: project.remoteUrl }
|
|
2587
|
+
: null,
|
|
2588
|
+
});
|
|
2589
|
+
});
|
|
2590
|
+
|
|
2591
|
+
// Issuing and revoking invites is the host's own privilege. These routes are
|
|
2592
|
+
// reachable through the tunnel like everything else, so a member must not be
|
|
2593
|
+
// able to call them and mint credentials for others.
|
|
2594
|
+
function requireLocal(req, res, next) {
|
|
2595
|
+
if (isRemoteRequest(req)) return res.status(403).json({ error: "host_only" });
|
|
2596
|
+
return next();
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
function inviteLink(room, code, project) {
|
|
2600
|
+
const base = String(room.url || "").replace(/\/$/, "");
|
|
2601
|
+
const u = new URL(`${base}/`);
|
|
2602
|
+
u.searchParams.set("relay_invite", code);
|
|
2603
|
+
const host = room.hostLogin || currentUser().login;
|
|
2604
|
+
const repo = room.repoKey || repoKeyFromRemote(project?.remoteUrl);
|
|
2605
|
+
if (host) u.searchParams.set("relay_host", host);
|
|
2606
|
+
if (repo) u.searchParams.set("relay_repo", repo);
|
|
2607
|
+
if (room.roomId) u.searchParams.set("relay_room", room.roomId);
|
|
2608
|
+
return u.toString();
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
async function ensureHosting(project) {
|
|
2612
|
+
const existing = loadRoom(project?.path);
|
|
2613
|
+
if (existing?.role === "guest") {
|
|
2614
|
+
return {
|
|
2615
|
+
ok: false,
|
|
2616
|
+
status: 409,
|
|
2617
|
+
error: "already_guest",
|
|
2618
|
+
hint: "Leave this room first if you want to host your own.",
|
|
2619
|
+
};
|
|
2620
|
+
}
|
|
2621
|
+
if (existing?.url && existing.role === "host") return { ok: true, room: existing, warning: null };
|
|
2622
|
+
const result = await shareRoom(parsePort(process.argv), shareExtras(project));
|
|
2623
|
+
if (!result.ok) return { ...result, status: 503 };
|
|
2624
|
+
syncRoomLocks();
|
|
2625
|
+
const warning = await publishAndWatch(result.room, project);
|
|
2626
|
+
return { ok: true, room: loadRoom(project?.path), warning };
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
app.post("/api/room/invite", requireLocal, async (req, res) => {
|
|
2630
|
+
const project = currentProject();
|
|
2631
|
+
const hosted = await ensureHosting(project);
|
|
2632
|
+
if (!hosted.ok) return res.status(hosted.status || 409).json(hosted);
|
|
2633
|
+
const room = hosted.room;
|
|
2634
|
+
const login = normLogin(req.body?.login);
|
|
2635
|
+
if (!login) return res.status(400).json({ error: "login_required" });
|
|
2636
|
+
// Warn — but do not block — when the invitee is not on the repo's
|
|
2637
|
+
// collaborator list. The host may be inviting someone they are about to add,
|
|
2638
|
+
// and the same check runs again at redeem time, which is what actually
|
|
2639
|
+
// decides admission.
|
|
2640
|
+
const check = await collaboratorCheck(project, login);
|
|
2641
|
+
const { ok, invite, code, error } = createInvite(room, { login, invitedBy: currentUser().login });
|
|
2642
|
+
if (!ok) return res.status(400).json({ error });
|
|
2643
|
+
saveRoom(room, project?.path);
|
|
2644
|
+
const warning = (await publishAndWatch(room, project)) || hosted.warning;
|
|
2645
|
+
res.status(201).json({
|
|
2646
|
+
ok: true,
|
|
2647
|
+
invite: inviteView(room).find((i) => i.id === invite.id),
|
|
2648
|
+
code,
|
|
2649
|
+
link: inviteLink(room, code, project),
|
|
2650
|
+
collaborator: check,
|
|
2651
|
+
warning,
|
|
2652
|
+
room: publicRoom(loadRoom(project?.path)),
|
|
2653
|
+
});
|
|
2654
|
+
});
|
|
2655
|
+
|
|
2656
|
+
app.get("/api/room/invites", requireLocal, (req, res) => {
|
|
2657
|
+
const room = loadRoom(currentProject()?.path);
|
|
2658
|
+
res.json({ invites: room ? inviteView(room) : [], open: Boolean(room?.open) });
|
|
2659
|
+
});
|
|
2660
|
+
|
|
2661
|
+
app.post("/api/room/invites/:id/revoke", requireLocal, (req, res) => {
|
|
2662
|
+
const project = currentProject();
|
|
2663
|
+
const room = loadRoom(project?.path);
|
|
2664
|
+
if (!room?.url) return res.status(409).json({ error: "not_hosting" });
|
|
2665
|
+
const result = revokeInvite(room, req.params.id);
|
|
2666
|
+
if (!result.ok) return res.status(404).json(result);
|
|
2667
|
+
saveRoom(room, project?.path);
|
|
2668
|
+
res.json({ ok: true, invites: inviteView(room) });
|
|
2669
|
+
});
|
|
2670
|
+
|
|
2671
|
+
app.post("/api/room/kick", requireLocal, (req, res) => {
|
|
2672
|
+
const project = currentProject();
|
|
2673
|
+
const room = loadRoom(project?.path);
|
|
2674
|
+
if (!room?.url) return res.status(409).json({ error: "not_hosting" });
|
|
2675
|
+
const login = normLogin(req.body?.login);
|
|
2676
|
+
if (!login) return res.status(400).json({ error: "login_required" });
|
|
2677
|
+
revokeMember(room, login);
|
|
2678
|
+
saveRoom(dropMember(room, login), project?.path);
|
|
2679
|
+
sse.emit("presence", { workspaceId: project?.id, userId: login, left: true });
|
|
2680
|
+
emitNotice(
|
|
2681
|
+
{
|
|
2682
|
+
type: "left",
|
|
2683
|
+
key: `left:${room.roomId || "room"}:${String(login).toLowerCase()}`,
|
|
2684
|
+
title: `@${login} left the room`,
|
|
2685
|
+
body: "Removed from the shared room — they stay on the GitHub team list.",
|
|
2686
|
+
workspaceId: project?.id || null,
|
|
2687
|
+
payload: { login },
|
|
2688
|
+
},
|
|
2689
|
+
{ revive: true }
|
|
2690
|
+
);
|
|
2691
|
+
res.json({ ok: true, members: membersView(loadRoom(project?.path)), invites: inviteView(room) });
|
|
2692
|
+
});
|
|
2693
|
+
|
|
2694
|
+
// The one room route a non-member may call, and only with a valid invite code.
|
|
2695
|
+
// Success mints the member token every other room request requires.
|
|
2696
|
+
app.post("/api/room/redeem", async (req, res) => {
|
|
2697
|
+
const project = currentProject();
|
|
2698
|
+
const room = loadRoom(project?.path);
|
|
2699
|
+
if (!room?.url || room.role !== "host") return res.status(409).json({ error: "not_hosting" });
|
|
2700
|
+
const user = req.body?.user;
|
|
2701
|
+
if (!user?.login) return res.status(400).json({ error: "user_required" });
|
|
2702
|
+
|
|
2703
|
+
if (!room.open) {
|
|
2704
|
+
const code = req.body?.code;
|
|
2705
|
+
const proofGistId = req.body?.proofGistId;
|
|
2706
|
+
if (!code) {
|
|
2707
|
+
if (!proofGistId || !room.roomId || !room.nonce) {
|
|
2708
|
+
return res.status(403).json({
|
|
2709
|
+
error: "invite_required",
|
|
2710
|
+
hint: "This room is invite-only. Ask the host to invite you from the Team tab.",
|
|
2711
|
+
});
|
|
2712
|
+
}
|
|
2713
|
+
const proof = await verifyProof({
|
|
2714
|
+
gistId: proofGistId,
|
|
2715
|
+
roomId: room.roomId,
|
|
2716
|
+
nonce: room.nonce,
|
|
2717
|
+
login: user.login,
|
|
2718
|
+
});
|
|
2719
|
+
if (!proof.ok) {
|
|
2720
|
+
return res.status(403).json({
|
|
2721
|
+
error: proof.reason,
|
|
2722
|
+
hint: "Could not verify your GitHub identity. Ask the host to invite you from the Team tab.",
|
|
2723
|
+
});
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
const redeemed = redeemInvite(room, code, user.login);
|
|
2727
|
+
if (!redeemed.ok) {
|
|
2728
|
+
return res.status(403).json({
|
|
2729
|
+
error: redeemed.reason,
|
|
2730
|
+
invitedLogin: redeemed.invitedLogin,
|
|
2731
|
+
hint:
|
|
2732
|
+
redeemed.reason === "invite_wrong_user"
|
|
2733
|
+
? `That invite was issued to @${redeemed.invitedLogin}.`
|
|
2734
|
+
: "Ask the host to invite you from the Team tab.",
|
|
2735
|
+
});
|
|
2736
|
+
}
|
|
2737
|
+
const check = await collaboratorCheck(project, user.login);
|
|
2738
|
+
if (!check.allowed) {
|
|
2739
|
+
return res.status(403).json({
|
|
2740
|
+
error: "not_a_collaborator",
|
|
2741
|
+
hint: `@${user.login} is not a collaborator on this repository.`,
|
|
2742
|
+
});
|
|
2743
|
+
}
|
|
2744
|
+
if (!redeemed.rejoin) markAccepted(redeemed.invite, user.login);
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
const token = issueMemberToken(room, user.login);
|
|
2748
|
+
upsertMember(room, user, "guest");
|
|
2749
|
+
upsertMember(room, { ...currentUser(), ...activeAgent(project?.id) }, "host");
|
|
2750
|
+
saveRoom(room, project?.path);
|
|
2751
|
+
sse.emit("presence", { workspaceId: project?.id, userId: user.login });
|
|
2752
|
+
console.log(`[relay-room] @${user.login} joined the room`);
|
|
2753
|
+
emitNotice(
|
|
2754
|
+
{
|
|
2755
|
+
type: "joined",
|
|
2756
|
+
key: `joined:${room.roomId || "room"}:${String(user.login).toLowerCase()}`,
|
|
2757
|
+
title: `@${user.login} joined the room`,
|
|
2758
|
+
body: `They're in ${project?.name || "this workspace"} with you.`,
|
|
2759
|
+
workspaceId: project?.id || null,
|
|
2760
|
+
payload: { login: user.login, roomId: room.roomId || null },
|
|
2761
|
+
},
|
|
2762
|
+
{ revive: true }
|
|
2763
|
+
);
|
|
2764
|
+
res.json({
|
|
2765
|
+
ok: true,
|
|
2766
|
+
token,
|
|
2767
|
+
roomId: room.roomId || null,
|
|
2768
|
+
members: membersView(room),
|
|
2769
|
+
hostProjectId: room.hostProjectId || project?.id || null,
|
|
2770
|
+
hostWorkspacePath: room.hostWorkspacePath || project?.path || null,
|
|
2771
|
+
hostLogin: currentUser().login,
|
|
2772
|
+
project: project
|
|
2773
|
+
? { id: project.id, name: project.name, pathName: path.basename(project.path), remoteUrl: project.remoteUrl }
|
|
2774
|
+
: null,
|
|
2775
|
+
});
|
|
2776
|
+
});
|
|
2777
|
+
|
|
2778
|
+
app.get("/api/room/members", requireRoomMember, (req, res) => {
|
|
2779
|
+
const { room } = hostRoomFor(req.query.projectId);
|
|
2780
|
+
res.json({ members: membersView(room) });
|
|
2781
|
+
});
|
|
2782
|
+
|
|
2783
|
+
app.post("/api/room/bye", requireRoomMember, (req, res) => {
|
|
2784
|
+
const { project, room } = hostRoomFor(req.body?.hostProjectId);
|
|
2785
|
+
if (!room?.url || !req.body?.login) return res.json({ ok: true });
|
|
2786
|
+
saveRoom(dropMember(room, req.body.login), project?.path);
|
|
2787
|
+
sse.emit("presence", { workspaceId: project?.id, userId: req.body.login, left: true });
|
|
2788
|
+
emitNotice(
|
|
2789
|
+
{
|
|
2790
|
+
type: "left",
|
|
2791
|
+
key: `left:${room.roomId || "room"}:${String(req.body.login).toLowerCase()}`,
|
|
2792
|
+
title: `@${req.body.login} left the room`,
|
|
2793
|
+
body: "They disconnected from the shared coordinator.",
|
|
2794
|
+
workspaceId: project?.id || null,
|
|
2795
|
+
},
|
|
2796
|
+
{ revive: true }
|
|
2797
|
+
);
|
|
2798
|
+
res.json({ ok: true });
|
|
2799
|
+
});
|
|
2800
|
+
|
|
2801
|
+
// --- Shared chat history --------------------------------------------------
|
|
2802
|
+
// The aggregate a guest pulls: this machine's own transcripts plus every other
|
|
2803
|
+
// member's, so guests see each other and not only the host.
|
|
2804
|
+
app.get("/api/room/state", requireRoomMember, (req, res) => {
|
|
2805
|
+
const project = req.query.projectId
|
|
2806
|
+
? loadRegistry().projects.find((p) => p.id === req.query.projectId) || currentProject()
|
|
2807
|
+
: currentProject();
|
|
2808
|
+
if (!project) return res.status(404).json({ error: "no_local_repo" });
|
|
2809
|
+
const login = currentUser().login || "local";
|
|
2810
|
+
const memory = loadMemory(project.id);
|
|
2811
|
+
const view = mergeRoomViews(memory, { exclude: req.query.exclude || null });
|
|
2812
|
+
res.json({
|
|
2813
|
+
ok: true,
|
|
2814
|
+
hostLogin: login,
|
|
2815
|
+
projectId: project.id,
|
|
2816
|
+
lastTranscriptSyncAt: memory.lastTranscriptSyncAt || 0,
|
|
2817
|
+
chats: view.chats,
|
|
2818
|
+
timeline: view.timeline,
|
|
2819
|
+
activity: view.activity,
|
|
2820
|
+
edits: view.edits,
|
|
2821
|
+
agents: view.agents,
|
|
2822
|
+
stats: memory.stats || {},
|
|
2823
|
+
roomCollisions: mergeCollisionStats(memory, { localLogin: login, exclude: req.query.exclude || null, projectId: project.id }),
|
|
2824
|
+
});
|
|
2825
|
+
});
|
|
2826
|
+
|
|
2827
|
+
app.post("/api/room/history", requireRoomMember, (req, res) => {
|
|
2828
|
+
const project = req.body?.projectId
|
|
2829
|
+
? loadRegistry().projects.find((p) => p.id === req.body.projectId) || currentProject()
|
|
2830
|
+
: currentProject();
|
|
2831
|
+
if (!project) return res.status(404).json({ error: "no_local_repo" });
|
|
2832
|
+
// Same rule as the socket path: a member may only publish its own history. A remote
|
|
2833
|
+
// caller is whoever its member token says; the login in the body is only trusted locally.
|
|
2834
|
+
const remote = Boolean(req.roomMember) || isRemoteRequest(req);
|
|
2835
|
+
const login = remote ? req.roomMember?.login || "" : String(req.body?.login || "").trim();
|
|
2836
|
+
if (!login) return res.status(remote ? 401 : 400).json({ error: remote ? "member_token_required" : "login_required" });
|
|
2837
|
+
ingestPeerHistory(login, req.body?.snapshot, project);
|
|
2838
|
+
res.json({ ok: true });
|
|
2839
|
+
});
|
|
2840
|
+
// --- MCP over HTTP ---------------------------------------------------------
|
|
2841
|
+
// The same tool surface `backend/mcp/server.js` serves over stdio, reachable
|
|
2842
|
+
// through the room tunnel — so an agent on another machine, or one with no
|
|
2843
|
+
// relay installed at all, can read what the room knows. Stateless: no session
|
|
2844
|
+
// id is issued, so there is nothing to expire and nothing to clean up.
|
|
2845
|
+
//
|
|
2846
|
+
// A caller that arrived from off-machine gets the read-only subset. Writing a
|
|
2847
|
+
// lock or a Central event as this process would attribute it to the host's
|
|
2848
|
+
// workspace and the host's user, and would put a second writer on the lock
|
|
2849
|
+
// table; guests do those through their own relay, which is also the path that
|
|
2850
|
+
// still works when the tunnel does not.
|
|
2851
|
+
app.post("/mcp", requireRoomMember, async (req, res) => {
|
|
2852
|
+
const project = currentProject();
|
|
2853
|
+
const remote = isRemoteRequest(req);
|
|
2854
|
+
const viewer = req.roomMember?.login || null;
|
|
2855
|
+
const ctx = {
|
|
2856
|
+
workspace: project?.path,
|
|
2857
|
+
projectId: project?.id,
|
|
2858
|
+
agentId: `mcp:http:${viewer || (remote ? "guest" : "local")}`,
|
|
2859
|
+
user: currentUser().login,
|
|
2860
|
+
owner: currentUser().login,
|
|
2861
|
+
readOnly: remote,
|
|
2862
|
+
viewer,
|
|
2863
|
+
};
|
|
2864
|
+
const batch = Array.isArray(req.body);
|
|
2865
|
+
const messages = batch ? req.body : [req.body];
|
|
2866
|
+
const replies = [];
|
|
2867
|
+
for (const msg of messages) {
|
|
2868
|
+
try {
|
|
2869
|
+
const reply = await mcpRpc(msg, ctx);
|
|
2870
|
+
if (reply) replies.push({ jsonrpc: "2.0", id: reply.id, result: reply.result });
|
|
2871
|
+
} catch (err) {
|
|
2872
|
+
// A failed tool is a JSON-RPC error, not an HTTP one: the client is
|
|
2873
|
+
// still talking, and a 500 would make it drop the whole connection.
|
|
2874
|
+
replies.push({ jsonrpc: "2.0", id: msg?.id ?? null, error: { code: -32000, message: err.message } });
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
// Nothing but notifications in the batch — the spec wants an empty 202.
|
|
2878
|
+
if (!replies.length) return res.status(202).end();
|
|
2879
|
+
res.json(batch ? replies : replies[0]);
|
|
2880
|
+
});
|
|
2881
|
+
|
|
2882
|
+
// The optional server-to-client stream and the session teardown. Declining
|
|
2883
|
+
// both is allowed, and a client that gets 405 here simply stops asking.
|
|
2884
|
+
app.get("/mcp", (_req, res) => res.status(405).json({ error: "streaming_not_supported" }));
|
|
2885
|
+
app.delete("/mcp", (_req, res) => res.status(405).json({ error: "stateless_no_sessions" }));
|
|
2886
|
+
|
|
2887
|
+
app.post("/api/room/adopt", (req, res) => {
|
|
2888
|
+
const url = String(req.body?.url || "").replace(/\/$/, "");
|
|
2889
|
+
if (!url) return res.status(400).json({ error: "url required" });
|
|
2890
|
+
const project = currentProject();
|
|
2891
|
+
const room = saveRoom(
|
|
2892
|
+
upsertMember(
|
|
2893
|
+
{
|
|
2894
|
+
role: "host",
|
|
2895
|
+
url,
|
|
2896
|
+
hostProjectId: project?.id || null,
|
|
2897
|
+
hostWorkspacePath: project?.path || null,
|
|
2898
|
+
hostProjectName: project?.name || null,
|
|
2899
|
+
projectId: project?.id || null,
|
|
2900
|
+
projectPath: project?.path || null,
|
|
2901
|
+
startedAt: new Date().toISOString(),
|
|
2902
|
+
members: [],
|
|
2903
|
+
},
|
|
2904
|
+
currentUser(),
|
|
2905
|
+
"host"
|
|
2906
|
+
),
|
|
2907
|
+
project?.path
|
|
2908
|
+
);
|
|
2909
|
+
syncRoomLocks();
|
|
2910
|
+
res.json({ ok: true, room });
|
|
2911
|
+
});
|
|
2912
|
+
// Where a guest's copy of the host's repo lands when it has no matching
|
|
2913
|
+
// workspace of its own.
|
|
2914
|
+
function defaultJoinDir(name) {
|
|
2915
|
+
return path.join(os.homedir(), "Documents", "Relay", String(name || "workspace").replace(/[<>:"|?*]/g, "-"));
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
// Resolves the workspace a joining guest will mirror into: an explicit path,
|
|
2919
|
+
// an existing workspace for the same repo, or a fresh folder named after the
|
|
2920
|
+
// host's. Registering it up front is what lets patches, locks and the seed all
|
|
2921
|
+
// agree on one target instead of falling back to "whatever is selected".
|
|
2922
|
+
function resolveJoinProject(hostMeta, explicitPath) {
|
|
2923
|
+
const reg = loadRegistry();
|
|
2924
|
+
if (explicitPath) {
|
|
2925
|
+
const resolved = normalizeWorkspaceRoot(explicitPath) || explicitPath;
|
|
2926
|
+
fs.mkdirSync(resolved, { recursive: true });
|
|
2927
|
+
return ensureWorkspace(resolved);
|
|
2928
|
+
}
|
|
2929
|
+
const match =
|
|
2930
|
+
(hostMeta.remoteUrl && reg.projects.find((p) => p.remoteUrl && p.remoteUrl === hostMeta.remoteUrl)) ||
|
|
2931
|
+
(hostMeta.name && reg.projects.find((p) => p.name === hostMeta.name)) ||
|
|
2932
|
+
(hostMeta.pathName && reg.projects.find((p) => path.basename(p.path) === hostMeta.pathName));
|
|
2933
|
+
if (match) return match;
|
|
2934
|
+
const target = defaultJoinDir(hostMeta.pathName || hostMeta.name);
|
|
2935
|
+
fs.mkdirSync(target, { recursive: true });
|
|
2936
|
+
return ensureWorkspace(target);
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
async function seedFromHost(url, hostProjectId, project) {
|
|
2940
|
+
const snap = await fetchHost(url, `/api/snapshot?full=1&projectId=${encodeURIComponent(hostProjectId || "")}`, {
|
|
2941
|
+
timeoutMs: SEED_TIMEOUT_MS,
|
|
2942
|
+
}).then((r) => r.json());
|
|
2943
|
+
applyingRemote.add(project.id);
|
|
2944
|
+
let written = [];
|
|
2945
|
+
let failed = [];
|
|
2946
|
+
try {
|
|
2947
|
+
({ written, failed } = writeTree(project.path, snap.files || []));
|
|
2948
|
+
} finally {
|
|
2949
|
+
applyingRemote.delete(project.id);
|
|
2950
|
+
}
|
|
2951
|
+
// Record what we just wrote as known content, so the filesystem watcher does
|
|
2952
|
+
// not immediately publish the whole seeded tree back to the host as if the
|
|
2953
|
+
// guest had authored it.
|
|
2954
|
+
const session = loadSession();
|
|
2955
|
+
for (const file of written) {
|
|
2956
|
+
const patch = computePatch({
|
|
2957
|
+
workspacePath: project.path,
|
|
2958
|
+
file,
|
|
2959
|
+
agent: "seed",
|
|
2960
|
+
ownerLogin: session.login || "local",
|
|
2961
|
+
workspaceId: project.id,
|
|
2962
|
+
});
|
|
2963
|
+
if (patch) {
|
|
2964
|
+
patch.originId = originId();
|
|
2965
|
+
recordPatch(project.id, patch);
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
return { files: written.length, failed: failed.length, truncated: Boolean(snap.truncated) };
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
// An invite link is the room URL plus a code, with host/repo/room stamped so
|
|
2972
|
+
// a guest can still find the room after ngrok recycles the URL.
|
|
2973
|
+
function decodeParam(value) {
|
|
2974
|
+
if (!value) return null;
|
|
2975
|
+
try {
|
|
2976
|
+
return decodeURIComponent(value);
|
|
2977
|
+
} catch {
|
|
2978
|
+
return value;
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2982
|
+
function splitInviteUrl(raw) {
|
|
2983
|
+
const text = String(raw || "").trim();
|
|
2984
|
+
const grab = (name) => {
|
|
2985
|
+
const m = text.match(new RegExp(`[?&#]${name}=([^&#\\s]+)`));
|
|
2986
|
+
return m ? decodeParam(m[1]) : null;
|
|
2987
|
+
};
|
|
2988
|
+
const code = grab("relay_invite");
|
|
2989
|
+
const host = grab("relay_host");
|
|
2990
|
+
const repo = grab("relay_repo");
|
|
2991
|
+
const roomId = grab("relay_room");
|
|
2992
|
+
let url = text;
|
|
2993
|
+
const first = text.search(/[?&#]relay_(invite|host|repo|room)=/);
|
|
2994
|
+
if (first >= 0) url = text.slice(0, first).replace(/[?#]$/, "").replace(/\/$/, "");
|
|
2995
|
+
if (!/^https?:\/\//i.test(url)) url = "";
|
|
2996
|
+
return { url, code, host, repo, roomId };
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
async function resolveJoinTarget(body) {
|
|
3000
|
+
const parsed = splitInviteUrl(body?.url);
|
|
3001
|
+
const code = body?.code || parsed.code;
|
|
3002
|
+
const hostLogin = body?.hostLogin || parsed.host;
|
|
3003
|
+
const repoKey = body?.repoKey || parsed.repo || repoKeyFromRemote(currentProject()?.remoteUrl);
|
|
3004
|
+
const roomId = body?.roomId || parsed.roomId;
|
|
3005
|
+
let url = parsed.url;
|
|
3006
|
+
if (!url && body?.url && /^https?:\/\//i.test(String(body.url).trim())) {
|
|
3007
|
+
url = String(body.url).replace(/\/$/, "");
|
|
3008
|
+
}
|
|
3009
|
+
const lookup = () =>
|
|
3010
|
+
findSignal({
|
|
3011
|
+
gistId: body?.gistId,
|
|
3012
|
+
hostLogin,
|
|
3013
|
+
repoKey,
|
|
3014
|
+
roomId,
|
|
3015
|
+
});
|
|
3016
|
+
let signal = null;
|
|
3017
|
+
if (url) {
|
|
3018
|
+
try {
|
|
3019
|
+
await fetchHost(url, "/api/health", { token: null, timeoutMs: 4000 });
|
|
3020
|
+
} catch {
|
|
3021
|
+
signal = await lookup().catch(() => null);
|
|
3022
|
+
if (signal?.url) url = signal.url;
|
|
3023
|
+
else {
|
|
3024
|
+
const err = new Error("unreachable");
|
|
3025
|
+
err.status = 502;
|
|
3026
|
+
throw err;
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
} else {
|
|
3030
|
+
signal = await lookup();
|
|
3031
|
+
if (!signal?.url) return { error: "room_not_found", hint: "The host is not sharing right now." };
|
|
3032
|
+
url = signal.url;
|
|
3033
|
+
}
|
|
3034
|
+
if (!signal && (hostLogin || body?.gistId)) {
|
|
3035
|
+
signal = await lookup().catch(() => null);
|
|
3036
|
+
}
|
|
3037
|
+
return { url, code, signal, hostLogin, repoKey, roomId };
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
app.get("/api/room/discover", requireLocal, async (_req, res) => {
|
|
3041
|
+
const project = currentProject();
|
|
3042
|
+
try {
|
|
3043
|
+
const rooms = await refreshOpenRooms(project);
|
|
3044
|
+
res.json({ ok: true, rooms });
|
|
3045
|
+
} catch (err) {
|
|
3046
|
+
res.json({ ok: false, rooms: [], error: String(err.message || err) });
|
|
3047
|
+
}
|
|
3048
|
+
});
|
|
3049
|
+
|
|
3050
|
+
app.get("/api/notices", requireLocal, (_req, res) => {
|
|
3051
|
+
res.json({ ok: true, notices: listNotices(), unread: unreadCount() });
|
|
3052
|
+
});
|
|
3053
|
+
|
|
3054
|
+
app.post("/api/notices/read-all", requireLocal, (_req, res) => {
|
|
3055
|
+
res.json({ ok: true, notices: markAllRead(), unread: 0 });
|
|
3056
|
+
});
|
|
3057
|
+
|
|
3058
|
+
app.post("/api/notices/:id/read", requireLocal, (req, res) => {
|
|
3059
|
+
markRead(req.params.id);
|
|
3060
|
+
res.json({ ok: true, notices: listNotices(), unread: unreadCount() });
|
|
3061
|
+
});
|
|
3062
|
+
|
|
3063
|
+
app.post("/api/notices/dismiss", requireLocal, (req, res) => {
|
|
3064
|
+
if (req.body?.id) dismissNotice(req.body.id);
|
|
3065
|
+
else if (req.body?.key) dismissByKey(req.body.key);
|
|
3066
|
+
res.json({ ok: true, notices: listNotices(), unread: unreadCount() });
|
|
3067
|
+
});
|
|
3068
|
+
|
|
3069
|
+
app.post("/api/room/join", async (req, res) => {
|
|
3070
|
+
const target = await resolveJoinTarget(req.body || {}).catch((err) => ({ error: "unreachable", detail: String(err.message || err) }));
|
|
3071
|
+
if (target.error) {
|
|
3072
|
+
return res.status(target.error === "room_not_found" ? 404 : 502).json(target);
|
|
3073
|
+
}
|
|
3074
|
+
const { url, code, signal } = target;
|
|
3075
|
+
const seed = req.body?.seed !== false;
|
|
3076
|
+
try {
|
|
3077
|
+
// /api/health needs no credential, and tells us whether this host is even
|
|
3078
|
+
// running a room before we spend an invite on it.
|
|
3079
|
+
const health = await fetchHost(url, "/api/health", { token: null }).then((r) => r.json());
|
|
3080
|
+
|
|
3081
|
+
let proofGistId = null;
|
|
3082
|
+
try {
|
|
3083
|
+
if (signal?.nonce && signal?.roomId) {
|
|
3084
|
+
proofGistId = await createProof({
|
|
3085
|
+
roomId: signal.roomId,
|
|
3086
|
+
nonce: signal.nonce,
|
|
3087
|
+
login: currentUser().login,
|
|
3088
|
+
});
|
|
3089
|
+
}
|
|
3090
|
+
} catch {
|
|
3091
|
+
/* invite code still works without a proof gist */
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
// Redeem before anything else: until the host mints a member token, every
|
|
3095
|
+
// other room route answers 401 and there is nothing useful to do.
|
|
3096
|
+
let admission;
|
|
3097
|
+
try {
|
|
3098
|
+
admission = await fetchHost(url, "/api/room/redeem", {
|
|
3099
|
+
token: null,
|
|
3100
|
+
method: "POST",
|
|
3101
|
+
body: JSON.stringify({ code, user: currentUser(), proofGistId }),
|
|
3102
|
+
}).then((r) => r.json());
|
|
3103
|
+
} catch (err) {
|
|
3104
|
+
if (err.status === 403 || err.status === 409) {
|
|
3105
|
+
const detail = await fetch(`${url}/api/room/redeem`, {
|
|
3106
|
+
method: "POST",
|
|
3107
|
+
headers: { "Content-Type": "application/json", "ngrok-skip-browser-warning": "relay" },
|
|
3108
|
+
body: JSON.stringify({ code, user: currentUser(), proofGistId }),
|
|
3109
|
+
})
|
|
3110
|
+
.then((r) => r.json())
|
|
3111
|
+
.catch(() => ({}));
|
|
3112
|
+
return res.status(403).json({
|
|
3113
|
+
error: detail.error || "invite_required",
|
|
3114
|
+
hint: detail.hint || "This room is invite-only. Ask the host to invite you from the Team tab.",
|
|
3115
|
+
});
|
|
3116
|
+
}
|
|
3117
|
+
throw err;
|
|
3118
|
+
} finally {
|
|
3119
|
+
if (proofGistId) deleteGist(proofGistId).catch(() => undefined);
|
|
3120
|
+
}
|
|
3121
|
+
|
|
3122
|
+
const hostProject = admission.project || null;
|
|
3123
|
+
const hostMeta = {
|
|
3124
|
+
remoteUrl: hostProject?.remoteUrl || null,
|
|
3125
|
+
name: hostProject?.name || null,
|
|
3126
|
+
pathName: hostProject?.pathName || hostProject?.name || null,
|
|
3127
|
+
};
|
|
3128
|
+
const project = resolveJoinProject(hostMeta, req.body?.path);
|
|
3129
|
+
if (!project) return res.status(400).json({ error: "no_local_workspace" });
|
|
3130
|
+
|
|
3131
|
+
const room = saveRoom(
|
|
3132
|
+
{
|
|
3133
|
+
role: "guest",
|
|
3134
|
+
url,
|
|
3135
|
+
memberToken: admission.token || null,
|
|
3136
|
+
roomId: admission.roomId || signal?.roomId || null,
|
|
3137
|
+
signalGistId: signal?.gistId || null,
|
|
3138
|
+
repoKey: signal?.repoKey || repoKeyFromRemote(hostMeta.remoteUrl) || target.repoKey || null,
|
|
3139
|
+
hostProjectId: admission.hostProjectId || health.room?.hostProjectId || null,
|
|
3140
|
+
hostWorkspacePath: admission.hostWorkspacePath || health.room?.hostWorkspacePath || null,
|
|
3141
|
+
hostProjectName: hostMeta.name,
|
|
3142
|
+
hostLogin: admission.hostLogin || signal?.hostLogin || target.hostLogin || null,
|
|
3143
|
+
projectId: project.id,
|
|
3144
|
+
projectPath: project.path,
|
|
3145
|
+
startedAt: new Date().toISOString(),
|
|
3146
|
+
members: Array.isArray(admission.members) ? admission.members : [],
|
|
3147
|
+
},
|
|
3148
|
+
project.path
|
|
3149
|
+
);
|
|
3150
|
+
|
|
3151
|
+
connectHostPatches(room);
|
|
3152
|
+
connectHostControl(room);
|
|
3153
|
+
syncRoomLocks();
|
|
3154
|
+
|
|
3155
|
+
let seeded = null;
|
|
3156
|
+
if (seed) {
|
|
3157
|
+
try {
|
|
3158
|
+
seeded = await seedFromHost(url, room.hostProjectId, project);
|
|
3159
|
+
} catch (err) {
|
|
3160
|
+
seeded = { error: String(err.message || err) };
|
|
3161
|
+
}
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3164
|
+
// Uncommitted work the host has on top of the seed.
|
|
3165
|
+
const pulled = [];
|
|
3166
|
+
try {
|
|
3167
|
+
const snap = await fetchHost(url, "/api/snapshot", { timeoutMs: SEED_TIMEOUT_MS }).then((r) => r.json());
|
|
3168
|
+
const hint = { remoteUrl: snap.remoteUrl, name: snap.name, pathName: snap.pathName };
|
|
3169
|
+
for (const patch of snap.patches || []) {
|
|
3170
|
+
const result = acceptPeerPatch(patch, hint, { skipIfOrigin: true });
|
|
3171
|
+
if (result.applied) pulled.push(patch.file);
|
|
3172
|
+
}
|
|
3173
|
+
} catch {
|
|
3174
|
+
/* guest can relay pull later */
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
try {
|
|
3178
|
+
await pullRoomHistory(room, project);
|
|
3179
|
+
} catch {
|
|
3180
|
+
/* history arrives on the next poll */
|
|
3181
|
+
}
|
|
3182
|
+
pushRoomHistory(room, project).catch(() => undefined);
|
|
3183
|
+
publishGraph(project);
|
|
3184
|
+
sse.emit("projects", { projects: loadRegistry().projects });
|
|
3185
|
+
if (room.roomId) dismissByKey(`invite:${room.roomId}`);
|
|
3186
|
+
emitNotice({
|
|
3187
|
+
type: "joined",
|
|
3188
|
+
key: `in:${room.roomId || "room"}`,
|
|
3189
|
+
title: `You're in @${room.hostLogin || "host"}'s room`,
|
|
3190
|
+
body: room.hostProjectName ? `Sharing ${room.hostProjectName}.` : "Locks and live patches are shared.",
|
|
3191
|
+
workspaceId: project.id,
|
|
3192
|
+
});
|
|
3193
|
+
|
|
3194
|
+
res.json({ ok: true, room: publicRoom(room), project, seeded, pulled });
|
|
3195
|
+
} catch (err) {
|
|
3196
|
+
res.status(502).json({ error: "unreachable", detail: String(err.message || err) });
|
|
3197
|
+
}
|
|
3198
|
+
});
|
|
3199
|
+
app.post("/api/room/leave", async (_req, res) => {
|
|
3200
|
+
const room = loadRoom();
|
|
3201
|
+
if (room?.role === "host") {
|
|
3202
|
+
stopTunnelWatch();
|
|
3203
|
+
killCloudflared();
|
|
3204
|
+
try {
|
|
3205
|
+
await publishSignal(
|
|
3206
|
+
{ ...room, live: false },
|
|
3207
|
+
{
|
|
3208
|
+
repoKey: room.repoKey || repoKeyFromRemote(currentProject()?.remoteUrl),
|
|
3209
|
+
hostLogin: currentUser().login,
|
|
3210
|
+
}
|
|
3211
|
+
);
|
|
3212
|
+
} catch {
|
|
3213
|
+
/* leaving still clears locally */
|
|
3214
|
+
}
|
|
3215
|
+
}
|
|
3216
|
+
if (room?.role === "guest" && room.url) {
|
|
3217
|
+
// Tell the host before tearing down, so the roster flips this member to
|
|
3218
|
+
// offline immediately instead of after the 45s presence timeout.
|
|
3219
|
+
await fetchHost(room.url, "/api/room/bye", {
|
|
3220
|
+
method: "POST",
|
|
3221
|
+
body: JSON.stringify({ login: currentUser().login, hostProjectId: room.hostProjectId }),
|
|
3222
|
+
}).catch(() => undefined);
|
|
3223
|
+
}
|
|
3224
|
+
// saveRoom() stamps room.json into every workspace it touches, so clearing
|
|
3225
|
+
// only the selected one leaves other workspaces still pointed at a room the
|
|
3226
|
+
// user just left.
|
|
3227
|
+
clearRoom(currentProject()?.path);
|
|
3228
|
+
for (const p of loadRegistry().projects) {
|
|
3229
|
+
clearRoom(p.path);
|
|
3230
|
+
// A peer's chats are only meaningful while the room lasts; leaving them
|
|
3231
|
+
// behind makes a solo workspace look like it still has collaborators.
|
|
3232
|
+
clearPeers(p.id);
|
|
3233
|
+
}
|
|
3234
|
+
lastPushedSync.clear();
|
|
3235
|
+
roomLocks.start(null);
|
|
3236
|
+
if (hostRetryTimer) {
|
|
3237
|
+
clearTimeout(hostRetryTimer);
|
|
3238
|
+
hostRetryTimer = null;
|
|
3239
|
+
}
|
|
3240
|
+
if (hostSocket) {
|
|
3241
|
+
try {
|
|
3242
|
+
hostSocket.close();
|
|
3243
|
+
} catch {
|
|
3244
|
+
/* ignore */
|
|
3245
|
+
}
|
|
3246
|
+
hostSocket = null;
|
|
3247
|
+
}
|
|
3248
|
+
res.json({ ok: true, room: null });
|
|
3249
|
+
});
|
|
3250
|
+
|
|
3251
|
+
app.get("/api/snapshot", requireRoomMember, (req, res) => {
|
|
3252
|
+
const project = req.query.projectId
|
|
3253
|
+
? loadRegistry().projects.find((p) => p.id === req.query.projectId) || currentProject()
|
|
3254
|
+
: req.query.path
|
|
3255
|
+
? projectForPath(req.query.path)
|
|
3256
|
+
: currentProject();
|
|
3257
|
+
if (!project) return res.status(404).json({ error: "no_local_repo" });
|
|
3258
|
+
const session = loadSession();
|
|
3259
|
+
// A joining guest needs the whole tree, not just what git calls dirty:
|
|
3260
|
+
// the dirty-only payload leaves anyone without a prior clone staring at an
|
|
3261
|
+
// empty folder.
|
|
3262
|
+
if (req.query.full === "1" || req.query.full === "true") {
|
|
3263
|
+
const { files, truncated } = treeFiles(project.path);
|
|
3264
|
+
return res.json({
|
|
3265
|
+
ok: true,
|
|
3266
|
+
full: true,
|
|
3267
|
+
remoteUrl: project.remoteUrl,
|
|
3268
|
+
name: project.name,
|
|
3269
|
+
pathName: path.basename(project.path),
|
|
3270
|
+
workspacePath: project.path,
|
|
3271
|
+
files,
|
|
3272
|
+
truncated,
|
|
3273
|
+
});
|
|
3274
|
+
}
|
|
3275
|
+
const patches = dirtyFiles(project.path)
|
|
3276
|
+
.map((file) =>
|
|
3277
|
+
computePatch({
|
|
3278
|
+
workspacePath: project.path,
|
|
3279
|
+
file,
|
|
3280
|
+
agent: "snapshot",
|
|
3281
|
+
ownerLogin: session.login || "local",
|
|
3282
|
+
workspaceId: project.id,
|
|
3283
|
+
})
|
|
3284
|
+
)
|
|
3285
|
+
.filter(Boolean)
|
|
3286
|
+
.map((patch) => {
|
|
3287
|
+
patch.originId = originId();
|
|
3288
|
+
patch.originAgentId = originId();
|
|
3289
|
+
return patch;
|
|
3290
|
+
});
|
|
3291
|
+
res.json({
|
|
3292
|
+
ok: true,
|
|
3293
|
+
remoteUrl: project.remoteUrl,
|
|
3294
|
+
name: project.name,
|
|
3295
|
+
pathName: path.basename(project.path),
|
|
3296
|
+
patches,
|
|
3297
|
+
});
|
|
3298
|
+
});
|
|
3299
|
+
|
|
3300
|
+
app.post("/api/ask", async (req, res) => {
|
|
3301
|
+
const project = req.body?.workspacePath ? projectForPath(req.body.workspacePath) : currentProject();
|
|
3302
|
+
if (!project) return res.status(404).json({ error: "no_local_repo" });
|
|
3303
|
+
const limit = Math.min(80, Math.max(5, Number(req.body?.limit) || 30));
|
|
3304
|
+
const writeFile = req.body?.write !== false;
|
|
3305
|
+
const room = loadRoom(project.path);
|
|
3306
|
+
try {
|
|
3307
|
+
await syncTranscriptsQueued(project, { ownerLogin: currentUser().login || "local" });
|
|
3308
|
+
} catch {
|
|
3309
|
+
/* transcripts are best-effort */
|
|
3310
|
+
}
|
|
3311
|
+
if (room?.role === "guest" && room.url) {
|
|
3312
|
+
await pullRoomHistory(room, project).catch(() => undefined);
|
|
3313
|
+
}
|
|
3314
|
+
const memory = loadMemory(project.id);
|
|
3315
|
+
const view = mergeRoomViews(memory);
|
|
3316
|
+
const user = currentUser();
|
|
3317
|
+
const brief = compileRoomAsk({
|
|
3318
|
+
view,
|
|
3319
|
+
room,
|
|
3320
|
+
login: user.login,
|
|
3321
|
+
locks: locksFor({ id: project.id, path: project.path }),
|
|
3322
|
+
limit,
|
|
3323
|
+
});
|
|
3324
|
+
let written = null;
|
|
3325
|
+
if (writeFile) {
|
|
3326
|
+
const out = path.join(project.path, ".relay", "relay_ask.md");
|
|
3327
|
+
try {
|
|
3328
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
3329
|
+
fs.writeFileSync(out, brief.markdown);
|
|
3330
|
+
written = out;
|
|
3331
|
+
} catch (err) {
|
|
3332
|
+
return res.status(500).json({ error: "write_failed", detail: String(err.message || err) });
|
|
3333
|
+
}
|
|
3334
|
+
}
|
|
3335
|
+
res.json({ ok: true, written, markdown: brief.markdown, data: brief.json });
|
|
3336
|
+
});
|
|
3337
|
+
|
|
3338
|
+
app.post("/api/push", (req, res) => {
|
|
3339
|
+
const project = req.body?.workspacePath ? projectForPath(req.body.workspacePath) : currentProject();
|
|
3340
|
+
if (!project) return res.status(404).json({ error: "no_local_repo" });
|
|
3341
|
+
const files = dirtyFiles(project.path);
|
|
3342
|
+
const flushed = files.map((file) => fanOutLocalFile(project, file, originId()));
|
|
3343
|
+
res.json({
|
|
3344
|
+
ok: true,
|
|
3345
|
+
files: flushed.filter((r) => r.flushed).map((r) => r.file),
|
|
3346
|
+
skipped: flushed.filter((r) => !r.flushed).length,
|
|
3347
|
+
});
|
|
3348
|
+
});
|
|
3349
|
+
|
|
3350
|
+
app.post("/api/pull", async (req, res) => {
|
|
3351
|
+
const project = req.body?.workspacePath ? projectForPath(req.body.workspacePath) : currentProject();
|
|
3352
|
+
const room = loadRoom(project?.path);
|
|
3353
|
+
if (!room?.url) return res.status(400).json({ error: "not_in_a_room", hint: "Join a shared room first" });
|
|
3354
|
+
if (room.role === "host") {
|
|
3355
|
+
return res.json({ ok: true, files: [], note: "host already has the working tree" });
|
|
3356
|
+
}
|
|
3357
|
+
try {
|
|
3358
|
+
const snap = await fetch(`${room.url.replace(/\/$/, "")}/api/snapshot`).then((r) => r.json());
|
|
3359
|
+
const hint = { remoteUrl: snap.remoteUrl, name: snap.name, pathName: snap.pathName };
|
|
3360
|
+
const applied = [];
|
|
3361
|
+
for (const patch of snap.patches || []) {
|
|
3362
|
+
const result = acceptPeerPatch(patch, hint, { skipIfOrigin: true });
|
|
3363
|
+
if (result.applied) applied.push(patch.file);
|
|
3364
|
+
}
|
|
3365
|
+
res.json({ ok: true, files: applied });
|
|
3366
|
+
} catch (err) {
|
|
3367
|
+
res.status(502).json({ error: "unreachable", detail: String(err.message || err) });
|
|
3368
|
+
}
|
|
3369
|
+
});
|
|
3370
|
+
|
|
3371
|
+
app.post("/api/patches", requireRoomMember, (req, res) => {
|
|
3372
|
+
const patch = req.body?.patch;
|
|
3373
|
+
if (!patch) return res.status(400).json({ error: "patch_required" });
|
|
3374
|
+
const hint = {
|
|
3375
|
+
remoteUrl: req.body.remoteUrl,
|
|
3376
|
+
name: req.body.name,
|
|
3377
|
+
pathName: req.body.pathName,
|
|
3378
|
+
};
|
|
3379
|
+
const result = acceptPeerPatch(patch, hint, { skipIfOrigin: true });
|
|
3380
|
+
broadcastPatch({
|
|
3381
|
+
workspaceId: result.projectId || req.body.workspaceId,
|
|
3382
|
+
patch,
|
|
3383
|
+
incoming: true,
|
|
3384
|
+
remoteUrl: hint.remoteUrl,
|
|
3385
|
+
name: hint.name,
|
|
3386
|
+
pathName: hint.pathName,
|
|
3387
|
+
originId: patch.originId,
|
|
3388
|
+
});
|
|
3389
|
+
res.json({ ok: true, relayed: true, ...result });
|
|
3390
|
+
});
|
|
3391
|
+
|
|
3392
|
+
app.get("/api/projects/:id/patches", (req, res) => {
|
|
3393
|
+
const project = loadRegistry().projects.find((p) => p.id === req.params.id);
|
|
3394
|
+
if (!project) return res.status(404).json({ error: "not_found" });
|
|
3395
|
+
const memory = loadMemory(project.id);
|
|
3396
|
+
res.json({ patches: memory.patches || [], lastAppliedLamport: memory.lastAppliedLamport || 0 });
|
|
3397
|
+
});
|
|
3398
|
+
|
|
3399
|
+
app.post("/api/projects/:id/rewind", (req, res) => {
|
|
3400
|
+
const project = loadRegistry().projects.find((p) => p.id === req.params.id);
|
|
3401
|
+
if (!project) return res.status(404).json({ error: "not_found" });
|
|
3402
|
+
const lamport = Number(req.body?.lamport || 0);
|
|
3403
|
+
applyingRemote.add(project.id);
|
|
3404
|
+
const result = rewindTo(project.id, project.path, lamport);
|
|
3405
|
+
applyingRemote.delete(project.id);
|
|
3406
|
+
sse.emit("rewind", { workspaceId: project.id, lamport, count: result.patches.length });
|
|
3407
|
+
res.json({ ok: true, ...result });
|
|
3408
|
+
});
|
|
3409
|
+
|
|
3410
|
+
app.post("/api/flush-file", (req, res) => {
|
|
3411
|
+
const workspacePath = req.body?.workspacePath;
|
|
3412
|
+
const agentId = req.body?.agentId;
|
|
3413
|
+
const files = Array.isArray(req.body?.files) && req.body.files.length
|
|
3414
|
+
? req.body.files
|
|
3415
|
+
: req.body?.file
|
|
3416
|
+
? [req.body.file]
|
|
3417
|
+
: [];
|
|
3418
|
+
const project = projectForPath(workspacePath);
|
|
3419
|
+
if (!project) return res.status(404).json({ error: "no_local_repo" });
|
|
3420
|
+
const flushed = files.map((file) => fanOutLocalFile(project, file, agentId));
|
|
3421
|
+
res.json({ ok: true, flushed });
|
|
3422
|
+
});
|
|
3423
|
+
|
|
3424
|
+
app.post("/api/flush-owned", (req, res) => {
|
|
3425
|
+
const workspacePath = req.body?.workspacePath;
|
|
3426
|
+
const agentId = req.body?.agentId;
|
|
3427
|
+
const project = projectForPath(workspacePath);
|
|
3428
|
+
if (!project) return res.status(404).json({ error: "no_local_repo" });
|
|
3429
|
+
const { sameOwner } = require("./coordinator/lockTable");
|
|
3430
|
+
const held = lockTable.list({ id: project.id, path: project.path }).filter((e) => sameOwner(e.agentId, agentId));
|
|
3431
|
+
const flushed = held.map((e) => fanOutLocalFile(project, e.filePath, agentId));
|
|
3432
|
+
res.json({ ok: true, flushed });
|
|
3433
|
+
});
|
|
3434
|
+
|
|
3435
|
+
app.post("/api/claim", (req, res) => {
|
|
3436
|
+
const result = lockTable.claim(req.body || {});
|
|
3437
|
+
res.status(result.allowed ? 200 : 409).json(result);
|
|
3438
|
+
});
|
|
3439
|
+
|
|
3440
|
+
app.post("/api/release", (req, res) => {
|
|
3441
|
+
res.json(lockTable.release(req.body || {}));
|
|
3442
|
+
});
|
|
3443
|
+
|
|
3444
|
+
app.post("/api/release-all", (req, res) => {
|
|
3445
|
+
res.json(lockTable.releaseAll(req.body?.agentId));
|
|
3446
|
+
});
|
|
3447
|
+
|
|
3448
|
+
app.post("/api/central/projects", requireCentral, (req, res) => {
|
|
3449
|
+
if (!req.central.admin) return res.status(403).json({ error: "admin_only" });
|
|
3450
|
+
res.status(201).json(createProject(req.body?.name));
|
|
3451
|
+
});
|
|
3452
|
+
|
|
3453
|
+
app.get("/api/central/projects", requireCentral, (req, res) => {
|
|
3454
|
+
if (!req.central.admin) return res.status(403).json({ error: "admin_only" });
|
|
3455
|
+
res.json({ projects: loadProjects() });
|
|
3456
|
+
});
|
|
3457
|
+
|
|
3458
|
+
app.get("/api/central/projects/:id", requireCentral, async (req, res) => {
|
|
3459
|
+
if (!req.central.admin && req.central.project?.id !== req.params.id) {
|
|
3460
|
+
return res.status(403).json({ error: "project_mismatch" });
|
|
3461
|
+
}
|
|
3462
|
+
const project = loadProjects().find((p) => p.id === req.params.id);
|
|
3463
|
+
if (!project) return res.status(404).json({ error: "not_found" });
|
|
3464
|
+
res.json({ project });
|
|
3465
|
+
});
|
|
3466
|
+
|
|
3467
|
+
app.post("/api/central/projects/:id/events", requireCentral, async (req, res) => {
|
|
3468
|
+
if (!req.central.admin && req.central.project?.id !== req.params.id) {
|
|
3469
|
+
return res.status(403).json({ error: "project_mismatch" });
|
|
3470
|
+
}
|
|
3471
|
+
const stored = await appendEvent(req.params.id, req.body || {});
|
|
3472
|
+
sse.emit("central", { workspaceId: req.params.id, event: stored });
|
|
3473
|
+
res.json(stored);
|
|
3474
|
+
});
|
|
3475
|
+
|
|
3476
|
+
app.get("/api/central/projects/:id/events", requireCentral, async (req, res) => {
|
|
3477
|
+
if (!req.central.admin && req.central.project?.id !== req.params.id) {
|
|
3478
|
+
return res.status(403).json({ error: "project_mismatch" });
|
|
3479
|
+
}
|
|
3480
|
+
const events = await listEvents(req.params.id, {
|
|
3481
|
+
since: req.query.since,
|
|
3482
|
+
limit: req.query.limit,
|
|
3483
|
+
kind: req.query.kind,
|
|
3484
|
+
});
|
|
3485
|
+
res.json({ events });
|
|
3486
|
+
});
|
|
3487
|
+
|
|
3488
|
+
app.get("/api/central/projects/:id/context", requireCentral, async (req, res) => {
|
|
3489
|
+
if (!req.central.admin && req.central.project?.id !== req.params.id) {
|
|
3490
|
+
return res.status(403).json({ error: "project_mismatch" });
|
|
3491
|
+
}
|
|
3492
|
+
const events = await listEvents(req.params.id, { limit: 1000 });
|
|
3493
|
+
res.json(jsonContext(events));
|
|
3494
|
+
});
|
|
3495
|
+
|
|
3496
|
+
app.get("/api/central/projects/:id/changes", requireCentral, async (req, res) => {
|
|
3497
|
+
if (!req.central.admin && req.central.project?.id !== req.params.id) {
|
|
3498
|
+
return res.status(403).json({ error: "project_mismatch" });
|
|
3499
|
+
}
|
|
3500
|
+
const events = await listEvents(req.params.id, { kind: "change", limit: 200 });
|
|
3501
|
+
res.json({ changes: events });
|
|
3502
|
+
});
|
|
3503
|
+
|
|
3504
|
+
app.get("/api/central/projects/:id/decisions", requireCentral, async (req, res) => {
|
|
3505
|
+
if (!req.central.admin && req.central.project?.id !== req.params.id) {
|
|
3506
|
+
return res.status(403).json({ error: "project_mismatch" });
|
|
3507
|
+
}
|
|
3508
|
+
res.json({ decisions: jsonContext(await listEvents(req.params.id, { limit: 1000 })).decisions });
|
|
3509
|
+
});
|
|
3510
|
+
|
|
3511
|
+
app.get("/api/central/projects/:id/tasks", requireCentral, async (req, res) => {
|
|
3512
|
+
if (!req.central.admin && req.central.project?.id !== req.params.id) {
|
|
3513
|
+
return res.status(403).json({ error: "project_mismatch" });
|
|
3514
|
+
}
|
|
3515
|
+
res.json({ tasks: jsonContext(await listEvents(req.params.id, { limit: 1000 })).tasks });
|
|
3516
|
+
});
|
|
3517
|
+
|
|
3518
|
+
app.get("/api/fs", requireLocal, async (req, res) => {
|
|
3519
|
+
const { status, body } = await listDir(req.query.path ? String(req.query.path) : null);
|
|
3520
|
+
res.status(status).json(body);
|
|
3521
|
+
});
|
|
3522
|
+
app.get("/api/github/repos", requireLocal, async (req, res) => {
|
|
3523
|
+
res.json(await listGithubRepos({ refresh: req.query.refresh === "1" }));
|
|
3524
|
+
});
|
|
3525
|
+
|
|
3526
|
+
const uiDist = path.join(__dirname, "..", "mission-control", "out");
|
|
3527
|
+
if (fs.existsSync(uiDist)) {
|
|
3528
|
+
app.use(express.static(uiDist));
|
|
3529
|
+
app.get("/", (_req, res) => res.sendFile(path.join(uiDist, "index.html")));
|
|
3530
|
+
} else {
|
|
3531
|
+
app.get("/", (_req, res) => {
|
|
3532
|
+
res.type("html").send(`<!doctype html><html><body style="background:#0b0c0f;color:#e7e7ea;font-family:sans-serif;padding:48px">
|
|
3533
|
+
<h1>Relay API</h1>
|
|
3534
|
+
<p>Coordinator on 127.0.0.1:${coordPort}. Open Mission Control at <a href="${UI_ORIGIN}" style="color:#f59e0b">${UI_ORIGIN}</a>.</p>
|
|
3535
|
+
</body></html>`);
|
|
3536
|
+
});
|
|
3537
|
+
}
|
|
3538
|
+
|
|
3539
|
+
const existingRoom = loadRoom();
|
|
3540
|
+
if (existingRoom?.role === "guest") {
|
|
3541
|
+
connectHostPatches(existingRoom);
|
|
3542
|
+
connectHostControl(existingRoom);
|
|
3543
|
+
}
|
|
3544
|
+
if (existingRoom?.url) syncRoomLocks();
|
|
3545
|
+
|
|
3546
|
+
const port = parsePort(process.argv);
|
|
3547
|
+
const bindHost = process.env.RELAY_BIND_HOST || "127.0.0.1";
|
|
3548
|
+
const server = http.createServer(app);
|
|
3549
|
+
// Two servers attached with `{ server, path }` both see every upgrade, and each rejects
|
|
3550
|
+
// the other's path with a 400: /ws/control never connected and /ws/patches got a 400
|
|
3551
|
+
// written into its upgraded socket. One upgrade handler routes by path instead.
|
|
3552
|
+
wss = new WebSocketServer({ noServer: true });
|
|
3553
|
+
wssControl = new WebSocketServer({ noServer: true });
|
|
3554
|
+
server.on("upgrade", (req, socket, head) => {
|
|
3555
|
+
let pathname = "";
|
|
3556
|
+
try {
|
|
3557
|
+
pathname = new URL(req.url, "http://relay.local").pathname;
|
|
3558
|
+
} catch {
|
|
3559
|
+
/* malformed request line */
|
|
3560
|
+
}
|
|
3561
|
+
const target = pathname === "/ws/patches" ? wss : pathname === "/ws/control" ? wssControl : null;
|
|
3562
|
+
if (!target) {
|
|
3563
|
+
socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
|
|
3564
|
+
socket.destroy();
|
|
3565
|
+
return;
|
|
3566
|
+
}
|
|
3567
|
+
target.handleUpgrade(req, socket, head, (ws) => target.emit("connection", ws, req));
|
|
3568
|
+
});
|
|
3569
|
+
|
|
3570
|
+
/**
|
|
3571
|
+
* Identity is decided once, at the handshake, and never re-read from a frame.
|
|
3572
|
+
*
|
|
3573
|
+
* A socket that re-authorized per message would let a member keep talking
|
|
3574
|
+
* after being kicked, and would trust whatever login a frame claimed. The
|
|
3575
|
+
* returned session is the only thing downstream is allowed to believe about
|
|
3576
|
+
* who is on the other end.
|
|
3577
|
+
*/
|
|
3578
|
+
function authorizeRoomSocket(socket, req) {
|
|
3579
|
+
const room = loadRoom();
|
|
3580
|
+
const remote = isRemoteRequest(req);
|
|
3581
|
+
if (!remote) return { remote: false, login: null, roomId: room?.roomId || null };
|
|
3582
|
+
if (room?.role !== "host" || !room.url) {
|
|
3583
|
+
socket.close(4403, "not_hosting");
|
|
3584
|
+
return null;
|
|
3585
|
+
}
|
|
3586
|
+
const url = new URL(req.url, "http://localhost");
|
|
3587
|
+
const token = req.headers["x-relay-room-token"] || url.searchParams.get("token");
|
|
3588
|
+
const member = authorizeMember(room, token);
|
|
3589
|
+
if (!member && !room.open) {
|
|
3590
|
+
socket.close(4401, "not_a_member");
|
|
3591
|
+
return null;
|
|
3592
|
+
}
|
|
3593
|
+
return { remote: true, login: member?.login || null, roomId: room.roomId || null };
|
|
3594
|
+
}
|
|
3595
|
+
|
|
3596
|
+
/** Still the member the handshake proved, and the room still the same room. */
|
|
3597
|
+
function sessionValid(session) {
|
|
3598
|
+
if (!session?.remote) return true;
|
|
3599
|
+
const room = loadRoom();
|
|
3600
|
+
if (room?.role !== "host" || !room.url) return false;
|
|
3601
|
+
if (session.roomId && room.roomId && session.roomId !== room.roomId) return false;
|
|
3602
|
+
if (room.open) return true;
|
|
3603
|
+
return Boolean(session.login) && !revokedLogin(room, session.login);
|
|
3604
|
+
}
|
|
3605
|
+
|
|
3606
|
+
function revokedLogin(room, login) {
|
|
3607
|
+
const tokens = Array.isArray(room.memberTokens) ? room.memberTokens : [];
|
|
3608
|
+
return !tokens.some((t) => normLogin(t.login) === normLogin(login));
|
|
3609
|
+
}
|
|
3610
|
+
|
|
3611
|
+
// Only a peer's lock traffic is rewritten onto the host's workspace path. A
|
|
3612
|
+
// local agent on the host machine is already using that path, and forcing the
|
|
3613
|
+
// rewrite on it would file edits made in some other repo against the shared
|
|
3614
|
+
// one.
|
|
3615
|
+
function peerScoped(body, session) {
|
|
3616
|
+
if (!session?.remote) return body;
|
|
3617
|
+
const room = loadRoom();
|
|
3618
|
+
if (!room?.url || room.role !== "host") return body;
|
|
3619
|
+
const shared = loadRegistry().projects.find((p) => p.id === room.hostProjectId) || currentProject();
|
|
3620
|
+
if (!shared?.path) return body;
|
|
3621
|
+
return { ...body, workspaceId: shared.path, peerWorkspacePath: body?.workspaceId || null };
|
|
3622
|
+
}
|
|
3623
|
+
|
|
3624
|
+
const LOCK_OPS = new Set(["claim", "release", "heartbeat", "release-all", "read"]);
|
|
3625
|
+
const MAX_FILES_PER_CLAIM = 64;
|
|
3626
|
+
|
|
3627
|
+
/**
|
|
3628
|
+
* Everything a remote frame is allowed to influence, restated from scratch.
|
|
3629
|
+
*
|
|
3630
|
+
* A lock request is a security boundary, not a data structure: `agentId` names
|
|
3631
|
+
* the holder the whole room arbitrates against, so a member that could send an
|
|
3632
|
+
* arbitrary one could release a teammate's locks and edit the file underneath
|
|
3633
|
+
* them. Stamping the authenticated login onto the agentId makes that
|
|
3634
|
+
* impossible to express.
|
|
3635
|
+
*/
|
|
3636
|
+
function sanitizeLockBody(body, session) {
|
|
3637
|
+
const clean = {
|
|
3638
|
+
agentId: String(body?.agentId || "").slice(0, 200),
|
|
3639
|
+
file: typeof body?.file === "string" ? body.file.slice(0, 1024) : undefined,
|
|
3640
|
+
workspaceId: typeof body?.workspaceId === "string" ? body.workspaceId : undefined,
|
|
3641
|
+
ttl: Number(body?.ttl) || undefined,
|
|
3642
|
+
mode: body?.mode === "read" ? "read" : "write",
|
|
3643
|
+
files: Array.isArray(body?.files)
|
|
3644
|
+
? body.files.filter((f) => typeof f === "string").slice(0, MAX_FILES_PER_CLAIM)
|
|
3645
|
+
: undefined,
|
|
3646
|
+
dependsOn: Array.isArray(body?.dependsOn)
|
|
3647
|
+
? body.dependsOn.filter((f) => typeof f === "string").slice(0, MAX_FILES_PER_CLAIM)
|
|
3648
|
+
: undefined,
|
|
3649
|
+
holder: body?.holder && typeof body.holder === "object" ? body.holder : null,
|
|
3650
|
+
};
|
|
3651
|
+
if (!clean.agentId) return null;
|
|
3652
|
+
if (session?.remote) {
|
|
3653
|
+
const login = session.login || "guest";
|
|
3654
|
+
clean.agentId = `${clean.agentId}@${login}`;
|
|
3655
|
+
clean.holder = { ...(clean.holder || {}), login };
|
|
3656
|
+
}
|
|
3657
|
+
return clean;
|
|
3658
|
+
}
|
|
3659
|
+
|
|
3660
|
+
function runLockRpc(op, body, session) {
|
|
3661
|
+
if (!LOCK_OPS.has(op)) return { error: "unknown_op" };
|
|
3662
|
+
const clean = sanitizeLockBody(body, session);
|
|
3663
|
+
if (!clean) return { error: "agent_required" };
|
|
3664
|
+
const scoped = peerScoped(clean, session);
|
|
3665
|
+
if (op === "claim") {
|
|
3666
|
+
const result = lockTable.claim(scoped);
|
|
3667
|
+
if (!result.allowed) {
|
|
3668
|
+
const project = projectForPath(scoped.workspaceId) || currentProject();
|
|
3669
|
+
if (project?.id) {
|
|
3670
|
+
bumpCollision(project.id, "claim_blocked", {
|
|
3671
|
+
file: scoped.file,
|
|
3672
|
+
agentId: scoped.agentId,
|
|
3673
|
+
holder: result.holder,
|
|
3674
|
+
reason: result.reason,
|
|
3675
|
+
via: "room_rpc",
|
|
3676
|
+
});
|
|
3677
|
+
}
|
|
3678
|
+
}
|
|
3679
|
+
return result;
|
|
3680
|
+
}
|
|
3681
|
+
if (op === "release") return lockTable.release(scoped);
|
|
3682
|
+
if (op === "heartbeat") return lockTable.heartbeat(scoped);
|
|
3683
|
+
if (op === "read") {
|
|
3684
|
+
const files = (Array.isArray(scoped.files) && scoped.files.length ? scoped.files : [scoped.file]).filter(Boolean);
|
|
3685
|
+
for (const file of files) {
|
|
3686
|
+
lockTable.noteRead({
|
|
3687
|
+
agentId: scoped.agentId,
|
|
3688
|
+
filePath: file,
|
|
3689
|
+
workspaceId: scoped.workspaceId,
|
|
3690
|
+
holder: scoped.holder,
|
|
3691
|
+
});
|
|
3692
|
+
}
|
|
3693
|
+
return { ok: true, files };
|
|
3694
|
+
}
|
|
3695
|
+
return lockTable.releaseAll(scoped.agentId, scoped.workspaceId);
|
|
3696
|
+
}
|
|
3697
|
+
// ws re-emits the underlying http server's 'error' on this instance. Without a
|
|
3698
|
+
// listener here, EventEmitter rethrows it as an uncaught exception — which
|
|
3699
|
+
// preempts the EADDRINUSE handler on `server` below and dumps a raw stack
|
|
3700
|
+
// trace instead of the "port already in use" message.
|
|
3701
|
+
wss.on("error", (err) => {
|
|
3702
|
+
if (err.code === "EADDRINUSE") return; // handled on `server`
|
|
3703
|
+
console.warn(`[relay-ws] ${err.message || err}`);
|
|
3704
|
+
});
|
|
3705
|
+
wss.on("connection", (socket, req) => {
|
|
3706
|
+
const session = authorizeRoomSocket(socket, req);
|
|
3707
|
+
if (!session) return;
|
|
3708
|
+
const room = loadRoom();
|
|
3709
|
+
// Greet a joining guest with the current table. Without this a board stays
|
|
3710
|
+
// blank from connect until the host's next lock change, which on a quiet
|
|
3711
|
+
// repo can be a very long time.
|
|
3712
|
+
if (room?.role === "host" && room.url) {
|
|
3713
|
+
const project = currentProject();
|
|
3714
|
+
if (project) {
|
|
3715
|
+
setImmediate(() => sendSocket(socket, roomStatePayload(project, "hello")));
|
|
3716
|
+
}
|
|
3717
|
+
}
|
|
3718
|
+
const beat = setInterval(() => {
|
|
3719
|
+
if (socket.readyState !== 1) return;
|
|
3720
|
+
try {
|
|
3721
|
+
socket.ping();
|
|
3722
|
+
socket.send(JSON.stringify({ type: "ping" }));
|
|
3723
|
+
} catch {
|
|
3724
|
+
/* closed */
|
|
3725
|
+
}
|
|
3726
|
+
}, 15000);
|
|
3727
|
+
if (beat.unref) beat.unref();
|
|
3728
|
+
socket.on("close", () => clearInterval(beat));
|
|
3729
|
+
socket.on("message", (raw) => {
|
|
3730
|
+
try {
|
|
3731
|
+
if (!sessionValid(session)) {
|
|
3732
|
+
socket.close(4401, "membership_revoked");
|
|
3733
|
+
return;
|
|
3734
|
+
}
|
|
3735
|
+
const msg = JSON.parse(String(raw));
|
|
3736
|
+
if (msg.type === "pong" || msg.type === "ping") {
|
|
3737
|
+
if (msg.type === "ping") sendSocket(socket, { type: "pong" });
|
|
3738
|
+
return;
|
|
3739
|
+
}
|
|
3740
|
+
if (msg.type === "join") {
|
|
3741
|
+
const project = currentProject();
|
|
3742
|
+
if (project && loadRoom()?.role === "host") sendSocket(socket, roomStatePayload(project, "hello"));
|
|
3743
|
+
return;
|
|
3744
|
+
}
|
|
3745
|
+
if (msg.type === "history") {
|
|
3746
|
+
const project = msg.projectId
|
|
3747
|
+
? loadRegistry().projects.find((p) => p.id === msg.projectId) || currentProject()
|
|
3748
|
+
: currentProject();
|
|
3749
|
+
// A member may only publish its own history. Trusting the login in the
|
|
3750
|
+
// frame would let any member overwrite another's transcript bucket.
|
|
3751
|
+
const login = session.remote ? session.login : String(msg.login || "").trim();
|
|
3752
|
+
if (!login) return;
|
|
3753
|
+
ingestPeerHistory(login, msg.snapshot, project);
|
|
3754
|
+
return;
|
|
3755
|
+
}
|
|
3756
|
+
if (msg.type === "patch" && msg.patch) {
|
|
3757
|
+
acceptPeerPatch(msg.patch, {
|
|
3758
|
+
remoteUrl: msg.remoteUrl,
|
|
3759
|
+
name: msg.name,
|
|
3760
|
+
pathName: msg.pathName,
|
|
3761
|
+
}, { skipIfOrigin: true });
|
|
3762
|
+
broadcastPatch({
|
|
3763
|
+
workspaceId: msg.workspaceId,
|
|
3764
|
+
patch: msg.patch,
|
|
3765
|
+
incoming: true,
|
|
3766
|
+
remoteUrl: msg.remoteUrl,
|
|
3767
|
+
name: msg.name,
|
|
3768
|
+
pathName: msg.pathName,
|
|
3769
|
+
originId: msg.patch.originId,
|
|
3770
|
+
});
|
|
3771
|
+
}
|
|
3772
|
+
} catch {
|
|
3773
|
+
/* ignore malformed */
|
|
3774
|
+
}
|
|
3775
|
+
});
|
|
3776
|
+
});
|
|
3777
|
+
wssControl.on("error", (err) => {
|
|
3778
|
+
if (err.code === "EADDRINUSE") return;
|
|
3779
|
+
console.warn(`[relay-ws-control] ${err.message || err}`);
|
|
3780
|
+
});
|
|
3781
|
+
wssControl.on("connection", (socket, req) => {
|
|
3782
|
+
const session = authorizeRoomSocket(socket, req);
|
|
3783
|
+
if (!session) return;
|
|
3784
|
+
const room = loadRoom();
|
|
3785
|
+
if (room?.role === "host" && room.url) {
|
|
3786
|
+
const shared = lockScope(room.hostProjectId) || (currentProject() ? { id: currentProject().id, path: currentProject().path } : null);
|
|
3787
|
+
if (shared) sendSocket(socket, { type: "locks", workspaceId: room.hostProjectId || shared.id, locks: locksFor(shared), reads: readsFor(shared) });
|
|
3788
|
+
}
|
|
3789
|
+
const beat = setInterval(() => {
|
|
3790
|
+
if (socket.readyState !== 1) return;
|
|
3791
|
+
try {
|
|
3792
|
+
socket.ping();
|
|
3793
|
+
sendSocket(socket, { type: "ping" });
|
|
3794
|
+
} catch {
|
|
3795
|
+
/* closed */
|
|
3796
|
+
}
|
|
3797
|
+
}, 12000);
|
|
3798
|
+
if (beat.unref) beat.unref();
|
|
3799
|
+
socket.on("close", () => clearInterval(beat));
|
|
3800
|
+
// A lock frame is small by construction. Anything larger is not a claim, and
|
|
3801
|
+
// parsing it would only hand an unauthenticated peer a cheap way to burn the
|
|
3802
|
+
// host's event loop — the one thing every other agent's claim waits on.
|
|
3803
|
+
const MAX_FRAME_BYTES = 64 * 1024;
|
|
3804
|
+
const RPC_BURST = 60;
|
|
3805
|
+
let windowStart = Date.now();
|
|
3806
|
+
let inWindow = 0;
|
|
3807
|
+
socket.on("message", (raw) => {
|
|
3808
|
+
try {
|
|
3809
|
+
if (raw.length > MAX_FRAME_BYTES) {
|
|
3810
|
+
socket.close(4413, "frame_too_large");
|
|
3811
|
+
return;
|
|
3812
|
+
}
|
|
3813
|
+
if (!sessionValid(session)) {
|
|
3814
|
+
socket.close(4401, "membership_revoked");
|
|
3815
|
+
return;
|
|
3816
|
+
}
|
|
3817
|
+
const msg = JSON.parse(String(raw));
|
|
3818
|
+
if (msg.type === "pong" || msg.type === "ping") {
|
|
3819
|
+
if (msg.type === "ping") sendSocket(socket, { type: "pong" });
|
|
3820
|
+
return;
|
|
3821
|
+
}
|
|
3822
|
+
if (msg.type === "join") {
|
|
3823
|
+
const hosted = loadRoom();
|
|
3824
|
+
const shared = lockScope(hosted?.hostProjectId) || (currentProject() ? { id: currentProject().id, path: currentProject().path } : null);
|
|
3825
|
+
if (shared) sendSocket(socket, { type: "locks", workspaceId: hosted.hostProjectId || shared.id, locks: locksFor(shared), reads: readsFor(shared) });
|
|
3826
|
+
return;
|
|
3827
|
+
}
|
|
3828
|
+
if (msg.type !== "rpc") return;
|
|
3829
|
+
const now = Date.now();
|
|
3830
|
+
if (now - windowStart > 1000) {
|
|
3831
|
+
windowStart = now;
|
|
3832
|
+
inWindow = 0;
|
|
3833
|
+
}
|
|
3834
|
+
if (++inWindow > RPC_BURST) {
|
|
3835
|
+
sendSocket(socket, { type: "rpc-err", id: msg.id, error: "rate_limited" });
|
|
3836
|
+
return;
|
|
3837
|
+
}
|
|
3838
|
+
try {
|
|
3839
|
+
sendSocket(socket, { type: "rpc-ok", id: msg.id, result: runLockRpc(msg.op, msg.body, session) });
|
|
3840
|
+
} catch (err) {
|
|
3841
|
+
sendSocket(socket, { type: "rpc-err", id: msg.id, error: err.message || "rpc_failed" });
|
|
3842
|
+
}
|
|
3843
|
+
} catch {
|
|
3844
|
+
/* ignore */
|
|
3845
|
+
}
|
|
3846
|
+
});
|
|
3847
|
+
});
|
|
3848
|
+
server.on("error", (err) => {
|
|
3849
|
+
if (err.code === "EADDRINUSE") {
|
|
3850
|
+
console.error(
|
|
3851
|
+
`[relay-api] port ${port} is already in use — another \`relay serve\` is running. ` +
|
|
3852
|
+
`Leaving its coordinator state untouched.`
|
|
3853
|
+
);
|
|
3854
|
+
process.exit(1);
|
|
3855
|
+
}
|
|
3856
|
+
throw err;
|
|
3857
|
+
});
|
|
3858
|
+
server.listen(port, bindHost, () => {
|
|
3859
|
+
writeState(process.cwd(), coordPort, { apiPort: port });
|
|
3860
|
+
console.log(`[relay-api] http://${bindHost}:${port}`);
|
|
3861
|
+
console.log(`[relay-coordinator] 127.0.0.1:${coordPort}`);
|
|
3862
|
+
console.log(`[relay-ui] ${UI_ORIGIN}`);
|
|
3863
|
+
const hosted = loadRoom();
|
|
3864
|
+
if (hosted?.role === "host") {
|
|
3865
|
+
startTunnelWatch();
|
|
3866
|
+
shareRoom(port, shareExtras(currentProject()))
|
|
3867
|
+
.then(async (result) => {
|
|
3868
|
+
if (!result.ok) {
|
|
3869
|
+
console.warn(`[relay-room] could not resume hosting: ${result.hint || result.error}`);
|
|
3870
|
+
return;
|
|
3871
|
+
}
|
|
3872
|
+
const warning = await publishAndWatch(result.room, currentProject());
|
|
3873
|
+
console.log(`[relay-room] resumed hosting at ${result.room.url}`);
|
|
3874
|
+
if (warning) console.warn(`[relay-room] ${warning}`);
|
|
3875
|
+
})
|
|
3876
|
+
.catch((err) => console.warn(`[relay-room] resume failed: ${err.message || err}`));
|
|
3877
|
+
} else if (hosted?.role === "guest") {
|
|
3878
|
+
refreshGuestRoom(hosted)
|
|
3879
|
+
.then((next) => {
|
|
3880
|
+
connectHostPatches(next || hosted);
|
|
3881
|
+
connectHostControl(next || hosted);
|
|
3882
|
+
})
|
|
3883
|
+
.catch(() => {
|
|
3884
|
+
connectHostPatches(hosted);
|
|
3885
|
+
connectHostControl(hosted);
|
|
3886
|
+
});
|
|
3887
|
+
}
|
|
3888
|
+
});
|
|
3889
|
+
}
|
|
3890
|
+
|
|
3891
|
+
main().catch((err) => {
|
|
3892
|
+
console.error(err);
|
|
3893
|
+
process.exit(1);
|
|
3894
|
+
});
|