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,63 @@
|
|
|
1
|
+
const crypto = require("crypto");
|
|
2
|
+
const { loadMemory, saveMemory, id } = require("./store");
|
|
3
|
+
|
|
4
|
+
function appendStopTurn(projectId, { agent, ownerLogin, sessionId, messages, edits }) {
|
|
5
|
+
const memory = loadMemory(projectId);
|
|
6
|
+
const ts = Date.now();
|
|
7
|
+
const mineLogin = ownerLogin;
|
|
8
|
+
const chatId = `chat_${sessionId || id("sess")}`;
|
|
9
|
+
let thread = memory.chats.find((c) => c.id === chatId);
|
|
10
|
+
if (!thread) {
|
|
11
|
+
thread = { id: chatId, agent, ownerLogin, mine: true, messages: [] };
|
|
12
|
+
memory.chats.unshift(thread);
|
|
13
|
+
}
|
|
14
|
+
for (const m of messages || []) thread.messages.push(m);
|
|
15
|
+
|
|
16
|
+
const events = (messages || []).map((m) => ({
|
|
17
|
+
type: m.role === "user" ? "user" : m.role === "tool" ? "edit" : "assistant",
|
|
18
|
+
text: m.text,
|
|
19
|
+
file: m.file,
|
|
20
|
+
}));
|
|
21
|
+
memory.timeline.unshift({
|
|
22
|
+
id: id("seg"),
|
|
23
|
+
agent,
|
|
24
|
+
ownerLogin,
|
|
25
|
+
mine: true,
|
|
26
|
+
ts,
|
|
27
|
+
title: `Stop sync · ${agent}`,
|
|
28
|
+
events,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
for (const edit of edits || []) {
|
|
32
|
+
memory.edits.unshift({
|
|
33
|
+
id: id("e"),
|
|
34
|
+
agent,
|
|
35
|
+
ownerLogin,
|
|
36
|
+
mine: true,
|
|
37
|
+
file: edit.file,
|
|
38
|
+
ts,
|
|
39
|
+
diff: edit.diff || "",
|
|
40
|
+
});
|
|
41
|
+
memory.activity.unshift({
|
|
42
|
+
id: id("a"),
|
|
43
|
+
kind: "edit",
|
|
44
|
+
agent,
|
|
45
|
+
ownerLogin,
|
|
46
|
+
mine: true,
|
|
47
|
+
text: `Edited ${edit.file}`,
|
|
48
|
+
ts,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
memory.stats = memory.stats || {};
|
|
53
|
+
memory.stats.events = (memory.stats.events || 0) + 1;
|
|
54
|
+
memory.stats.patches = memory.edits.length;
|
|
55
|
+
saveMemory(projectId, memory);
|
|
56
|
+
return memory;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function clientEventId(ts, filepath, index) {
|
|
60
|
+
return crypto.createHash("sha256").update(`${ts}|${filepath}|${index}`).digest("hex");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { appendStopTurn, clientEventId };
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
const crypto = require("crypto");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { dataDir } = require("./paths");
|
|
4
|
+
const { readJson, writeJson } = require("./store");
|
|
5
|
+
|
|
6
|
+
const MAX = 80;
|
|
7
|
+
|
|
8
|
+
function noticesPath() {
|
|
9
|
+
return path.join(dataDir(), "notices.json");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function load() {
|
|
13
|
+
const rows = readJson(noticesPath(), []);
|
|
14
|
+
return Array.isArray(rows) ? rows : [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function save(rows) {
|
|
18
|
+
writeJson(noticesPath(), rows.slice(0, MAX));
|
|
19
|
+
return rows;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function prune(rows, now = Date.now()) {
|
|
23
|
+
return rows.filter((n) => {
|
|
24
|
+
if (n.dismissedAt && now - n.dismissedAt > 7 * 24 * 60 * 60 * 1000) return false;
|
|
25
|
+
if (n.readAt && !n.dismissedAt && now - n.readAt > 14 * 24 * 60 * 60 * 1000) return false;
|
|
26
|
+
return true;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function view(row) {
|
|
31
|
+
return {
|
|
32
|
+
id: row.id,
|
|
33
|
+
type: row.type,
|
|
34
|
+
key: row.key,
|
|
35
|
+
title: row.title,
|
|
36
|
+
body: row.body || "",
|
|
37
|
+
action: row.action || null,
|
|
38
|
+
payload: row.payload || null,
|
|
39
|
+
workspaceId: row.workspaceId || null,
|
|
40
|
+
ts: row.ts,
|
|
41
|
+
readAt: row.readAt || null,
|
|
42
|
+
dismissedAt: row.dismissedAt || null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function listNotices({ includeDismissed } = {}) {
|
|
47
|
+
return prune(load())
|
|
48
|
+
.filter((n) => includeDismissed || !n.dismissedAt)
|
|
49
|
+
.sort((a, b) => (b.ts || 0) - (a.ts || 0))
|
|
50
|
+
.map(view);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function unreadCount() {
|
|
54
|
+
return listNotices().filter((n) => !n.readAt).length;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Inserts a notice unless one with the same key is already sitting unread.
|
|
59
|
+
* `revive` reopens a read/dismissed row (host came back, teammate rejoined).
|
|
60
|
+
*/
|
|
61
|
+
function pushNotice(input, { revive } = {}) {
|
|
62
|
+
if (!input?.type || !input?.key || !input?.title) return null;
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
const rows = prune(load(), now);
|
|
65
|
+
const existing = rows.find((n) => n.key === input.key);
|
|
66
|
+
if (existing && !revive) return null;
|
|
67
|
+
if (existing && revive) {
|
|
68
|
+
existing.title = input.title;
|
|
69
|
+
existing.body = input.body || existing.body || "";
|
|
70
|
+
existing.action = input.action || existing.action || null;
|
|
71
|
+
existing.payload = input.payload || existing.payload || null;
|
|
72
|
+
existing.workspaceId = input.workspaceId || existing.workspaceId || null;
|
|
73
|
+
existing.ts = now;
|
|
74
|
+
existing.readAt = null;
|
|
75
|
+
existing.dismissedAt = null;
|
|
76
|
+
save(rows);
|
|
77
|
+
return view(existing);
|
|
78
|
+
}
|
|
79
|
+
const row = {
|
|
80
|
+
id: `ntc_${crypto.randomBytes(6).toString("hex")}`,
|
|
81
|
+
type: input.type,
|
|
82
|
+
key: input.key,
|
|
83
|
+
title: input.title,
|
|
84
|
+
body: input.body || "",
|
|
85
|
+
action: input.action || null,
|
|
86
|
+
payload: input.payload || null,
|
|
87
|
+
workspaceId: input.workspaceId || null,
|
|
88
|
+
ts: now,
|
|
89
|
+
readAt: null,
|
|
90
|
+
dismissedAt: null,
|
|
91
|
+
};
|
|
92
|
+
save([row, ...rows.filter((n) => n.key !== input.key)]);
|
|
93
|
+
return view(row);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function markRead(id) {
|
|
97
|
+
const rows = load();
|
|
98
|
+
const row = rows.find((n) => n.id === id);
|
|
99
|
+
if (!row || row.readAt) return row ? view(row) : null;
|
|
100
|
+
row.readAt = Date.now();
|
|
101
|
+
save(rows);
|
|
102
|
+
return view(row);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function markAllRead() {
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
const rows = load().map((n) => (n.readAt || n.dismissedAt ? n : { ...n, readAt: now }));
|
|
108
|
+
save(rows);
|
|
109
|
+
return listNotices();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function dismissNotice(id) {
|
|
113
|
+
const rows = load();
|
|
114
|
+
const row = rows.find((n) => n.id === id);
|
|
115
|
+
if (!row) return null;
|
|
116
|
+
row.dismissedAt = Date.now();
|
|
117
|
+
row.readAt = row.readAt || row.dismissedAt;
|
|
118
|
+
save(rows);
|
|
119
|
+
return view(row);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function dismissByKey(key) {
|
|
123
|
+
if (!key) return 0;
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
let count = 0;
|
|
126
|
+
const rows = load().map((n) => {
|
|
127
|
+
if (n.key !== key || n.dismissedAt) return n;
|
|
128
|
+
count += 1;
|
|
129
|
+
return { ...n, dismissedAt: now, readAt: n.readAt || now };
|
|
130
|
+
});
|
|
131
|
+
if (count) save(rows);
|
|
132
|
+
return count;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The stored row for a key, dismissed or not (raw, with payload). */
|
|
136
|
+
function findNotice(key) {
|
|
137
|
+
if (!key) return null;
|
|
138
|
+
return prune(load()).find((n) => n.key === key) || null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = {
|
|
142
|
+
findNotice,
|
|
143
|
+
listNotices,
|
|
144
|
+
unreadCount,
|
|
145
|
+
pushNotice,
|
|
146
|
+
markRead,
|
|
147
|
+
markAllRead,
|
|
148
|
+
dismissNotice,
|
|
149
|
+
dismissByKey,
|
|
150
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ordered patch apply. Same-file overlapping hunks → manual merge (no busy-wait).
|
|
3
|
+
*/
|
|
4
|
+
function parseHunks(diff) {
|
|
5
|
+
const hunks = [];
|
|
6
|
+
let line = 0;
|
|
7
|
+
for (const raw of String(diff || "").split("\n")) {
|
|
8
|
+
const header = raw.match(/^@@ -(\d+)/);
|
|
9
|
+
if (header) {
|
|
10
|
+
line = Number(header[1]);
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
if (raw.startsWith("+") || raw.startsWith("-") || raw.startsWith(" ")) {
|
|
14
|
+
hunks.push({ line, kind: raw[0], text: raw.slice(1) });
|
|
15
|
+
if (raw[0] !== "+") line += 1;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return hunks;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function overlaps(a, b) {
|
|
22
|
+
const ha = parseHunks(a.diff);
|
|
23
|
+
const hb = parseHunks(b.diff);
|
|
24
|
+
if (!ha.length || !hb.length) return false;
|
|
25
|
+
const range = (hunks) => {
|
|
26
|
+
const lines = hunks.map((h) => h.line);
|
|
27
|
+
return [Math.min(...lines), Math.max(...lines)];
|
|
28
|
+
};
|
|
29
|
+
const [a0, a1] = range(ha);
|
|
30
|
+
const [b0, b1] = range(hb);
|
|
31
|
+
return a0 <= b1 && b0 <= a1;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function transformPair(first, second) {
|
|
35
|
+
if (first.file !== second.file) return { ok: true, patches: [first, second] };
|
|
36
|
+
if (first.sha256 && second.sha256 && first.binary) {
|
|
37
|
+
return first.lamport <= second.lamport
|
|
38
|
+
? { ok: true, patches: [first, second] }
|
|
39
|
+
: { ok: true, patches: [second, first] };
|
|
40
|
+
}
|
|
41
|
+
if (overlaps(first, second)) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
manual: true,
|
|
45
|
+
reason: `Conflicting patches on ${first.file} (Lamport ${first.lamport} vs ${second.lamport})`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const ordered = first.lamport <= second.lamport ? [first, second] : [second, first];
|
|
49
|
+
return { ok: true, patches: ordered };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function insertOrdered(buffer, patch) {
|
|
53
|
+
const next = [...buffer, patch].sort((a, b) => a.lamport - b.lamport || a.file.localeCompare(b.file));
|
|
54
|
+
return next;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function drain(buffer, lastApplied) {
|
|
58
|
+
const ready = [];
|
|
59
|
+
const held = [];
|
|
60
|
+
let applied = lastApplied || 0;
|
|
61
|
+
const sorted = [...buffer].sort((a, b) => a.lamport - b.lamport);
|
|
62
|
+
for (const p of sorted) {
|
|
63
|
+
if (p.lamport <= applied) continue;
|
|
64
|
+
if (applied === 0 || p.lamport === applied + 1) {
|
|
65
|
+
ready.push(p);
|
|
66
|
+
applied = p.lamport;
|
|
67
|
+
} else {
|
|
68
|
+
held.push(p);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { ready, held, lastApplied: applied };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { parseHunks, overlaps, transformPair, insertOrdered, drain };
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const { execFileSync } = require("child_process");
|
|
5
|
+
const { tick } = require("./lamport");
|
|
6
|
+
const { loadMemory, saveMemory, id } = require("./store");
|
|
7
|
+
const { recordCollision } = require("./collisionMetrics");
|
|
8
|
+
const { transformPair, insertOrdered, drain } = require("./ot");
|
|
9
|
+
|
|
10
|
+
function isBinary(buf) {
|
|
11
|
+
return buf.includes(0);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function sha256(buf) {
|
|
15
|
+
return crypto.createHash("sha256").update(buf).digest("hex");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function gitDiff(workspacePath, file) {
|
|
19
|
+
try {
|
|
20
|
+
return execFileSync("git", ["-C", workspacePath, "diff", "--", file], {
|
|
21
|
+
encoding: "utf8",
|
|
22
|
+
timeout: 2000,
|
|
23
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
24
|
+
windowsHide: true,
|
|
25
|
+
});
|
|
26
|
+
} catch {
|
|
27
|
+
return "";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function computePatch({ workspacePath, file, agent, ownerLogin, workspaceId }) {
|
|
32
|
+
const abs = path.join(workspacePath, file);
|
|
33
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return null;
|
|
34
|
+
const buf = fs.readFileSync(abs);
|
|
35
|
+
const binary = isBinary(buf);
|
|
36
|
+
const lamport = tick(workspaceId || workspacePath);
|
|
37
|
+
const patch = {
|
|
38
|
+
id: id("patch"),
|
|
39
|
+
file: file.replace(/\\/g, "/"),
|
|
40
|
+
agent,
|
|
41
|
+
ownerLogin,
|
|
42
|
+
lamport,
|
|
43
|
+
ts: Date.now(),
|
|
44
|
+
binary,
|
|
45
|
+
sha256: sha256(buf),
|
|
46
|
+
diff: binary ? "" : gitDiff(workspacePath, file) || buf.toString("utf8").slice(0, 4000),
|
|
47
|
+
content: binary ? null : undefined,
|
|
48
|
+
};
|
|
49
|
+
if (binary) patch.replace = true;
|
|
50
|
+
else if (buf.length < 1_000_000) patch.content = buf.toString("utf8");
|
|
51
|
+
return patch;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** True when the file already holds exactly this content. */
|
|
55
|
+
function matchesDisk(abs, patch) {
|
|
56
|
+
try {
|
|
57
|
+
return sha256(fs.readFileSync(abs)) === patch.sha256;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function applyPatchToDisk(workspacePath, patch) {
|
|
64
|
+
if (!workspacePath || !patch?.file) return { applied: false };
|
|
65
|
+
const abs = path.join(workspacePath, patch.file);
|
|
66
|
+
if (patch.binary) {
|
|
67
|
+
return { applied: false, reason: "binary_sha_replace", sha256: patch.sha256 };
|
|
68
|
+
}
|
|
69
|
+
if (typeof patch.content === "string") {
|
|
70
|
+
// Rewriting identical content still fires the filesystem watcher, which
|
|
71
|
+
// publishes a fresh patch (new id, so no dedupe catches it) straight back to
|
|
72
|
+
// the peer it came from — two machines in a room then trade the same file
|
|
73
|
+
// forever at full CPU. Content equality is the only thing that terminates
|
|
74
|
+
// that loop, because every hop legitimately looks like a new edit.
|
|
75
|
+
if (patch.sha256 && matchesDisk(abs, patch)) return { applied: false, reason: "identical" };
|
|
76
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
77
|
+
fs.writeFileSync(abs, patch.content);
|
|
78
|
+
return { applied: true };
|
|
79
|
+
}
|
|
80
|
+
return { applied: false, reason: "no_content" };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function seenPatch(memory, patch) {
|
|
84
|
+
const id = patch?.id;
|
|
85
|
+
if (!id) return false;
|
|
86
|
+
return (memory.patches || []).some((p) => p.id === id) || (memory.patchBuffer || []).some((p) => p.id === id);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Peer fan-out: write this file on MY tree. Host is not the source of truth. */
|
|
90
|
+
function applyPeerFile(projectId, patch, workspacePath) {
|
|
91
|
+
const memory = loadMemory(projectId);
|
|
92
|
+
if (seenPatch(memory, patch)) return { applied: false, duplicate: true };
|
|
93
|
+
memory.patches = insertOrdered(memory.patches || [], patch);
|
|
94
|
+
memory.edits = memory.edits || [];
|
|
95
|
+
memory.edits.unshift({
|
|
96
|
+
id: patch.id,
|
|
97
|
+
agent: patch.agent,
|
|
98
|
+
ownerLogin: patch.ownerLogin,
|
|
99
|
+
mine: false,
|
|
100
|
+
file: patch.file,
|
|
101
|
+
ts: patch.ts,
|
|
102
|
+
diff: patch.diff || `sha256:${patch.sha256}`,
|
|
103
|
+
lamport: patch.lamport,
|
|
104
|
+
binary: patch.binary,
|
|
105
|
+
});
|
|
106
|
+
let disk = { applied: false };
|
|
107
|
+
if (workspacePath) disk = applyPatchToDisk(workspacePath, patch);
|
|
108
|
+
saveMemory(projectId, memory);
|
|
109
|
+
require("./falkor/bus").emit("patch", { projectId, patch, mine: false });
|
|
110
|
+
return { ...disk, recorded: true };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function recordPatch(projectId, patch) {
|
|
114
|
+
const memory = loadMemory(projectId);
|
|
115
|
+
memory.patches = insertOrdered(memory.patches || [], patch);
|
|
116
|
+
memory.edits = memory.edits || [];
|
|
117
|
+
memory.edits.unshift({
|
|
118
|
+
id: patch.id,
|
|
119
|
+
agent: patch.agent,
|
|
120
|
+
ownerLogin: patch.ownerLogin,
|
|
121
|
+
mine: true,
|
|
122
|
+
file: patch.file,
|
|
123
|
+
ts: patch.ts,
|
|
124
|
+
diff: patch.diff || `sha256:${patch.sha256}`,
|
|
125
|
+
lamport: patch.lamport,
|
|
126
|
+
binary: patch.binary,
|
|
127
|
+
});
|
|
128
|
+
saveMemory(projectId, memory);
|
|
129
|
+
require("./falkor/bus").emit("patch", { projectId, patch, mine: true });
|
|
130
|
+
return memory;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function applyIncoming(projectId, patch, lastApplied, workspacePath) {
|
|
134
|
+
const memory = loadMemory(projectId);
|
|
135
|
+
memory.patchBuffer = insertOrdered(memory.patchBuffer || [], patch);
|
|
136
|
+
const { ready, held, lastApplied: next } = drain(
|
|
137
|
+
memory.patchBuffer,
|
|
138
|
+
lastApplied ?? memory.lastAppliedLamport ?? 0
|
|
139
|
+
);
|
|
140
|
+
memory.patchBuffer = held;
|
|
141
|
+
memory.lastAppliedLamport = next;
|
|
142
|
+
const conflicts = [];
|
|
143
|
+
for (let i = 1; i < ready.length; i++) {
|
|
144
|
+
const t = transformPair(ready[i - 1], ready[i]);
|
|
145
|
+
if (!t.ok) {
|
|
146
|
+
conflicts.push(t);
|
|
147
|
+
if (t.manual) {
|
|
148
|
+
recordCollision(projectId, "merge_flagged", {
|
|
149
|
+
file: ready[i].file,
|
|
150
|
+
reason: t.reason,
|
|
151
|
+
lamport: ready[i].lamport,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
memory.patches = ready.reduce((acc, p) => insertOrdered(acc, p), memory.patches || []);
|
|
157
|
+
const applied = [];
|
|
158
|
+
if (workspacePath) {
|
|
159
|
+
for (const p of ready) {
|
|
160
|
+
if (conflicts.some((c) => c.manual && (c.reason || "").includes(p.file))) continue;
|
|
161
|
+
applied.push({ file: p.file, ...applyPatchToDisk(workspacePath, p) });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
saveMemory(projectId, memory);
|
|
165
|
+
return { ready, conflicts, lastApplied: next, applied };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function replayUntil(projectId, lamport) {
|
|
169
|
+
const memory = loadMemory(projectId);
|
|
170
|
+
return (memory.patches || []).filter((p) => p.lamport <= lamport);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function rewindTo(projectId, workspacePath, lamport) {
|
|
174
|
+
const patches = replayUntil(projectId, lamport);
|
|
175
|
+
const latest = new Map();
|
|
176
|
+
for (const p of patches) latest.set(p.file, p);
|
|
177
|
+
const applied = [];
|
|
178
|
+
for (const p of latest.values()) applied.push({ file: p.file, ...applyPatchToDisk(workspacePath, p) });
|
|
179
|
+
return { lamport, files: [...latest.keys()], applied, patches };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = {
|
|
183
|
+
computePatch,
|
|
184
|
+
recordPatch,
|
|
185
|
+
applyIncoming,
|
|
186
|
+
applyPeerFile,
|
|
187
|
+
applyPatchToDisk,
|
|
188
|
+
replayUntil,
|
|
189
|
+
rewindTo,
|
|
190
|
+
isBinary,
|
|
191
|
+
sha256,
|
|
192
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const os = require("os");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const crypto = require("crypto");
|
|
5
|
+
|
|
6
|
+
function homeRelayDir() {
|
|
7
|
+
const dir = path.join(os.homedir(), ".relay");
|
|
8
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
9
|
+
return dir;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function tryMkdir(dir) {
|
|
13
|
+
try {
|
|
14
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
15
|
+
return true;
|
|
16
|
+
} catch {
|
|
17
|
+
// Missing drive (D:\ on a machine that only has C:), unplugged disk, or a
|
|
18
|
+
// host path from another OS. Callers fall back to ~/.relay instead of
|
|
19
|
+
// taking down relay serve.
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function dataDir() {
|
|
25
|
+
const dir = path.join(homeRelayDir(), "data");
|
|
26
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
27
|
+
return dir;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function registryPath() {
|
|
31
|
+
return path.join(dataDir(), "registry.json");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sessionPath() {
|
|
35
|
+
return path.join(dataDir(), "session.json");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function nativeWorkspaceRoot(workspacePath) {
|
|
39
|
+
if (!workspacePath) return workspacePath;
|
|
40
|
+
let trimmed = String(workspacePath).trim().replace(/^file:\/+/i, "/");
|
|
41
|
+
const drive = trimmed.match(/^\/?([a-zA-Z]):([\\/].*)$/);
|
|
42
|
+
if (drive) trimmed = `${drive[1]}:${drive[2]}`;
|
|
43
|
+
const doubled = trimmed.match(/^([a-zA-Z]):[\\/]+\1:[\\/]+(.*)$/i);
|
|
44
|
+
if (doubled) trimmed = `${doubled[1]}:\\${doubled[2]}`;
|
|
45
|
+
return path.resolve(trimmed);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function coordinatorStatePath(workspacePath) {
|
|
49
|
+
if (workspacePath) {
|
|
50
|
+
const dir = path.join(nativeWorkspaceRoot(workspacePath), ".relay");
|
|
51
|
+
if (tryMkdir(dir)) return path.join(dir, "coordinator-state.json");
|
|
52
|
+
}
|
|
53
|
+
return path.join(homeRelayDir(), "coordinator-state.json");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normKey(value) {
|
|
57
|
+
return String(value || "")
|
|
58
|
+
.replace(/\\/g, "/")
|
|
59
|
+
.replace(/\/+$/, "")
|
|
60
|
+
.toLowerCase();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function locksDirFor(workspacePath) {
|
|
64
|
+
const homeLocks = path.join(homeRelayDir(), "locks");
|
|
65
|
+
if (!workspacePath) {
|
|
66
|
+
tryMkdir(homeLocks);
|
|
67
|
+
return homeLocks;
|
|
68
|
+
}
|
|
69
|
+
const root = nativeWorkspaceRoot(workspacePath);
|
|
70
|
+
const dir = path.join(root, ".relay", "locks");
|
|
71
|
+
if (tryMkdir(dir)) return dir;
|
|
72
|
+
const slug = crypto.createHash("sha256").update(normKey(root)).digest("hex").slice(0, 16);
|
|
73
|
+
const fallback = path.join(homeLocks, slug);
|
|
74
|
+
tryMkdir(fallback);
|
|
75
|
+
return fallback;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = {
|
|
79
|
+
homeRelayDir,
|
|
80
|
+
dataDir,
|
|
81
|
+
registryPath,
|
|
82
|
+
sessionPath,
|
|
83
|
+
coordinatorStatePath,
|
|
84
|
+
locksDirFor,
|
|
85
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const os = require("os");
|
|
2
|
+
const { sameOwner } = require("../coordinator/lockTable");
|
|
3
|
+
|
|
4
|
+
function localHost() {
|
|
5
|
+
return String(os.hostname() || "").toLowerCase();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function holderHost(held) {
|
|
9
|
+
if (!held) return "";
|
|
10
|
+
if (held.holder?.host) return String(held.holder.host).toLowerCase();
|
|
11
|
+
const parts = String(held.agentId || "").split(":");
|
|
12
|
+
return (parts[1] || "").toLowerCase();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function holderIsLocal(held) {
|
|
16
|
+
const host = holderHost(held);
|
|
17
|
+
return Boolean(host) && host === localHost();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function aliveLock(lockTable, file, workspacePath) {
|
|
21
|
+
if (!lockTable || typeof lockTable.getLock !== "function") return null;
|
|
22
|
+
const held = lockTable.getLock(file, workspacePath);
|
|
23
|
+
if (!held) return null;
|
|
24
|
+
if (held.mode && held.mode !== "write") return null;
|
|
25
|
+
const ttl = held.ttlMs || 15_000;
|
|
26
|
+
if (Date.now() >= (held.lastHeartbeat || held.claimedAt) + ttl) return null;
|
|
27
|
+
return held;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* skip — remote holder (their apply landed here).
|
|
32
|
+
* defer — local agent still writing; wait for PostToolUse.
|
|
33
|
+
* publish — no live write lock.
|
|
34
|
+
*/
|
|
35
|
+
function skipPublish(lockTable, workspacePath, file) {
|
|
36
|
+
const held = aliveLock(lockTable, file, workspacePath);
|
|
37
|
+
if (!held) return { skip: false, defer: false, held: null };
|
|
38
|
+
if (!holderIsLocal(held)) {
|
|
39
|
+
return {
|
|
40
|
+
skip: true,
|
|
41
|
+
defer: false,
|
|
42
|
+
held,
|
|
43
|
+
reason: `${file} is locked by ${held.holder?.label || held.agentId}`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return { skip: false, defer: true, held };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Do not write a peer file onto a tree where a local agent is mid-edit,
|
|
51
|
+
* or where a different remote agent holds the lock.
|
|
52
|
+
*/
|
|
53
|
+
function applyClash(lockTable, workspacePath, file, patch) {
|
|
54
|
+
const held = aliveLock(lockTable, file, workspacePath);
|
|
55
|
+
if (!held) return null;
|
|
56
|
+
const label = held.holder?.label || held.agentId;
|
|
57
|
+
if (holderIsLocal(held)) {
|
|
58
|
+
return { clash: true, holder: held.agentId, reason: `${file} is locked locally by ${label}` };
|
|
59
|
+
}
|
|
60
|
+
if (patch?.originAgentId && sameOwner(held.agentId, patch.originAgentId)) return null;
|
|
61
|
+
return { clash: true, holder: held.agentId, reason: `${file} is locked by ${label}` };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { skipPublish, applyClash, holderIsLocal, aliveLock };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const { EventEmitter } = require("events");
|
|
2
|
+
|
|
3
|
+
class Presence extends EventEmitter {
|
|
4
|
+
constructor() {
|
|
5
|
+
super();
|
|
6
|
+
this.workspaces = new Map();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
join(workspaceId, userId) {
|
|
10
|
+
if (!workspaceId || !userId) return { count: 0, escalated: false };
|
|
11
|
+
if (!this.workspaces.has(workspaceId)) this.workspaces.set(workspaceId, new Set());
|
|
12
|
+
const set = this.workspaces.get(workspaceId);
|
|
13
|
+
const before = set.size;
|
|
14
|
+
set.add(userId);
|
|
15
|
+
const escalated = before < 2 && set.size >= 2;
|
|
16
|
+
this.emit("change", { workspaceId, userId, count: set.size, type: "join", escalated });
|
|
17
|
+
if (escalated) this.emit("escalate", { workspaceId, users: [...set] });
|
|
18
|
+
return { count: set.size, escalated };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
leave(workspaceId, userId) {
|
|
22
|
+
const set = this.workspaces.get(workspaceId);
|
|
23
|
+
if (!set) return { count: 0 };
|
|
24
|
+
set.delete(userId);
|
|
25
|
+
const deescalated = set.size < 2;
|
|
26
|
+
this.emit("change", { workspaceId, userId, count: set.size, type: "leave", deescalated });
|
|
27
|
+
return { count: set.size, deescalated };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
count(workspaceId) {
|
|
31
|
+
return this.workspaces.get(workspaceId)?.size || 0;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { Presence };
|