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,344 @@
|
|
|
1
|
+
// Named, parameterized, read-only queries: the only way anything reads the graph.
|
|
2
|
+
// No caller-supplied Cypher, so the surface is safe for room guests over the tunnel.
|
|
3
|
+
const keys = require("./keys");
|
|
4
|
+
|
|
5
|
+
const MAX_LIMIT = 50;
|
|
6
|
+
const ACTIVE_MS = 15 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
function limitOf(v, d = 10) {
|
|
9
|
+
const n = Number(v) || d;
|
|
10
|
+
return Math.max(1, Math.min(MAX_LIMIT, Math.floor(n)));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function requireFile(args) {
|
|
14
|
+
const fk = keys.fileKey(args.path);
|
|
15
|
+
if (!fk) throw Object.assign(new Error("`path` must be a repo-relative file path"), { status: 400 });
|
|
16
|
+
return fk;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function fileList(args) {
|
|
20
|
+
const raw = Array.isArray(args.paths) ? args.paths : String(args.paths || args.path || "").split(",");
|
|
21
|
+
const list = [...new Set(raw.map((p) => keys.fileKey(p)).filter(Boolean))].slice(0, 10);
|
|
22
|
+
if (!list.length) throw Object.assign(new Error("`paths` must list repo-relative files"), { status: 400 });
|
|
23
|
+
return list;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** RediSearch syntax characters would turn user text into a query language; keep words only. */
|
|
27
|
+
function searchTerms(q) {
|
|
28
|
+
const words = String(q || "")
|
|
29
|
+
.replace(/[^\p{L}\p{N}_\s]/gu, " ")
|
|
30
|
+
.split(/\s+/)
|
|
31
|
+
.filter((w) => w.length > 1)
|
|
32
|
+
.slice(0, 8);
|
|
33
|
+
return words.join(" ");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const Q = {
|
|
37
|
+
file_story: {
|
|
38
|
+
summary: "Every change to a file, newest first, with the prompt that caused it.",
|
|
39
|
+
cypher: `
|
|
40
|
+
MATCH (f:File {key: $fileKey})<-[:MODIFIED]-(e:Edit)-[:BY]->(a:Agent)
|
|
41
|
+
WHERE NOT (e)-[:SAME_CHANGE]->()
|
|
42
|
+
OPTIONAL MATCH (e)-[p:PROMPTED_BY]->(u:Turn)
|
|
43
|
+
OPTIONAL MATCH (e)-[:EXPLAINED_BY]->(x:Turn)
|
|
44
|
+
OPTIONAL MATCH (s:Session)-[:MADE]->(e)
|
|
45
|
+
RETURN e.ts AS ts, a.label AS agent, a.login AS person, e.source AS source, s.title AS session,
|
|
46
|
+
u.text AS prompt, p.via AS via, x.text AS agentSaid, e.added AS added, e.removed AS removed, e.diffHead AS diff
|
|
47
|
+
ORDER BY ts DESC LIMIT $limit`,
|
|
48
|
+
params: (a) => ({ fileKey: requireFile(a), limit: limitOf(a.limit) }),
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
who_to_ask: {
|
|
52
|
+
summary: "People (and their agents) who know a file best, by recency and volume.",
|
|
53
|
+
cypher: `
|
|
54
|
+
MATCH (p:Person)-[:RUNS]->(a:Agent)-[t:TOUCHED]->(f:File {key: $fileKey})
|
|
55
|
+
WITH p, sum(t.count) AS edits, max(t.lastAt) AS lastAt, collect(DISTINCT a.label) AS via
|
|
56
|
+
RETURN p.login AS person, edits, lastAt, via
|
|
57
|
+
ORDER BY lastAt DESC, edits DESC LIMIT $limit`,
|
|
58
|
+
params: (a) => ({ fileKey: requireFile(a), limit: limitOf(a.limit, 5) }),
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
contention_trace: {
|
|
62
|
+
summary: "Why agents were blocked: who held the file, what they were doing, who wanted it.",
|
|
63
|
+
cypher: `
|
|
64
|
+
MATCH (c:Contention)-[:ON]->(f:File)
|
|
65
|
+
WHERE $fileKey IS NULL OR f.key = $fileKey
|
|
66
|
+
OPTIONAL MATCH (c)-[:HELD_BY]->(hs:Session)
|
|
67
|
+
OPTIONAL MATCH (ha:Agent)-[:RAN]->(hs)
|
|
68
|
+
OPTIONAL MATCH (c)-[:WANTED_BY]->(ws:Session)
|
|
69
|
+
OPTIONAL MATCH (wa:Agent)-[:RAN]->(ws)
|
|
70
|
+
RETURN c.ts AS ts, c.lastTs AS lastTs, c.kind AS kind, c.attempts AS attempts, f.path AS file,
|
|
71
|
+
coalesce(ha.label, c.holderLabel) AS holder, ha.login AS holderPerson,
|
|
72
|
+
coalesce(c.holderIntent, hs.intent) AS holderWasDoing,
|
|
73
|
+
coalesce(wa.label, c.wanterLabel) AS wanter, wa.login AS wanterPerson, ws.intent AS wanterWasDoing,
|
|
74
|
+
c.reason AS reason
|
|
75
|
+
ORDER BY ts DESC LIMIT $limit`,
|
|
76
|
+
params: (a) => ({ fileKey: a.path ? requireFile(a) : null, limit: limitOf(a.limit) }),
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
search_memory: {
|
|
80
|
+
summary: "Full-text search across every agent conversation in the project.",
|
|
81
|
+
cypher: `
|
|
82
|
+
CALL db.idx.fulltext.queryNodes('Turn', $q) YIELD node, score
|
|
83
|
+
MATCH (s:Session)-[:HAS_TURN]->(node)
|
|
84
|
+
OPTIONAL MATCH (a:Agent)-[:RAN]->(s)
|
|
85
|
+
RETURN node.text AS text, node.ts AS ts, node.role AS role, a.label AS agent, a.login AS person,
|
|
86
|
+
s.title AS session, s.key AS sessionKey, score
|
|
87
|
+
ORDER BY score DESC LIMIT $limit`,
|
|
88
|
+
params: (a) => {
|
|
89
|
+
const q = searchTerms(a.q);
|
|
90
|
+
if (!q) throw Object.assign(new Error("`q` must contain words to search for"), { status: 400 });
|
|
91
|
+
return { q, limit: limitOf(a.limit) };
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
agent_activity: {
|
|
96
|
+
summary: "What an agent or person has been working on, session by session.",
|
|
97
|
+
cypher: `
|
|
98
|
+
MATCH (a:Agent)-[:RAN]->(s:Session)
|
|
99
|
+
WHERE ($label IS NULL OR a.label = $label) AND ($login IS NULL OR a.login = $login) AND s.lastAt >= $since
|
|
100
|
+
OPTIONAL MATCH (s)-[:MADE]->(e:Edit)-[:MODIFIED]->(f:File)
|
|
101
|
+
RETURN a.label AS agent, a.login AS person, s.title AS title, s.intent AS intent, s.lastAt AS lastAt,
|
|
102
|
+
s.turnCount AS turns, s.editCount AS edits, collect(DISTINCT f.path)[..15] AS files
|
|
103
|
+
ORDER BY lastAt DESC LIMIT $limit`,
|
|
104
|
+
params: (a) => ({
|
|
105
|
+
label: a.agent ? String(a.agent) : null,
|
|
106
|
+
login: a.person ? keys.loginKey(a.person) : null,
|
|
107
|
+
since: Number(a.since) || 0,
|
|
108
|
+
limit: limitOf(a.limit),
|
|
109
|
+
}),
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
hotspots: {
|
|
113
|
+
summary: "Files where coordination matters most: PageRank over co-edits, plus overlaps and blocks.",
|
|
114
|
+
cypher: `
|
|
115
|
+
MATCH (f:File) WHERE f.rank IS NOT NULL
|
|
116
|
+
OPTIONAL MATCH (f)<-[:ON]-(c:Contention)
|
|
117
|
+
OPTIONAL MATCH (f)<-[:ON]-(o:Overlap)
|
|
118
|
+
OPTIONAL MATCH (a:Agent)-[:TOUCHED]->(f)
|
|
119
|
+
RETURN f.path AS file, f.rank AS rank, f.community AS community, f.editCount AS edits,
|
|
120
|
+
count(DISTINCT c) AS contentions, count(DISTINCT o) AS overlaps, count(DISTINCT a) AS agents
|
|
121
|
+
ORDER BY rank DESC LIMIT $limit`,
|
|
122
|
+
params: (a) => ({ limit: limitOf(a.limit) }),
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
decision_trace: {
|
|
126
|
+
summary: "Decisions about a file (or all), who made them and in which session.",
|
|
127
|
+
cypher: `
|
|
128
|
+
MATCH (d:Decision)
|
|
129
|
+
WHERE $fileKey IS NULL OR (d)-[:ABOUT]->(:File {key: $fileKey})
|
|
130
|
+
OPTIONAL MATCH (a:Agent)-[:DECIDED]->(d)
|
|
131
|
+
OPTIONAL MATCH (d)-[r:DURING]->(s:Session)
|
|
132
|
+
OPTIONAL MATCH (d)-[:ABOUT]->(f:File)
|
|
133
|
+
RETURN d.key AS id, d.content AS decision, d.status AS status, d.updatedAt AS updatedAt, d.revisions AS revisions,
|
|
134
|
+
a.label AS agent, a.login AS person, s.title AS session, r.confidence AS sessionConfidence,
|
|
135
|
+
collect(DISTINCT f.path) AS files
|
|
136
|
+
ORDER BY updatedAt DESC LIMIT $limit`,
|
|
137
|
+
params: (a) => ({ fileKey: a.path ? requireFile(a) : null, limit: limitOf(a.limit) }),
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
overlaps: {
|
|
141
|
+
summary: "Places where two different agents changed the same file within minutes.",
|
|
142
|
+
cypher: `
|
|
143
|
+
MATCH (o:Overlap)-[:ON]->(f:File)
|
|
144
|
+
WHERE $fileKey IS NULL OR f.key = $fileKey
|
|
145
|
+
MATCH (o)-[:BETWEEN]->(e:Edit)-[:BY]->(a:Agent)
|
|
146
|
+
OPTIONAL MATCH (e)-[:PROMPTED_BY]->(u:Turn)
|
|
147
|
+
WITH o, f, collect({agent: a.label, person: a.login, ts: e.ts, prompt: u.text}) AS sides
|
|
148
|
+
RETURN o.ts AS ts, f.path AS file, o.gapMs AS gapMs, o.confidence AS confidence, sides
|
|
149
|
+
ORDER BY ts DESC LIMIT $limit`,
|
|
150
|
+
params: (a) => ({ fileKey: a.path ? requireFile(a) : null, limit: limitOf(a.limit) }),
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
overview: {
|
|
154
|
+
summary: "Counts by node label and relationship type.",
|
|
155
|
+
cypher: `
|
|
156
|
+
MATCH (n) WHERE NOT n:Meta
|
|
157
|
+
RETURN labels(n)[0] AS label, count(n) AS count ORDER BY count DESC`,
|
|
158
|
+
params: () => ({}),
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const NEIGHBORHOOD = `
|
|
163
|
+
MATCH (f:File {key: $fileKey})
|
|
164
|
+
OPTIONAL MATCH (f)<-[:MODIFIED]-(e:Edit)-[:BY]->(a:Agent)
|
|
165
|
+
WHERE NOT (e)-[:SAME_CHANGE]->()
|
|
166
|
+
OPTIONAL MATCH (s:Session)-[:MADE]->(e)
|
|
167
|
+
OPTIONAL MATCH (e)-[:PROMPTED_BY]->(u:Turn)
|
|
168
|
+
WITH f, e, a, s, u ORDER BY e.ts DESC LIMIT 25
|
|
169
|
+
RETURN f.key AS fileKey, f.path AS filePath, f.rank AS rank,
|
|
170
|
+
collect({edit: e.key, ts: e.ts, agent: a.key, agentLabel: a.label, person: a.login,
|
|
171
|
+
session: s.key, sessionTitle: s.title, sessionIntent: s.intent, prompt: u.key, promptText: u.text}) AS rows`;
|
|
172
|
+
|
|
173
|
+
const NEIGHBOR_EXTRAS = `
|
|
174
|
+
MATCH (f:File {key: $fileKey})
|
|
175
|
+
OPTIONAL MATCH (f)<-[:ON]-(c:Contention)
|
|
176
|
+
OPTIONAL MATCH (c)-[:HELD_BY]->(hs:Session)
|
|
177
|
+
OPTIONAL MATCH (c)-[:WANTED_BY]->(ws:Session)
|
|
178
|
+
WITH f, collect(DISTINCT {key: c.key, kind: c.kind, attempts: c.attempts, ts: c.ts, holder: hs.key, holderLabel: hs.label,
|
|
179
|
+
holderIntent: coalesce(c.holderIntent, hs.intent), wanter: ws.key, wanterLabel: ws.label}) AS contentions
|
|
180
|
+
OPTIONAL MATCH (f)<-[:ABOUT]-(d:Decision)
|
|
181
|
+
WITH f, contentions, collect(DISTINCT {key: d.key, content: d.content, status: d.status}) AS decisions
|
|
182
|
+
OPTIONAL MATCH (f)-[x:CO_EDITED]->(g:File)
|
|
183
|
+
WITH f, contentions, decisions, g, x ORDER BY x.weight DESC
|
|
184
|
+
RETURN contentions, decisions, collect({key: g.key, path: g.path, weight: x.weight})[..8] AS coEdited`;
|
|
185
|
+
|
|
186
|
+
const HOTSPOT_SEEDS = `MATCH (f:File) WHERE f.rank IS NOT NULL RETURN f.key AS k ORDER BY f.rank DESC LIMIT 1`;
|
|
187
|
+
|
|
188
|
+
/** Small node/edge set around one file, shaped for the Mission Control canvas. */
|
|
189
|
+
async function neighborhood(client, projectId, args) {
|
|
190
|
+
let fk = args.path ? requireFile(args) : null;
|
|
191
|
+
if (!fk) fk = (await client.read(projectId, HOTSPOT_SEEDS))[0]?.k || null;
|
|
192
|
+
if (!fk) return { nodes: [], edges: [], focus: null };
|
|
193
|
+
const [main] = await client.read(projectId, NEIGHBORHOOD, { fileKey: fk });
|
|
194
|
+
const [extra] = await client.read(projectId, NEIGHBOR_EXTRAS, { fileKey: fk });
|
|
195
|
+
if (!main) return { nodes: [], edges: [], focus: null };
|
|
196
|
+
const nodes = new Map();
|
|
197
|
+
const edges = [];
|
|
198
|
+
const node = (id, kind, label, props = {}) => {
|
|
199
|
+
if (id && !nodes.has(id)) nodes.set(id, { id, kind, label, props });
|
|
200
|
+
};
|
|
201
|
+
const seenEdges = new Set();
|
|
202
|
+
const edge = (from, to, type) => {
|
|
203
|
+
const k = `${from}|${to}|${type}`;
|
|
204
|
+
if (!from || !to || seenEdges.has(k)) return;
|
|
205
|
+
seenEdges.add(k);
|
|
206
|
+
edges.push({ from, to, type });
|
|
207
|
+
};
|
|
208
|
+
node(`file:${main.fileKey}`, "file", main.filePath, { rank: main.rank, focus: true });
|
|
209
|
+
for (const r of main.rows || []) {
|
|
210
|
+
if (!r.edit) continue;
|
|
211
|
+
node(`agent:${r.agent}`, "agent", `${r.agentLabel} · ${r.person}`, { person: r.person, label: r.agentLabel });
|
|
212
|
+
if (r.session) {
|
|
213
|
+
node(`session:${r.session}`, "session", r.sessionTitle || r.session.split("::")[1]?.slice(0, 8) || "session", { intent: r.sessionIntent });
|
|
214
|
+
edge(`agent:${r.agent}`, `session:${r.session}`, "RAN");
|
|
215
|
+
edge(`session:${r.session}`, `file:${main.fileKey}`, "EDITED");
|
|
216
|
+
} else {
|
|
217
|
+
edge(`agent:${r.agent}`, `file:${main.fileKey}`, "EDITED");
|
|
218
|
+
}
|
|
219
|
+
if (r.prompt && r.session) {
|
|
220
|
+
node(`turn:${r.prompt}`, "prompt", r.promptText ? String(r.promptText).slice(0, 80) : "prompt", { text: r.promptText });
|
|
221
|
+
edge(`turn:${r.prompt}`, `session:${r.session}`, "PROMPTED");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
for (const c of extra?.contentions || []) {
|
|
225
|
+
if (!c.key) continue;
|
|
226
|
+
node(`contention:${c.key}`, "contention", `${c.kind === "claim_blocked" ? "blocked" : c.kind} ×${c.attempts || 1}`, c);
|
|
227
|
+
edge(`contention:${c.key}`, `file:${main.fileKey}`, "ON");
|
|
228
|
+
if (c.holder) {
|
|
229
|
+
node(`session:${c.holder}`, "session", c.holderLabel || "holder", { intent: c.holderIntent });
|
|
230
|
+
edge(`contention:${c.key}`, `session:${c.holder}`, "HELD_BY");
|
|
231
|
+
}
|
|
232
|
+
if (c.wanter) {
|
|
233
|
+
node(`session:${c.wanter}`, "session", c.wanterLabel || "wanter", {});
|
|
234
|
+
edge(`contention:${c.key}`, `session:${c.wanter}`, "WANTED_BY");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
for (const d of extra?.decisions || []) {
|
|
238
|
+
if (!d.key) continue;
|
|
239
|
+
node(`decision:${d.key}`, "decision", String(d.content || "decision").slice(0, 60), d);
|
|
240
|
+
edge(`decision:${d.key}`, `file:${main.fileKey}`, "ABOUT");
|
|
241
|
+
}
|
|
242
|
+
for (const g of extra?.coEdited || []) {
|
|
243
|
+
if (!g.key) continue;
|
|
244
|
+
node(`file:${g.key}`, "file", g.path, { weight: g.weight });
|
|
245
|
+
edge(`file:${main.fileKey}`, `file:${g.key}`, "CO_EDITED");
|
|
246
|
+
}
|
|
247
|
+
return { focus: main.fileKey, nodes: [...nodes.values()].slice(0, 150), edges };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const BRIEF_EDITS = `
|
|
251
|
+
MATCH (f:File {key: $fileKey})<-[:MODIFIED]-(e:Edit)-[:BY]->(a:Agent)
|
|
252
|
+
WHERE NOT (e)-[:SAME_CHANGE]->()
|
|
253
|
+
OPTIONAL MATCH (e)-[:PROMPTED_BY]->(u:Turn)
|
|
254
|
+
RETURN e.ts AS ts, a.label AS agent, a.login AS person, u.text AS prompt
|
|
255
|
+
ORDER BY ts DESC LIMIT 8`;
|
|
256
|
+
const BRIEF_FILE = `MATCH (f:File {key: $fileKey}) RETURN f.path AS path, f.rank AS rank, f.community AS community, f.editCount AS edits`;
|
|
257
|
+
const BRIEF_ACTIVE = `
|
|
258
|
+
MATCH (s:Session)-[h:HELD]->(f:File {key: $fileKey})
|
|
259
|
+
WHERE h.lastAt >= $activeSince
|
|
260
|
+
OPTIONAL MATCH (a:Agent)-[:RAN]->(s)
|
|
261
|
+
RETURN coalesce(a.label, s.label) AS agent, a.login AS person, s.intent AS intent, h.lastAt AS lastAt
|
|
262
|
+
ORDER BY lastAt DESC LIMIT 3`;
|
|
263
|
+
const BRIEF_NEIGHBORS = `
|
|
264
|
+
MATCH (f:File {key: $fileKey})
|
|
265
|
+
WHERE f.community IS NOT NULL
|
|
266
|
+
MATCH (g:File) WHERE g.community = f.community AND g.key <> f.key
|
|
267
|
+
MATCH (a:Agent)-[t:TOUCHED]->(g) WHERE t.lastAt >= $activeSince
|
|
268
|
+
RETURN g.path AS file, a.label AS agent, a.login AS person, t.lastAt AS lastAt
|
|
269
|
+
ORDER BY lastAt DESC LIMIT 3`;
|
|
270
|
+
|
|
271
|
+
function ago(ts, now) {
|
|
272
|
+
if (!ts) return "";
|
|
273
|
+
const m = Math.round((now - ts) / 60000);
|
|
274
|
+
if (m < 60) return `${m}m ago`;
|
|
275
|
+
if (m < 48 * 60) return `${Math.round(m / 60)}h ago`;
|
|
276
|
+
return `${Math.round(m / 1440)}d ago`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function clipLine(s, n = 140) {
|
|
280
|
+
const t = String(s || "").replace(/\s+/g, " ").trim();
|
|
281
|
+
return t.length > n ? `${t.slice(0, n)}…` : t;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Everything an agent should know before touching these files, as data and as markdown. */
|
|
285
|
+
async function sessionBrief(client, projectId, args, run) {
|
|
286
|
+
const files = fileList(args);
|
|
287
|
+
const now = Date.now();
|
|
288
|
+
const activeSince = now - ACTIVE_MS;
|
|
289
|
+
const out = [];
|
|
290
|
+
for (const fileKey of files) {
|
|
291
|
+
const [info] = await client.read(projectId, BRIEF_FILE, { fileKey });
|
|
292
|
+
const recent = await client.read(projectId, BRIEF_EDITS, { fileKey });
|
|
293
|
+
const people = (await run("who_to_ask", { path: fileKey, limit: 3 })).rows;
|
|
294
|
+
const decisions = (await run("decision_trace", { path: fileKey, limit: 3 })).rows;
|
|
295
|
+
const blocks = (await run("contention_trace", { path: fileKey, limit: 3 })).rows;
|
|
296
|
+
const active = await client.read(projectId, BRIEF_ACTIVE, { fileKey, activeSince });
|
|
297
|
+
const nearby = await client.read(projectId, BRIEF_NEIGHBORS, { fileKey, activeSince });
|
|
298
|
+
out.push({ file: info?.path || fileKey, known: Boolean(info), rank: info?.rank ?? null, community: info?.community ?? null, edits: info?.edits || 0, recent, people, decisions, blocks, active, nearby });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const md = ["# Relay memory brief", ""];
|
|
302
|
+
for (const f of out) {
|
|
303
|
+
md.push(`## ${f.file}`);
|
|
304
|
+
if (!f.known) {
|
|
305
|
+
md.push("- No history in the project memory graph yet.", "");
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (f.active.length) for (const a of f.active) md.push(`- ⚠️ **In use right now** by ${a.agent}${a.person ? ` (${a.person})` : ""}${a.intent ? `: "${clipLine(a.intent)}"` : ""}`);
|
|
309
|
+
for (const b of f.blocks) md.push(`- Contention ${ago(b.ts, now)}: ${b.wanter} was blocked by ${b.holder}${b.holderWasDoing ? ` while it worked on "${clipLine(b.holderWasDoing, 90)}"` : ""}`);
|
|
310
|
+
for (const d of f.decisions) md.push(`- Decision (${d.status}): ${clipLine(d.decision)}${d.agent ? `, by ${d.agent}` : ""}`);
|
|
311
|
+
const why = new Map();
|
|
312
|
+
for (const r of f.recent) {
|
|
313
|
+
const k = `${r.agent}|${r.person}|${r.prompt || ""}`;
|
|
314
|
+
if (why.has(k)) why.get(k).n += 1;
|
|
315
|
+
else why.set(k, { ...r, n: 1 });
|
|
316
|
+
}
|
|
317
|
+
for (const r of why.values()) {
|
|
318
|
+
md.push(`- ${ago(r.ts, now)} ${r.agent} (${r.person}) changed it${r.n > 1 ? ` ${r.n}×` : ""} because: "${clipLine(r.prompt || "unknown")}"`);
|
|
319
|
+
}
|
|
320
|
+
if (f.people.length) md.push(`- Ask: ${f.people.map((p) => `${p.person} (${p.edits} edits via ${p.via.join("/")})`).join(", ")}`);
|
|
321
|
+
for (const n of f.nearby) md.push(`- Same work area: ${n.file} touched ${ago(n.lastAt, now)} by ${n.agent} (${n.person})`);
|
|
322
|
+
md.push("");
|
|
323
|
+
}
|
|
324
|
+
return { files: out, markdown: md.join("\n") };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function names() {
|
|
328
|
+
return [...Object.keys(Q), "session_brief", "neighborhood"];
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Runs one named query. Throws a 400-style error on bad input. */
|
|
332
|
+
async function runNamed(client, projectId, name, args = {}) {
|
|
333
|
+
if (!projectId) throw Object.assign(new Error("no project"), { status: 404 });
|
|
334
|
+
const run = (n, a) => runNamed(client, projectId, n, a);
|
|
335
|
+
if (name === "session_brief") return { query: name, ...(await sessionBrief(client, projectId, args, run)) };
|
|
336
|
+
if (name === "neighborhood") return { query: name, ...(await neighborhood(client, projectId, args)) };
|
|
337
|
+
const def = Q[name];
|
|
338
|
+
if (!def) throw Object.assign(new Error(`unknown query "${name}". Try: ${names().join(", ")}`), { status: 400 });
|
|
339
|
+
const params = def.params(args);
|
|
340
|
+
const rows = await client.read(projectId, def.cypher, params);
|
|
341
|
+
return { query: name, summary: def.summary, cypher: def.cypher.trim(), params, rows };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
module.exports = { runNamed, names, searchTerms, Q };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const PATTERNS = [
|
|
2
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
3
|
+
/\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}/g,
|
|
4
|
+
/\bgh[pousr]_[A-Za-z0-9]{30,}/g,
|
|
5
|
+
/\bgithub_pat_[A-Za-z0-9_]{30,}/g,
|
|
6
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
7
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}/g,
|
|
8
|
+
/\bAIza[0-9A-Za-z_-]{35}\b/g,
|
|
9
|
+
/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
|
|
10
|
+
];
|
|
11
|
+
const ASSIGNMENT = /\b(password|passwd|secret|api[_-]?key|token)(\s*[:=]\s*)(["']?)[^\s"']{6,}\3/gi;
|
|
12
|
+
|
|
13
|
+
function redact(text) {
|
|
14
|
+
let s = String(text ?? "");
|
|
15
|
+
for (const re of PATTERNS) s = s.replace(re, "[redacted]");
|
|
16
|
+
return s.replace(ASSIGNMENT, (_m, name, sep) => `${name}${sep}[redacted]`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function clip(text, max) {
|
|
20
|
+
const s = String(text ?? "");
|
|
21
|
+
return s.length > max ? `${s.slice(0, max)}…` : s;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Text as stored in the graph: redacted, clipped, or dropped entirely when text storage is off. */
|
|
25
|
+
function storedText(text, max, storeText = true) {
|
|
26
|
+
if (!storeText) return null;
|
|
27
|
+
return clip(redact(text), max);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { redact, clip, storedText };
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
const { runNamed, names } = require("./query");
|
|
2
|
+
const { projectIdFor } = require("./coord");
|
|
3
|
+
const { demoGraphId, seedDemoGraph, rebuildProjectGraph } = require("./demo");
|
|
4
|
+
const { loadRoom } = require("../room");
|
|
5
|
+
const { loadRegistry, loadSession } = require("../store");
|
|
6
|
+
|
|
7
|
+
let api = null;
|
|
8
|
+
|
|
9
|
+
function argsFrom(req) {
|
|
10
|
+
const q = { ...(req.query || {}), ...(req.body || {}) };
|
|
11
|
+
if (typeof q.paths === "string") q.paths = q.paths.split(",").filter(Boolean);
|
|
12
|
+
return q;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isDemoMode(args) {
|
|
16
|
+
return args?.demo === "1" || args?.demo === 1 || args?.demo === true || args?.mode === "demo";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function resolveProjectId(args) {
|
|
20
|
+
if (args.projectId) return String(args.projectId);
|
|
21
|
+
const byPath = projectIdFor(args.workspace);
|
|
22
|
+
if (byPath) return byPath;
|
|
23
|
+
const reg = loadRegistry();
|
|
24
|
+
const session = loadSession();
|
|
25
|
+
return reg.projects.find((p) => p.id === session.projectId)?.id || reg.projects[0]?.id || null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function projectPath(projectId) {
|
|
29
|
+
return loadRegistry().projects.find((p) => p.id === projectId)?.path || null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A guest's graph lives on the host; ask it through the room's existing read-only MCP endpoint. */
|
|
33
|
+
async function askHost(room, name, args) {
|
|
34
|
+
const body = {
|
|
35
|
+
jsonrpc: "2.0",
|
|
36
|
+
id: 1,
|
|
37
|
+
method: "tools/call",
|
|
38
|
+
params: { name: "relay_graph_query", arguments: { ...args, query: name, projectId: undefined, workspace: undefined } },
|
|
39
|
+
};
|
|
40
|
+
const res = await api.state.deps.fetchHost(room.url, "/mcp", { method: "POST", body: JSON.stringify(body), timeoutMs: 4000 });
|
|
41
|
+
const json = await res.json();
|
|
42
|
+
if (json.error) throw Object.assign(new Error(json.error.message || "host query failed"), { status: 502 });
|
|
43
|
+
const text = json.result?.content?.[0]?.text;
|
|
44
|
+
return { ...JSON.parse(text || "{}"), source: "host" };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Local graph first unless this machine is a room guest, in which case the host's graph is the room brain. */
|
|
48
|
+
async function query(name, args) {
|
|
49
|
+
const baseProjectId = resolveProjectId(args);
|
|
50
|
+
const demo = isDemoMode(args);
|
|
51
|
+
const targetGraphId = demo ? demoGraphId(baseProjectId) : baseProjectId;
|
|
52
|
+
const room = loadRoom(args.workspace || projectPath(baseProjectId) || undefined);
|
|
53
|
+
if (!demo && room?.role === "guest" && room.url && api.state.deps.fetchHost && !args.local) {
|
|
54
|
+
try {
|
|
55
|
+
return await askHost(room, name, args);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if (!api.state.started) {
|
|
58
|
+
throw Object.assign(new Error(`room host unreachable (${err.message || err}) and this machine has no memory graph of its own`), {
|
|
59
|
+
status: 503,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/* tunnel down: fall back to whatever this machine's own graph knows */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!api.state.started) return { ok: false, reason: "falkor_disabled", hint: "set RELAY_FALKOR_URL and restart relay serve" };
|
|
66
|
+
if (!api.client.available()) return { ok: false, reason: "falkor_unavailable", breaker: api.client.status() };
|
|
67
|
+
await api.ensureProject(targetGraphId);
|
|
68
|
+
return {
|
|
69
|
+
ok: true,
|
|
70
|
+
projectId: baseProjectId,
|
|
71
|
+
graphId: targetGraphId,
|
|
72
|
+
mode: demo ? "demo" : "live",
|
|
73
|
+
source: "local",
|
|
74
|
+
...(await runNamed(api.client, targetGraphId, name, args)),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function register(app, falkor) {
|
|
79
|
+
api = {
|
|
80
|
+
...falkor,
|
|
81
|
+
get client() {
|
|
82
|
+
return falkor.state.client;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
const guard = falkor.state.deps.requireRoomMember || ((_req, _res, next) => next());
|
|
86
|
+
const handler = async (req, res) => {
|
|
87
|
+
try {
|
|
88
|
+
res.json(await query(req.params.name, argsFrom(req)));
|
|
89
|
+
} catch (err) {
|
|
90
|
+
res.status(err.status || 500).json({ ok: false, reason: String(err.message || err), queries: names() });
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
app.get("/api/graph/queries", (_req, res) => res.json({ queries: names() }));
|
|
94
|
+
app.get("/api/graph/query/:name", guard, handler);
|
|
95
|
+
app.post("/api/graph/query/:name", guard, handler);
|
|
96
|
+
|
|
97
|
+
// Local-only actions: seed the isolated demo sandbox or rebuild a real project graph from live transcripts.
|
|
98
|
+
app.post("/api/graph/seed-demo", async (req, res) => {
|
|
99
|
+
if (require("../roomAuth").isRemoteRequest(req)) return res.status(403).json({ ok: false, reason: "local requests only" });
|
|
100
|
+
if (!api.state.started) return res.status(409).json({ ok: false, reason: "falkor_disabled" });
|
|
101
|
+
try {
|
|
102
|
+
const pid = resolveProjectId(argsFrom(req)) || "sandbox";
|
|
103
|
+
const out = await seedDemoGraph(falkor, pid);
|
|
104
|
+
res.json({ ok: true, projectId: pid, ...out });
|
|
105
|
+
} catch (err) {
|
|
106
|
+
res.status(err.status || 500).json({ ok: false, reason: String(err.message || err) });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
app.post("/api/graph/rebuild", async (req, res) => {
|
|
111
|
+
if (require("../roomAuth").isRemoteRequest(req)) return res.status(403).json({ ok: false, reason: "local requests only" });
|
|
112
|
+
if (!api.state.started) return res.status(409).json({ ok: false, reason: "falkor_disabled" });
|
|
113
|
+
try {
|
|
114
|
+
const q = argsFrom(req);
|
|
115
|
+
const pid = resolveProjectId(q);
|
|
116
|
+
if (!pid) return res.status(404).json({ ok: false, reason: "no project selected" });
|
|
117
|
+
const out = await rebuildProjectGraph(falkor, pid, { keep: Boolean(q.keep) });
|
|
118
|
+
res.json({ ok: true, ...out });
|
|
119
|
+
} catch (err) {
|
|
120
|
+
res.status(err.status || 500).json({ ok: false, reason: String(err.message || err) });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { register, query };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
const { ConstraintType, EntityType } = require("falkordb");
|
|
2
|
+
|
|
3
|
+
const SCHEMA_VERSION = 1;
|
|
4
|
+
// Bump when projector/key logic changes in a way that would re-key existing nodes.
|
|
5
|
+
const INGEST_VERSION = 6;
|
|
6
|
+
|
|
7
|
+
const KEYED_LABELS = ["Person", "Agent", "Session", "Turn", "Edit", "File", "Decision", "Task", "Contention", "Overlap", "Meta"];
|
|
8
|
+
|
|
9
|
+
const RANGE_INDEXES = [
|
|
10
|
+
...KEYED_LABELS.map((label) => [label, "key"]),
|
|
11
|
+
["Edit", "ts"],
|
|
12
|
+
["Turn", "ts"],
|
|
13
|
+
["Contention", "ts"],
|
|
14
|
+
["Session", "lastAt"],
|
|
15
|
+
["Session", "label"],
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const FULLTEXT_INDEXES = [
|
|
19
|
+
["Turn", "text"],
|
|
20
|
+
["Decision", "content"],
|
|
21
|
+
["Task", "description"],
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const ensured = new Map();
|
|
25
|
+
|
|
26
|
+
function has(existing, label, prop, type) {
|
|
27
|
+
return existing.some((row) => row.label === label && (row.types?.[prop] || []).includes(type));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function bootstrap(client, projectId) {
|
|
31
|
+
// Writing Meta first creates the graph; read-only calls on a missing graph error.
|
|
32
|
+
const meta = await client.write(
|
|
33
|
+
projectId,
|
|
34
|
+
`MERGE (m:Meta {key: 'meta'})
|
|
35
|
+
ON CREATE SET m.schemaVersion = $schema, m.ingestVersion = $ingest, m.createdAt = $now
|
|
36
|
+
RETURN m.schemaVersion AS schemaVersion, m.ingestVersion AS ingestVersion, m.createdAt AS createdAt,
|
|
37
|
+
m.lastIngestAt AS lastIngestAt`,
|
|
38
|
+
{ schema: SCHEMA_VERSION, ingest: INGEST_VERSION, now: Date.now() }
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const existing = await client.read(projectId, "CALL db.indexes() YIELD label, properties, types RETURN label, properties, types");
|
|
42
|
+
for (const [label, prop] of RANGE_INDEXES) {
|
|
43
|
+
if (has(existing, label, prop, "RANGE")) continue;
|
|
44
|
+
await client.write(projectId, `CREATE INDEX FOR (n:${label}) ON (n.${prop})`);
|
|
45
|
+
}
|
|
46
|
+
for (const [label, prop] of FULLTEXT_INDEXES) {
|
|
47
|
+
if (has(existing, label, prop, "FULLTEXT")) continue;
|
|
48
|
+
await client.write(projectId, `CREATE FULLTEXT INDEX FOR (n:${label}) ON (n.${prop})`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Unique constraints are a backstop (MERGE already guarantees one node per key). Awaited
|
|
52
|
+
// on purpose: CONSTRAINT CREATE returns PENDING at once, and a fire-and-forget call that
|
|
53
|
+
// lands after the graph was deleted silently recreates it.
|
|
54
|
+
try {
|
|
55
|
+
const graph = await client.graph(projectId);
|
|
56
|
+
const current = await client.read(projectId, "CALL db.constraints() YIELD label, properties RETURN label, properties");
|
|
57
|
+
for (const label of KEYED_LABELS) {
|
|
58
|
+
if (current.some((c) => c.label === label && (c.properties || []).includes("key"))) continue;
|
|
59
|
+
await graph.constraintCreate(ConstraintType.UNIQUE, EntityType.NODE, label, "key").catch(() => undefined);
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
/* constraints are optional */
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const { runMigrations } = require("./migrations");
|
|
66
|
+
let migResult = { applied: [], ran: [] };
|
|
67
|
+
try {
|
|
68
|
+
migResult = await runMigrations(client, projectId, {
|
|
69
|
+
schemaVersion: SCHEMA_VERSION,
|
|
70
|
+
ingestVersion: INGEST_VERSION,
|
|
71
|
+
});
|
|
72
|
+
} catch {
|
|
73
|
+
/* keep existing meta row if migrations cannot run */
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const refreshed = await client.read(
|
|
77
|
+
projectId,
|
|
78
|
+
"MATCH (m:Meta {key: 'meta'}) RETURN m.schemaVersion AS schemaVersion, m.ingestVersion AS ingestVersion, m.createdAt AS createdAt, m.lastIngestAt AS lastIngestAt"
|
|
79
|
+
);
|
|
80
|
+
const row = refreshed[0] || meta[0] || {};
|
|
81
|
+
return {
|
|
82
|
+
schemaVersion: row.schemaVersion,
|
|
83
|
+
ingestVersion: row.ingestVersion,
|
|
84
|
+
createdAt: row.createdAt,
|
|
85
|
+
lastIngestAt: row.lastIngestAt || 0,
|
|
86
|
+
migrations: migResult.applied,
|
|
87
|
+
migrated: migResult.ran,
|
|
88
|
+
needsRebuild: row.schemaVersion !== SCHEMA_VERSION || row.ingestVersion !== INGEST_VERSION,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Idempotent, once per process per project; a failed attempt is retried on the next call. */
|
|
93
|
+
function ensure(client, projectId) {
|
|
94
|
+
if (!ensured.has(projectId)) {
|
|
95
|
+
const pending = bootstrap(client, projectId).catch((err) => {
|
|
96
|
+
ensured.delete(projectId);
|
|
97
|
+
throw err;
|
|
98
|
+
});
|
|
99
|
+
ensured.set(projectId, pending);
|
|
100
|
+
}
|
|
101
|
+
return ensured.get(projectId);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function forget(projectId) {
|
|
105
|
+
ensured.delete(projectId);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = { ensure, forget, bootstrap, SCHEMA_VERSION, INGEST_VERSION, KEYED_LABELS };
|