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,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collision counters — claims blocked, patches held back, merge flags.
|
|
3
|
+
*
|
|
4
|
+
* Primary store (survives memory resets):
|
|
5
|
+
* ~/.relay/data/projects/<projectId>/collisions.json
|
|
6
|
+
*
|
|
7
|
+
* Workspace mirror (git-visible, human-readable):
|
|
8
|
+
* <repo>/.relay/collisions.json
|
|
9
|
+
*
|
|
10
|
+
* memory.json stats.collisions is kept in sync for room snapshot fan-out only.
|
|
11
|
+
*/
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const path = require("path");
|
|
14
|
+
const { loadMemory, saveMemory, projectDir, loadRegistry, readJson, writeJson } = require("./store");
|
|
15
|
+
|
|
16
|
+
const RECENT_MAX = 40;
|
|
17
|
+
const FILE_NAME = "collisions.json";
|
|
18
|
+
|
|
19
|
+
function defaultCollisionStats() {
|
|
20
|
+
return {
|
|
21
|
+
claimsBlocked: 0,
|
|
22
|
+
patchesBlocked: 0,
|
|
23
|
+
patchesSkipped: 0,
|
|
24
|
+
patchesDeferred: 0,
|
|
25
|
+
mergesFlagged: 0,
|
|
26
|
+
totalSaved: 0,
|
|
27
|
+
updatedAt: 0,
|
|
28
|
+
recent: [],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function totalSaved(c) {
|
|
33
|
+
return (
|
|
34
|
+
(c.claimsBlocked || 0) +
|
|
35
|
+
(c.patchesBlocked || 0) +
|
|
36
|
+
(c.patchesSkipped || 0) +
|
|
37
|
+
(c.mergesFlagged || 0)
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function collisionsDataPath(projectId) {
|
|
42
|
+
return path.join(projectDir(projectId), FILE_NAME);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function workspaceCollisionsPath(projectId) {
|
|
46
|
+
const project = loadRegistry().projects.find((p) => p.id === projectId);
|
|
47
|
+
if (!project?.path) return null;
|
|
48
|
+
return path.join(project.path, ".relay", FILE_NAME);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeStats(raw) {
|
|
52
|
+
const base = { ...defaultCollisionStats(), ...(raw && typeof raw === "object" ? raw : {}) };
|
|
53
|
+
base.recent = Array.isArray(base.recent) ? base.recent.slice(0, RECENT_MAX) : [];
|
|
54
|
+
base.totalSaved = totalSaved(base);
|
|
55
|
+
return base;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readCollisionsFile(file) {
|
|
59
|
+
if (!file) return null;
|
|
60
|
+
const raw = readJson(file, null);
|
|
61
|
+
if (!raw || typeof raw !== "object") return null;
|
|
62
|
+
return normalizeStats(raw);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Load local counters: dedicated file first, then migrate from memory.json if needed.
|
|
67
|
+
*/
|
|
68
|
+
function loadLocalCollisions(projectId) {
|
|
69
|
+
if (!projectId) return defaultCollisionStats();
|
|
70
|
+
|
|
71
|
+
const fromData = readCollisionsFile(collisionsDataPath(projectId));
|
|
72
|
+
if (fromData && (fromData.updatedAt || fromData.totalSaved)) return fromData;
|
|
73
|
+
|
|
74
|
+
const fromWorkspace = readCollisionsFile(workspaceCollisionsPath(projectId));
|
|
75
|
+
if (fromWorkspace && (fromWorkspace.updatedAt || fromWorkspace.totalSaved)) {
|
|
76
|
+
persistLocalCollisions(projectId, fromWorkspace);
|
|
77
|
+
return fromWorkspace;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const memory = loadMemory(projectId);
|
|
81
|
+
const legacy = memory?.stats?.collisions;
|
|
82
|
+
if (legacy && typeof legacy === "object") {
|
|
83
|
+
const migrated = normalizeStats(legacy);
|
|
84
|
+
persistLocalCollisions(projectId, migrated);
|
|
85
|
+
return migrated;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return defaultCollisionStats();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function persistLocalCollisions(projectId, stats) {
|
|
92
|
+
if (!projectId) return;
|
|
93
|
+
const next = normalizeStats(stats);
|
|
94
|
+
writeJson(collisionsDataPath(projectId), next);
|
|
95
|
+
const workspaceFile = workspaceCollisionsPath(projectId);
|
|
96
|
+
if (workspaceFile) {
|
|
97
|
+
try {
|
|
98
|
+
fs.mkdirSync(path.dirname(workspaceFile), { recursive: true });
|
|
99
|
+
writeJson(workspaceFile, next);
|
|
100
|
+
} catch {
|
|
101
|
+
/* workspace may be missing / read-only */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// Keep memory.json in sync so room snapshots still carry our counters.
|
|
105
|
+
try {
|
|
106
|
+
const memory = loadMemory(projectId);
|
|
107
|
+
memory.stats = memory.stats || {};
|
|
108
|
+
memory.stats.collisions = next;
|
|
109
|
+
saveMemory(projectId, memory);
|
|
110
|
+
} catch {
|
|
111
|
+
/* non-fatal */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getCollisionStats(memoryOrProjectId) {
|
|
116
|
+
if (typeof memoryOrProjectId === "string") {
|
|
117
|
+
return loadLocalCollisions(memoryOrProjectId);
|
|
118
|
+
}
|
|
119
|
+
// Dashboard / MCP often pass a memory object; prefer dedicated file when project id is known.
|
|
120
|
+
const mem = memoryOrProjectId || {};
|
|
121
|
+
if (mem.__projectId) return loadLocalCollisions(mem.__projectId);
|
|
122
|
+
if (mem.stats?.collisions) return normalizeStats(mem.stats.collisions);
|
|
123
|
+
return defaultCollisionStats();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function memberRow(raw, ownerLogin) {
|
|
127
|
+
const base = normalizeStats(raw);
|
|
128
|
+
return {
|
|
129
|
+
ownerLogin,
|
|
130
|
+
claimsBlocked: base.claimsBlocked || 0,
|
|
131
|
+
patchesBlocked: base.patchesBlocked || 0,
|
|
132
|
+
patchesSkipped: base.patchesSkipped || 0,
|
|
133
|
+
patchesDeferred: base.patchesDeferred || 0,
|
|
134
|
+
mergesFlagged: base.mergesFlagged || 0,
|
|
135
|
+
totalSaved: totalSaved(base),
|
|
136
|
+
updatedAt: base.updatedAt || 0,
|
|
137
|
+
recent: base.recent || [],
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Sum this machine + every peer snapshot in roomPeers.
|
|
143
|
+
*/
|
|
144
|
+
function mergeCollisionStats(memory, opts = {}) {
|
|
145
|
+
// Lazy require — roomSync imports loadLocalCollisions for snapshots.
|
|
146
|
+
const { peerSnapshots } = require("./roomSync");
|
|
147
|
+
const projectId = opts.projectId || memory?.__projectId || null;
|
|
148
|
+
const local = projectId ? loadLocalCollisions(projectId) : getCollisionStats(memory);
|
|
149
|
+
const peers = peerSnapshots(memory, opts);
|
|
150
|
+
const localLogin = opts.localLogin || "local";
|
|
151
|
+
if (!peers.length) {
|
|
152
|
+
return { ...local, scope: "solo", byMember: [memberRow(local, localLogin)] };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const members = [memberRow(local, localLogin), ...peers.map((p) => memberRow(p.stats?.collisions, p.login || "peer"))];
|
|
156
|
+
const merged = defaultCollisionStats();
|
|
157
|
+
for (const m of members) {
|
|
158
|
+
merged.claimsBlocked += m.claimsBlocked;
|
|
159
|
+
merged.patchesBlocked += m.patchesBlocked;
|
|
160
|
+
merged.patchesSkipped += m.patchesSkipped;
|
|
161
|
+
merged.patchesDeferred += m.patchesDeferred;
|
|
162
|
+
merged.mergesFlagged += m.mergesFlagged;
|
|
163
|
+
}
|
|
164
|
+
merged.totalSaved = totalSaved(merged);
|
|
165
|
+
merged.updatedAt = Math.max(local.updatedAt || 0, ...members.map((m) => m.updatedAt || 0));
|
|
166
|
+
merged.recent = members
|
|
167
|
+
.flatMap((m) => (m.recent || []).map((row) => ({ ...row, ownerLogin: m.ownerLogin })))
|
|
168
|
+
.sort((a, b) => (b.ts || 0) - (a.ts || 0))
|
|
169
|
+
.slice(0, RECENT_MAX);
|
|
170
|
+
merged.scope = "room";
|
|
171
|
+
merged.byMember = members;
|
|
172
|
+
merged.peerLogins = peers.map((p) => p.login).filter(Boolean);
|
|
173
|
+
return merged;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* @param {string} projectId
|
|
178
|
+
* @param {"claim_blocked"|"patch_blocked"|"patch_skipped"|"patch_deferred"|"merge_flagged"} kind
|
|
179
|
+
* @param {object} [detail]
|
|
180
|
+
*/
|
|
181
|
+
function recordCollision(projectId, kind, detail = {}) {
|
|
182
|
+
if (!projectId || !kind) return defaultCollisionStats();
|
|
183
|
+
const c = loadLocalCollisions(projectId);
|
|
184
|
+
switch (kind) {
|
|
185
|
+
case "claim_blocked":
|
|
186
|
+
c.claimsBlocked = (c.claimsBlocked || 0) + 1;
|
|
187
|
+
break;
|
|
188
|
+
case "patch_blocked":
|
|
189
|
+
c.patchesBlocked = (c.patchesBlocked || 0) + 1;
|
|
190
|
+
break;
|
|
191
|
+
case "patch_skipped":
|
|
192
|
+
c.patchesSkipped = (c.patchesSkipped || 0) + 1;
|
|
193
|
+
break;
|
|
194
|
+
case "patch_deferred":
|
|
195
|
+
c.patchesDeferred = (c.patchesDeferred || 0) + 1;
|
|
196
|
+
break;
|
|
197
|
+
case "merge_flagged":
|
|
198
|
+
c.mergesFlagged = (c.mergesFlagged || 0) + 1;
|
|
199
|
+
break;
|
|
200
|
+
default:
|
|
201
|
+
return c;
|
|
202
|
+
}
|
|
203
|
+
c.totalSaved = totalSaved(c);
|
|
204
|
+
c.updatedAt = Date.now();
|
|
205
|
+
c.recent = [{ kind, ts: c.updatedAt, ...detail }, ...(c.recent || [])].slice(0, RECENT_MAX);
|
|
206
|
+
persistLocalCollisions(projectId, c);
|
|
207
|
+
require("./falkor/bus").emit("collision", { projectId, kind, detail, ts: c.updatedAt });
|
|
208
|
+
return normalizeStats(c);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Attach file-backed counters onto a memory object for dashboard/MCP reads. */
|
|
212
|
+
function hydrateMemoryCollisions(projectId, memory) {
|
|
213
|
+
if (!memory) return memory;
|
|
214
|
+
memory.stats = memory.stats || {};
|
|
215
|
+
memory.stats.collisions = loadLocalCollisions(projectId);
|
|
216
|
+
memory.__projectId = projectId;
|
|
217
|
+
return memory;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = {
|
|
221
|
+
defaultCollisionStats,
|
|
222
|
+
getCollisionStats,
|
|
223
|
+
mergeCollisionStats,
|
|
224
|
+
recordCollision,
|
|
225
|
+
totalSaved,
|
|
226
|
+
loadLocalCollisions,
|
|
227
|
+
persistLocalCollisions,
|
|
228
|
+
hydrateMemoryCollisions,
|
|
229
|
+
collisionsDataPath,
|
|
230
|
+
workspaceCollisionsPath,
|
|
231
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
function normalizePath(p) {
|
|
2
|
+
return String(p || "")
|
|
3
|
+
.replace(/\\/g, "/")
|
|
4
|
+
.replace(/^\.?\//, "")
|
|
5
|
+
.toLowerCase()
|
|
6
|
+
.replace(/\/$/, "");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function detectConflicts(memory, windowMs = 5 * 60 * 1000) {
|
|
10
|
+
const now = Date.now();
|
|
11
|
+
const edits = (memory.edits || []).filter((e) => now - e.ts <= windowMs);
|
|
12
|
+
const byFile = new Map();
|
|
13
|
+
for (const e of edits) {
|
|
14
|
+
const key = normalizePath(e.file);
|
|
15
|
+
if (!byFile.has(key)) byFile.set(key, []);
|
|
16
|
+
byFile.get(key).push(e);
|
|
17
|
+
}
|
|
18
|
+
const conflicts = [];
|
|
19
|
+
for (const [file, list] of byFile) {
|
|
20
|
+
const agents = [...new Set(list.map((e) => e.agent))];
|
|
21
|
+
if (agents.length < 2) continue;
|
|
22
|
+
conflicts.push({
|
|
23
|
+
file,
|
|
24
|
+
agents,
|
|
25
|
+
edits: list.map((e) => ({
|
|
26
|
+
agent: e.agent,
|
|
27
|
+
ts: new Date(e.ts).toISOString(),
|
|
28
|
+
summary: e.diff ? e.diff.split("\n").slice(0, 2).join(" ") : e.file,
|
|
29
|
+
})),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return conflicts;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { detectConflicts, normalizePath };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const { loadMemory, loadSession } = require("./store");
|
|
2
|
+
const { detectConflicts } = require("./conflicts");
|
|
3
|
+
const { getCollisionStats, mergeCollisionStats, hydrateMemoryCollisions } = require("./collisionMetrics");
|
|
4
|
+
const { mergeRoomViews } = require("./roomSync");
|
|
5
|
+
|
|
6
|
+
function tagMine(list, login) {
|
|
7
|
+
return (list || []).map((item) => ({
|
|
8
|
+
...item,
|
|
9
|
+
mine: item.ownerLogin ? item.ownerLogin === login : Boolean(item.mine),
|
|
10
|
+
}));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function buildDashboard(project, memory) {
|
|
14
|
+
const mem = hydrateMemoryCollisions(project.id, stripMarkdownIr(memory || loadMemory(project.id)));
|
|
15
|
+
const login = loadSession().login || "local";
|
|
16
|
+
// Outside a room `roomPeers` is empty and this is the local memory verbatim;
|
|
17
|
+
// inside one it folds in every other machine's chats, edits and activity so
|
|
18
|
+
// both sides of an ngrok link see the same feed.
|
|
19
|
+
const view = mergeRoomViews(mem);
|
|
20
|
+
return {
|
|
21
|
+
project,
|
|
22
|
+
stats: {
|
|
23
|
+
...(mem.stats || {}),
|
|
24
|
+
collisions: mergeCollisionStats(mem, { localLogin: login, projectId: project.id }),
|
|
25
|
+
collisionsLocal: getCollisionStats(project.id),
|
|
26
|
+
},
|
|
27
|
+
agents: view.agents,
|
|
28
|
+
collaborators: mem.collaborators || [],
|
|
29
|
+
activity: tagMine(view.activity, login),
|
|
30
|
+
history: tagMine(view.history, login),
|
|
31
|
+
timeline: tagMine(view.timeline, login),
|
|
32
|
+
chats: tagMine(view.chats, login),
|
|
33
|
+
edits: tagMine(view.edits, login),
|
|
34
|
+
patches: mem.patches || mem.edits || [],
|
|
35
|
+
lastAppliedLamport: mem.lastAppliedLamport || 0,
|
|
36
|
+
conflicts: detectConflicts(mem),
|
|
37
|
+
peers: view.peerLogins,
|
|
38
|
+
memory: {
|
|
39
|
+
historyCount: (view.history || mem.history || []).length,
|
|
40
|
+
timelineCount: (view.timeline || []).length,
|
|
41
|
+
chatCount: (view.chats || []).length,
|
|
42
|
+
editCount: (view.edits || []).length,
|
|
43
|
+
lastTranscriptSyncAt: mem.lastTranscriptSyncAt || project.lastSyncAt || 0,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function stripMarkdownIr(mem) {
|
|
49
|
+
if (mem && mem.ir) delete mem.ir;
|
|
50
|
+
return mem;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { buildDashboard };
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { extractWithQuery, languageForExt, supportedLanguages } = require("./treeSitterImports");
|
|
4
|
+
const { sameOwner } = require("../coordinator/lockTable");
|
|
5
|
+
const { normalizeWorkspaceRoot } = require("./transcripts/util");
|
|
6
|
+
|
|
7
|
+
const SKIP = new Set([
|
|
8
|
+
"node_modules",
|
|
9
|
+
".git",
|
|
10
|
+
".next",
|
|
11
|
+
"out",
|
|
12
|
+
"dist",
|
|
13
|
+
"coverage",
|
|
14
|
+
".relay",
|
|
15
|
+
]);
|
|
16
|
+
const SOURCE = new Set([
|
|
17
|
+
".ts",
|
|
18
|
+
".tsx",
|
|
19
|
+
".js",
|
|
20
|
+
".jsx",
|
|
21
|
+
".mjs",
|
|
22
|
+
".cjs",
|
|
23
|
+
".py",
|
|
24
|
+
".go",
|
|
25
|
+
".rs",
|
|
26
|
+
".java",
|
|
27
|
+
".cs",
|
|
28
|
+
".cpp",
|
|
29
|
+
".cc",
|
|
30
|
+
".cxx",
|
|
31
|
+
".h",
|
|
32
|
+
".hpp",
|
|
33
|
+
".php",
|
|
34
|
+
".rb",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
function posix(p) {
|
|
38
|
+
return String(p || "").replace(/\\/g, "/").replace(/^\.?\//, "");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function walk(root, rel = "", out = []) {
|
|
42
|
+
const dir = path.join(root, rel);
|
|
43
|
+
let entries = [];
|
|
44
|
+
try {
|
|
45
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
46
|
+
} catch {
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
for (const e of entries) {
|
|
50
|
+
if (SKIP.has(e.name) || e.name.startsWith(".")) continue;
|
|
51
|
+
const child = rel ? `${rel}/${e.name}` : e.name;
|
|
52
|
+
if (e.isDirectory()) walk(root, child, out);
|
|
53
|
+
else if (SOURCE.has(path.extname(e.name))) out.push(child.replace(/\\/g, "/"));
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function loadOverride(workspacePath) {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(fs.readFileSync(path.join(workspacePath, ".relay", "deps.json"), "utf8"));
|
|
61
|
+
} catch {
|
|
62
|
+
return { lockDepth: 1, edges: [] };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function tryResolve(root, fromFile, spec, ext) {
|
|
67
|
+
if (!spec || spec.startsWith("http")) return null;
|
|
68
|
+
const dir = path.posix.dirname(fromFile);
|
|
69
|
+
|
|
70
|
+
if (spec.startsWith(".")) {
|
|
71
|
+
const rel = path.posix.normalize(path.posix.join(dir, spec)).replace(/^\.\//, "");
|
|
72
|
+
return resolveExisting(root, rel, ext);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (ext === ".py") {
|
|
76
|
+
const rel = spec.replace(/\./g, "/");
|
|
77
|
+
return resolveExisting(root, rel, ".py") || resolveExisting(root, path.posix.join(dir, rel), ".py");
|
|
78
|
+
}
|
|
79
|
+
if (ext === ".java" || ext === ".cs") {
|
|
80
|
+
const rel = spec.replace(/\./g, "/");
|
|
81
|
+
return resolveExisting(root, rel, ext);
|
|
82
|
+
}
|
|
83
|
+
if (ext === ".rs" && !spec.includes("::") && !["crate", "self", "super", "std", "core"].includes(spec)) {
|
|
84
|
+
return resolveExisting(root, path.posix.join(dir, spec), ".rs");
|
|
85
|
+
}
|
|
86
|
+
if (ext === ".rs" && spec.startsWith("super::")) {
|
|
87
|
+
const name = spec.split("::").pop();
|
|
88
|
+
return resolveExisting(root, path.posix.join(path.posix.dirname(dir), name), ".rs");
|
|
89
|
+
}
|
|
90
|
+
if ((ext === ".php" || ext === ".rb") && (spec.startsWith(".") || spec.includes("/"))) {
|
|
91
|
+
return resolveExisting(root, path.posix.join(dir, spec), ext);
|
|
92
|
+
}
|
|
93
|
+
if (ext === ".go" && (spec.startsWith("./") || spec.startsWith("../"))) {
|
|
94
|
+
return resolveExisting(root, path.posix.normalize(path.posix.join(dir, spec)), ".go");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { kind: "external", spec };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function resolveExisting(root, rel, hintExt) {
|
|
101
|
+
const bases = [
|
|
102
|
+
rel,
|
|
103
|
+
`${rel}.ts`,
|
|
104
|
+
`${rel}.tsx`,
|
|
105
|
+
`${rel}.js`,
|
|
106
|
+
`${rel}.jsx`,
|
|
107
|
+
`${rel}.mjs`,
|
|
108
|
+
`${rel}.py`,
|
|
109
|
+
`${rel}.go`,
|
|
110
|
+
`${rel}.rs`,
|
|
111
|
+
`${rel}.java`,
|
|
112
|
+
`${rel}.cs`,
|
|
113
|
+
`${rel}.php`,
|
|
114
|
+
`${rel}.rb`,
|
|
115
|
+
`${rel}.h`,
|
|
116
|
+
`${rel}.hpp`,
|
|
117
|
+
path.posix.join(rel, "index.ts"),
|
|
118
|
+
path.posix.join(rel, "index.js"),
|
|
119
|
+
path.posix.join(rel, "__init__.py"),
|
|
120
|
+
path.posix.join(rel, "mod.rs"),
|
|
121
|
+
];
|
|
122
|
+
if (hintExt) bases.unshift(rel + hintExt);
|
|
123
|
+
for (const c of bases) {
|
|
124
|
+
const abs = path.join(root, c);
|
|
125
|
+
try {
|
|
126
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) return { kind: "file", spec: c.replace(/\\/g, "/") };
|
|
127
|
+
} catch {
|
|
128
|
+
/* skip */
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { kind: "missing", spec: rel };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function extractSpecs(source, ext) {
|
|
135
|
+
const lang = languageForExt(ext);
|
|
136
|
+
if (lang) {
|
|
137
|
+
const result = extractWithQuery(lang, source);
|
|
138
|
+
if (result.engine === "tree-sitter") return result;
|
|
139
|
+
}
|
|
140
|
+
return { staticSpecs: [], dynamic: [], engine: "none" };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function stronglyConnected(nodes, imports) {
|
|
144
|
+
const index = new Map();
|
|
145
|
+
const low = new Map();
|
|
146
|
+
const stack = [];
|
|
147
|
+
const on = new Set();
|
|
148
|
+
const sccs = [];
|
|
149
|
+
let i = 0;
|
|
150
|
+
function strong(v) {
|
|
151
|
+
index.set(v, i);
|
|
152
|
+
low.set(v, i);
|
|
153
|
+
i += 1;
|
|
154
|
+
stack.push(v);
|
|
155
|
+
on.add(v);
|
|
156
|
+
for (const w of imports.get(v) || []) {
|
|
157
|
+
if (!index.has(w)) {
|
|
158
|
+
strong(w);
|
|
159
|
+
low.set(v, Math.min(low.get(v), low.get(w)));
|
|
160
|
+
} else if (on.has(w)) {
|
|
161
|
+
low.set(v, Math.min(low.get(v), index.get(w)));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (low.get(v) === index.get(v)) {
|
|
165
|
+
const comp = [];
|
|
166
|
+
while (true) {
|
|
167
|
+
const w = stack.pop();
|
|
168
|
+
on.delete(w);
|
|
169
|
+
comp.push(w);
|
|
170
|
+
if (w === v) break;
|
|
171
|
+
}
|
|
172
|
+
sccs.push(comp);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
for (const n of nodes) if (!index.has(n)) strong(n);
|
|
176
|
+
const owner = new Map();
|
|
177
|
+
for (const comp of sccs) {
|
|
178
|
+
const id = comp.slice().sort().join("|");
|
|
179
|
+
for (const n of comp) owner.set(n, { id, members: comp, cyclic: comp.length > 1 });
|
|
180
|
+
}
|
|
181
|
+
return owner;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
class DepGraph {
|
|
185
|
+
constructor(workspacePath) {
|
|
186
|
+
this.workspacePath = workspacePath;
|
|
187
|
+
this.imports = new Map();
|
|
188
|
+
this.importedBy = new Map();
|
|
189
|
+
this.unresolvable = [];
|
|
190
|
+
this.cycles = [];
|
|
191
|
+
this.scc = new Map();
|
|
192
|
+
this.lockDepth = 1;
|
|
193
|
+
this.builtAt = 0;
|
|
194
|
+
this.engine = "none";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
build() {
|
|
198
|
+
const override = loadOverride(this.workspacePath);
|
|
199
|
+
this.lockDepth = Number(override.lockDepth || process.env.RELAY_LOCK_DEPTH || 1);
|
|
200
|
+
this.imports = new Map();
|
|
201
|
+
this.importedBy = new Map();
|
|
202
|
+
this.unresolvable = [];
|
|
203
|
+
const files = walk(this.workspacePath);
|
|
204
|
+
for (const file of files) {
|
|
205
|
+
if (!this.imports.has(file)) this.imports.set(file, new Set());
|
|
206
|
+
if (!this.importedBy.has(file)) this.importedBy.set(file, new Set());
|
|
207
|
+
let src = "";
|
|
208
|
+
try {
|
|
209
|
+
src = fs.readFileSync(path.join(this.workspacePath, file), "utf8");
|
|
210
|
+
} catch {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const ext = path.extname(file);
|
|
214
|
+
const { staticSpecs, dynamic, engine } = extractSpecs(src, ext);
|
|
215
|
+
this.engine = engine || this.engine;
|
|
216
|
+
for (const d of dynamic) {
|
|
217
|
+
this.unresolvable.push({ from: file, spec: d, reason: "dynamic" });
|
|
218
|
+
}
|
|
219
|
+
for (const spec of staticSpecs) {
|
|
220
|
+
const resolved = tryResolve(this.workspacePath, file, spec, ext);
|
|
221
|
+
if (!resolved || resolved.kind !== "file") {
|
|
222
|
+
if (resolved && resolved.kind === "missing" && spec.startsWith(".")) {
|
|
223
|
+
this.unresolvable.push({ from: file, spec, reason: "missing" });
|
|
224
|
+
}
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
this.imports.get(file).add(resolved.spec);
|
|
228
|
+
if (!this.importedBy.has(resolved.spec)) this.importedBy.set(resolved.spec, new Set());
|
|
229
|
+
this.importedBy.get(resolved.spec).add(file);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
for (const [a, b] of override.edges || []) {
|
|
233
|
+
const from = posix(a);
|
|
234
|
+
const to = posix(b);
|
|
235
|
+
if (!this.imports.has(from)) this.imports.set(from, new Set());
|
|
236
|
+
this.imports.get(from).add(to);
|
|
237
|
+
if (!this.importedBy.has(to)) this.importedBy.set(to, new Set());
|
|
238
|
+
this.importedBy.get(to).add(from);
|
|
239
|
+
}
|
|
240
|
+
const nodes = [...this.imports.keys()];
|
|
241
|
+
this.scc = stronglyConnected(nodes, this.imports);
|
|
242
|
+
this.cycles = [...new Set([...this.scc.values()].filter((s) => s.cyclic).map((s) => s.id))].map((id) => {
|
|
243
|
+
const members = [...this.scc.values()].find((s) => s.id === id)?.members || [];
|
|
244
|
+
return members;
|
|
245
|
+
});
|
|
246
|
+
this.builtAt = Date.now();
|
|
247
|
+
return this;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
walk(start, map, depth) {
|
|
251
|
+
const out = new Set();
|
|
252
|
+
const q = [{ file: posix(start), d: 0 }];
|
|
253
|
+
const seen = new Set();
|
|
254
|
+
while (q.length) {
|
|
255
|
+
const { file, d } = q.shift();
|
|
256
|
+
if (d >= depth) continue;
|
|
257
|
+
for (const n of map.get(file) || []) {
|
|
258
|
+
if (seen.has(n)) continue;
|
|
259
|
+
seen.add(n);
|
|
260
|
+
out.add(n);
|
|
261
|
+
q.push({ file: n, d: d + 1 });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return [...out];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
dependencies(file, depth = this.lockDepth) {
|
|
268
|
+
return this.walk(file, this.imports, depth);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
dependents(file, depth = this.lockDepth) {
|
|
272
|
+
return this.walk(file, this.importedBy, depth);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
cycleMembers(file) {
|
|
276
|
+
return this.scc.get(posix(file))?.members || [posix(file)];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Soft-lock if an upstream import is held, or another file in this SCC is held.
|
|
281
|
+
*/
|
|
282
|
+
blockedBy(file, isHeld) {
|
|
283
|
+
const rel = posix(file);
|
|
284
|
+
for (const member of this.cycleMembers(rel)) {
|
|
285
|
+
if (member === rel) continue;
|
|
286
|
+
const holder = isHeld(member);
|
|
287
|
+
if (holder) {
|
|
288
|
+
return {
|
|
289
|
+
soft: true,
|
|
290
|
+
file: member,
|
|
291
|
+
holder,
|
|
292
|
+
reason: `⚠️ \`${rel}\` is in a circular dependency with \`${member}\` (single lock unit). Held by ${holder}. Pick a different file.`,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
for (const dep of this.dependencies(rel, this.lockDepth)) {
|
|
297
|
+
const holder = isHeld(dep);
|
|
298
|
+
if (holder) {
|
|
299
|
+
return {
|
|
300
|
+
soft: true,
|
|
301
|
+
file: dep,
|
|
302
|
+
holder,
|
|
303
|
+
reason: `⚠️ \`${rel}\` depends on \`${dep}\`, which is currently being modified by ${holder}. Proceeding may cause inconsistencies. Pick a different file.`,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
snapshot(lockedFiles = []) {
|
|
311
|
+
const locked = new Set(lockedFiles.map(posix));
|
|
312
|
+
const focus = new Set(locked);
|
|
313
|
+
for (const f of locked) {
|
|
314
|
+
for (const d of this.dependents(f, 1)) focus.add(d);
|
|
315
|
+
for (const d of this.dependencies(f, 1)) focus.add(d);
|
|
316
|
+
for (const d of this.cycleMembers(f)) focus.add(d);
|
|
317
|
+
}
|
|
318
|
+
const degrees = [...this.imports.keys()].map((n) => ({
|
|
319
|
+
n,
|
|
320
|
+
deg: (this.imports.get(n)?.size || 0) + (this.importedBy.get(n)?.size || 0),
|
|
321
|
+
}));
|
|
322
|
+
degrees.sort((a, b) => b.deg - a.deg);
|
|
323
|
+
for (const { n } of degrees.slice(0, 48)) focus.add(n);
|
|
324
|
+
|
|
325
|
+
const nodes = [...focus].filter((n) => this.imports.has(n) || this.importedBy.has(n));
|
|
326
|
+
const nodeSet = new Set(nodes);
|
|
327
|
+
const edges = [];
|
|
328
|
+
for (const from of nodes) {
|
|
329
|
+
for (const to of this.imports.get(from) || []) {
|
|
330
|
+
if (nodeSet.has(to)) edges.push({ from, to });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
lockDepth: this.lockDepth,
|
|
335
|
+
engine: this.engine || "tree-sitter",
|
|
336
|
+
languages: supportedLanguages(),
|
|
337
|
+
builtAt: this.builtAt,
|
|
338
|
+
fileCount: this.imports.size,
|
|
339
|
+
edgeCount: [...this.imports.values()].reduce((n, s) => n + s.size, 0),
|
|
340
|
+
cycles: this.cycles,
|
|
341
|
+
unresolvable: this.unresolvable.slice(0, 40),
|
|
342
|
+
nodes: nodes.map((id) => ({
|
|
343
|
+
id,
|
|
344
|
+
imports: [...(this.imports.get(id) || [])],
|
|
345
|
+
dependents: [...(this.importedBy.get(id) || [])],
|
|
346
|
+
cyclic: Boolean(this.scc.get(id)?.cyclic),
|
|
347
|
+
locked: locked.has(id),
|
|
348
|
+
})),
|
|
349
|
+
edges,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const cache = new Map();
|
|
355
|
+
|
|
356
|
+
function resolveRoot(workspacePath) {
|
|
357
|
+
return normalizeWorkspaceRoot(workspacePath) || workspacePath;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function graphFor(workspacePath) {
|
|
361
|
+
const root = resolveRoot(workspacePath);
|
|
362
|
+
if (!root || !fs.existsSync(root)) return null;
|
|
363
|
+
let g = cache.get(root);
|
|
364
|
+
if (!g) {
|
|
365
|
+
g = new DepGraph(root).build();
|
|
366
|
+
cache.set(root, g);
|
|
367
|
+
}
|
|
368
|
+
return g;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function rebuild(workspacePath) {
|
|
372
|
+
const root = resolveRoot(workspacePath);
|
|
373
|
+
if (!root || !fs.existsSync(root)) return null;
|
|
374
|
+
const g = new DepGraph(root).build();
|
|
375
|
+
cache.set(root, g);
|
|
376
|
+
return g;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function holderOf(lockTable, file, agentId, workspacePath) {
|
|
380
|
+
const e = typeof lockTable.getLock === "function" ? lockTable.getLock(file, workspacePath) : null;
|
|
381
|
+
if (!e) return null;
|
|
382
|
+
if (sameOwner(e.agentId, agentId)) return null;
|
|
383
|
+
if (Date.now() >= (e.lastHeartbeat || e.claimedAt) + e.ttlMs) return null;
|
|
384
|
+
return e.holder?.label || e.agentId;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function softBlock(lockTable, workspacePath, agentId, file) {
|
|
388
|
+
const g = graphFor(workspacePath);
|
|
389
|
+
if (!g) return null;
|
|
390
|
+
return g.blockedBy(file, (f) => holderOf(lockTable, f, agentId, workspacePath));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
module.exports = { DepGraph, graphFor, rebuild, posix, softBlock };
|