relaybrain 0.2.0 → 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/README.md +434 -291
- package/backend/lib/falkor/demo.js +221 -0
- package/backend/lib/falkor/migrations.js +56 -56
- package/backend/lib/falkor/routes.js +52 -8
- package/backend/lib/localBrowse.js +109 -109
- package/backend/lib/notices.js +7 -0
- package/backend/lib/room.js +152 -12
- package/backend/lib/transcripts/discover.js +109 -10
- package/backend/server.js +62 -7
- package/cli/graph.js +8 -0
- package/cli/relay.js +64 -8
- package/docs/MEMORY-GRAPH.md +57 -12
- package/mission-control/out/404.html +1 -1
- package/mission-control/out/__next.__PAGE__.txt +3 -3
- package/mission-control/out/__next._full.txt +3 -3
- package/mission-control/out/__next._tree.txt +2 -2
- package/mission-control/out/_next/static/chunks/064ntycm-fx5v.js +1 -0
- package/mission-control/out/_next/static/chunks/{25rvg4bnew99f.css → 1-dy52ls2ntb_.css} +1 -1
- package/mission-control/out/_next/static/chunks/31qgr-p-oulpb.js +1 -0
- package/mission-control/out/_not-found/__next._full.txt +1 -1
- package/mission-control/out/_not-found/__next._not-found/__PAGE__.txt +1 -1
- package/mission-control/out/_not-found/__next._tree.txt +1 -1
- package/mission-control/out/_not-found.html +1 -1
- package/mission-control/out/_not-found.txt +1 -1
- package/mission-control/out/index.html +1 -1
- package/mission-control/out/index.txt +3 -3
- package/package.json +76 -76
- package/scripts/falkor-rebuild.js +8 -27
- package/scripts/falkor-seed-demo.js +12 -140
- package/mission-control/out/_next/static/chunks/3_5x4qn1_b4al.js +0 -1
- package/mission-control/out/_next/static/chunks/3o1tiwncjss_k.js +0 -1
- /package/mission-control/out/_next/static/{dglM39XRu0cmneClUkGYd → BuhfCxd0WQE65yIzZJCOM}/_buildManifest.js +0 -0
- /package/mission-control/out/_next/static/{dglM39XRu0cmneClUkGYd → BuhfCxd0WQE65yIzZJCOM}/_clientMiddlewareManifest.js +0 -0
- /package/mission-control/out/_next/static/{dglM39XRu0cmneClUkGYd → BuhfCxd0WQE65yIzZJCOM}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Shared helper for seeding the isolated 5-agent demo sandbox (`demo_<projectId>`)
|
|
2
|
+
// and rebuilding a real workspace graph (`<projectId>`) from real transcripts & history.
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const schema = require("./schema");
|
|
5
|
+
const cursor = require("./cursor");
|
|
6
|
+
const { projectHistory, projectPeer } = require("./project");
|
|
7
|
+
const { writeProjection } = require("./write");
|
|
8
|
+
const { ingestHistory, ingestPeer } = require("./ingest");
|
|
9
|
+
const coord = require("./coord");
|
|
10
|
+
const central = require("./central");
|
|
11
|
+
const analytics = require("./analytics");
|
|
12
|
+
const intents = require("./intents");
|
|
13
|
+
const { loadRegistry, loadMemory } = require("../store");
|
|
14
|
+
|
|
15
|
+
const WS = process.platform === "win32" ? "C:\\demo\\relay-app" : "/workspace/relay-app";
|
|
16
|
+
const wsFile = (...parts) => path.join(WS, ...parts);
|
|
17
|
+
|
|
18
|
+
const S_CLAUDE = "52b29a54-d8fb-4091-ab60-463ac73c9d6c";
|
|
19
|
+
const S_CURSOR = "3e62675d-9b0f-4d99-b17d-c1d875fe98dc";
|
|
20
|
+
const S_CODEX = "019e1fdf-e54d-70e2-ae26-d0d7103a404f";
|
|
21
|
+
const S_COPILOT = "fcc51653-804f-4e4f-9330-347a42abd57c";
|
|
22
|
+
const S_ANTI = "46d9adf1-1b25-4cf7-bd41-da8309d19f08";
|
|
23
|
+
|
|
24
|
+
function demoGraphId(projectId) {
|
|
25
|
+
const base = String(projectId || "sandbox").replace(/^demo_/, "");
|
|
26
|
+
return `demo_${base}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function buildDemoHistory(now = Date.now()) {
|
|
30
|
+
const m = (minsAgo) => now - minsAgo * 60_000;
|
|
31
|
+
return [
|
|
32
|
+
// Session 1: Claude Code (@alice) switching login flow to JWT tokens
|
|
33
|
+
{ id: "d01", agent: "Claude Code", ownerLogin: "alice", sessionId: S_CLAUDE, ts: m(25), role: "user", kind: "message", text: "switch the login flow to short-lived JWT tokens with refresh rotation" },
|
|
34
|
+
{ id: "d02", agent: "Claude Code", ownerLogin: "alice", sessionId: S_CLAUDE, ts: m(24.5), role: "assistant", kind: "message", text: "I'll update src/auth.ts to issue 15-minute access JWTs and rotate refresh tokens in src/session.ts." },
|
|
35
|
+
{ id: "d03", agent: "Claude Code", ownerLogin: "alice", sessionId: S_CLAUDE, ts: m(24), role: "tool", kind: "code_edit", text: "Edit auth.ts", file: "auth.ts", path: wsFile("src", "auth.ts"), diff: "--- a/src/auth.ts\n+++ b/src/auth.ts\n@@ -10,4 +10,8 @@\n-export function loginWithCookie(user) {\n+export function issueJwtPair(user) {\n+ return signJwt(user, { expiresIn: '15m' });\n" },
|
|
36
|
+
{ id: "d04", agent: "Claude Code", ownerLogin: "alice", sessionId: S_CLAUDE, ts: m(23), role: "tool", kind: "code_edit", text: "Edit session.ts", file: "session.ts", path: wsFile("src", "session.ts"), diff: "--- a/src/session.ts\n+++ b/src/session.ts\n@@ -1,3 +1,6 @@\n+export function rotateRefreshToken(token) {\n+ return revokeAndMint(token);\n+}\n" },
|
|
37
|
+
{ id: "d05", agent: "Claude Code", ownerLogin: "alice", sessionId: S_CLAUDE, ts: m(22), role: "user", kind: "message", text: "continue" },
|
|
38
|
+
{ id: "d06", agent: "Claude Code", ownerLogin: "alice", sessionId: S_CLAUDE, ts: m(21.5), role: "tool", kind: "code_edit", text: "Edit middleware.ts", file: "middleware.ts", path: wsFile("src", "middleware.ts"), diff: "--- a/src/middleware.ts\n+++ b/src/middleware.ts\n@@ -4,2 +4,5 @@\n+export function verifyBearerJwt(req, res, next) {\n+ req.user = verifyJwt(req.headers.authorization);\n+}\n" },
|
|
39
|
+
|
|
40
|
+
// Session 2: Codex (@alice) hardening rate limits on auth & api routes
|
|
41
|
+
{ id: "d07", agent: "Codex", ownerLogin: "alice", sessionId: S_CODEX, ts: m(18), role: "user", kind: "message", text: "add sliding-window rate limiting to login and token refresh endpoints" },
|
|
42
|
+
{ id: "d08", agent: "Codex", ownerLogin: "alice", sessionId: S_CODEX, ts: m(17.5), role: "assistant", kind: "message", text: "Applying rate limiter middleware to src/routes.ts and src/auth.ts." },
|
|
43
|
+
{ id: "d09", agent: "Codex", ownerLogin: "alice", sessionId: S_CODEX, ts: m(17), role: "tool", kind: "code_edit", text: "apply_patch auth.ts", file: "auth.ts", path: wsFile("src", "auth.ts"), diff: "+import { rateLimit } from './rateLimit';\n+export const authLimiter = rateLimit({ windowMs: 60000, max: 10 });\n" },
|
|
44
|
+
{ id: "d10", agent: "Codex", ownerLogin: "alice", sessionId: S_CODEX, ts: m(16.8), role: "tool", kind: "code_edit", text: "apply_patch routes.ts", file: "routes.ts", path: wsFile("src", "routes.ts"), diff: "+app.post('/api/token/refresh', authLimiter, rotateRefreshToken);\n" },
|
|
45
|
+
|
|
46
|
+
// Session 3: Antigravity (@alice) updating UI auth client
|
|
47
|
+
{ id: "d11", agent: "Antigravity", ownerLogin: "alice", sessionId: S_ANTI, ts: m(12), role: "user", kind: "message", text: "wire automatic silent JWT refresh into the frontend API client" },
|
|
48
|
+
{ id: "d12", agent: "Antigravity", ownerLogin: "alice", sessionId: S_ANTI, ts: m(11.5), role: "assistant", kind: "message", text: "Updating src/client.ts and src/auth.ts types to retry 401 responses after refreshing the access token." },
|
|
49
|
+
{ id: "d13", agent: "Antigravity", ownerLogin: "alice", sessionId: S_ANTI, ts: m(11), role: "tool", kind: "code_edit", text: "replace_file_content client.ts", file: "client.ts", path: wsFile("src", "client.ts"), diff: "+if (res.status === 401) await refreshAccessToken();\n" },
|
|
50
|
+
{ id: "d14", agent: "Antigravity", ownerLogin: "alice", sessionId: S_ANTI, ts: m(10.5), role: "tool", kind: "code_edit", text: "replace_file_content auth.ts", file: "auth.ts", path: wsFile("src", "auth.ts"), diff: "+export type TokenPair = { accessToken: string; refreshToken: string; expiresIn: number };\n" },
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function buildPeerSnapshot(now = Date.now()) {
|
|
55
|
+
const m = (minsAgo) => now - minsAgo * 60_000;
|
|
56
|
+
return {
|
|
57
|
+
login: "bob",
|
|
58
|
+
chats: [
|
|
59
|
+
{
|
|
60
|
+
id: `chat_cursor_${S_CURSOR}`,
|
|
61
|
+
agent: "Cursor",
|
|
62
|
+
ownerLogin: "bob",
|
|
63
|
+
sessionId: S_CURSOR,
|
|
64
|
+
messages: [
|
|
65
|
+
{ role: "user", kind: "message", text: "add logout revocation endpoint and clear refresh token store", ts: m(20) },
|
|
66
|
+
{ role: "assistant", kind: "message", text: "Adding token revocation in src/auth.ts and src/session.ts.", ts: m(19.5) },
|
|
67
|
+
{ role: "tool", kind: "code_edit", text: "StrReplace auth.ts", file: "auth.ts", ts: m(19) },
|
|
68
|
+
{ role: "tool", kind: "code_edit", text: "StrReplace session.ts", file: "session.ts", ts: m(18.5) },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: `chat_copilot_${S_COPILOT}`,
|
|
73
|
+
agent: "Copilot",
|
|
74
|
+
ownerLogin: "bob",
|
|
75
|
+
sessionId: S_COPILOT,
|
|
76
|
+
messages: [
|
|
77
|
+
{ role: "user", kind: "message", text: "add integration tests for JWT expiry and refresh rotation", ts: m(8) },
|
|
78
|
+
{ role: "assistant", kind: "message", text: "Creating tests/auth.test.ts and exporting verify helpers from src/auth.ts.", ts: m(7.5) },
|
|
79
|
+
{ role: "tool", kind: "code_edit", text: "replace_string_in_file auth.ts", file: "auth.ts", ts: m(7) },
|
|
80
|
+
{ role: "tool", kind: "code_edit", text: "create_file auth.test.ts", file: "auth.test.ts", ts: m(6.5) },
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
edits: [
|
|
85
|
+
{ id: "e_bob01", agent: "Cursor", ownerLogin: "bob", file: "src/auth.ts", ts: m(19), diff: "+export async function revokeSession(jti: string) { await denylist.add(jti); }\n" },
|
|
86
|
+
{ id: "e_bob02", agent: "Cursor", ownerLogin: "bob", file: "src/session.ts", ts: m(18.5), diff: "+export const denylist = new Set<string>();\n" },
|
|
87
|
+
{ id: "e_bob03", agent: "Copilot", ownerLogin: "bob", file: "src/auth.ts", ts: m(7), diff: "+export { verifyJwt } from './jwt';\n" },
|
|
88
|
+
{ id: "e_bob04", agent: "Copilot", ownerLogin: "bob", file: "tests/auth.test.ts", ts: m(6.5), diff: "+test('rotates refresh token and rejects replay', async () => {});\n" },
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Seeds the isolated demo graph (`demo_<projectId>`) without touching the real workspace graph.
|
|
95
|
+
* Note: does NOT pollute the live `intents` cache for real workspace sessions unless `updateIntents` is set.
|
|
96
|
+
*/
|
|
97
|
+
async function seedDemoGraph(falkor, projectId, opts = {}) {
|
|
98
|
+
const targetId = opts.rawTarget ? String(projectId || "demo_sandbox") : demoGraphId(projectId);
|
|
99
|
+
const { client } = falkor.state;
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
await client.deleteGraph(targetId).catch(() => undefined);
|
|
102
|
+
schema.forget(targetId);
|
|
103
|
+
await falkor.ensureProject(targetId);
|
|
104
|
+
|
|
105
|
+
const localProj = projectHistory(buildDemoHistory(now), { workspacePath: WS, storeText: true });
|
|
106
|
+
await writeProjection(client, targetId, localProj);
|
|
107
|
+
|
|
108
|
+
const peerProj = projectPeer(buildPeerSnapshot(now), { login: "bob", trustRowOwners: false, storeText: true });
|
|
109
|
+
await writeProjection(client, targetId, peerProj);
|
|
110
|
+
|
|
111
|
+
if (opts.updateIntents) {
|
|
112
|
+
for (const i of localProj.intents.values()) intents.remember(i);
|
|
113
|
+
for (const i of peerProj.intents.values()) intents.remember(i);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
coord.configure(falkor);
|
|
117
|
+
coord.recordHeld(targetId, `Claude Code:ALICE-DESK:${S_CLAUDE}`, "src/auth.ts", now - 2 * 60_000);
|
|
118
|
+
coord.recordContention(targetId, {
|
|
119
|
+
kind: "claim_blocked",
|
|
120
|
+
file: "src/auth.ts",
|
|
121
|
+
wanter: `Cursor:BOB-LAPTOP:${S_CURSOR}`,
|
|
122
|
+
holder: `Claude Code:ALICE-DESK:${S_CLAUDE}`,
|
|
123
|
+
reason: `File \`src/auth.ts\` is locked by Claude Code. They're working on: "switch the login flow to short-lived JWT tokens with refresh rotation" (2 min ago).`,
|
|
124
|
+
ts: now - 20 * 60_000,
|
|
125
|
+
holderIntentText: "switch the login flow to short-lived JWT tokens with refresh rotation",
|
|
126
|
+
});
|
|
127
|
+
await coord.flushAll();
|
|
128
|
+
|
|
129
|
+
central.configure(falkor);
|
|
130
|
+
const decisions = [
|
|
131
|
+
central.toRow({
|
|
132
|
+
event_id: 101,
|
|
133
|
+
kind: "decision",
|
|
134
|
+
decision_id: "jwt-refresh-rotation",
|
|
135
|
+
content: "Use 15-minute stateless JWT access tokens paired with single-use rotating refresh tokens in src/auth.ts and src/session.ts.",
|
|
136
|
+
status: "accepted",
|
|
137
|
+
user: "alice",
|
|
138
|
+
agent_label: "Claude Code",
|
|
139
|
+
files: ["src/auth.ts", "src/session.ts", "src/middleware.ts"],
|
|
140
|
+
ts: new Date(now - 21 * 60_000).toISOString(),
|
|
141
|
+
}),
|
|
142
|
+
central.toRow({
|
|
143
|
+
event_id: 102,
|
|
144
|
+
kind: "decision",
|
|
145
|
+
decision_id: "auth-rate-limit",
|
|
146
|
+
content: "Enforce 10 req/min sliding-window rate limit on /api/token/refresh and login routes.",
|
|
147
|
+
status: "accepted",
|
|
148
|
+
user: "alice",
|
|
149
|
+
agent_label: "Codex",
|
|
150
|
+
files: ["src/auth.ts", "src/routes.ts"],
|
|
151
|
+
ts: new Date(now - 16 * 60_000).toISOString(),
|
|
152
|
+
}),
|
|
153
|
+
].filter(Boolean);
|
|
154
|
+
await central.writeRows(targetId, decisions);
|
|
155
|
+
|
|
156
|
+
analytics.configure(falkor);
|
|
157
|
+
const summary = await analytics.run(targetId, { full: true });
|
|
158
|
+
falkor.notify?.(projectId, { demo: true, summary });
|
|
159
|
+
return { targetId, summary };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Drops and rebuilds a real workspace graph from its local `memory.json` + roomPeers + central JSONL.
|
|
164
|
+
*/
|
|
165
|
+
async function rebuildProjectGraph(falkor, projectId, opts = {}) {
|
|
166
|
+
const projects = loadRegistry().projects || [];
|
|
167
|
+
const project = projects.find((p) => p.id === projectId || p.name === projectId);
|
|
168
|
+
if (!project) throw Object.assign(new Error(`Unknown project "${projectId}"`), { status: 404 });
|
|
169
|
+
const { client } = falkor.state;
|
|
170
|
+
if (!opts.keep) {
|
|
171
|
+
await client.deleteGraph(project.id);
|
|
172
|
+
schema.forget(project.id);
|
|
173
|
+
cursor.reset(project.id);
|
|
174
|
+
}
|
|
175
|
+
await falkor.ensureProject(project.id);
|
|
176
|
+
const memory = loadMemory(project.id);
|
|
177
|
+
const history = memory.history || [];
|
|
178
|
+
const t = Date.now();
|
|
179
|
+
const result = await ingestHistory(project.id, history, project.path, { full: true });
|
|
180
|
+
const peers = Object.entries(memory.roomPeers || {});
|
|
181
|
+
for (const [key, snapshot] of peers) {
|
|
182
|
+
await ingestPeer(project.id, { snapshot, login: snapshot.login || key, trustRowOwners: key === "host" });
|
|
183
|
+
}
|
|
184
|
+
// Re-ingest Central JSONL decisions/tasks if present
|
|
185
|
+
try {
|
|
186
|
+
central.configure(falkor);
|
|
187
|
+
const { eventsPath } = require("../centralStore");
|
|
188
|
+
const fs = require("fs");
|
|
189
|
+
const centralId = project.centralProjectId || project.id;
|
|
190
|
+
const file = eventsPath(centralId);
|
|
191
|
+
if (fs.existsSync(file)) {
|
|
192
|
+
const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
|
|
193
|
+
const rows = lines
|
|
194
|
+
.map((l) => {
|
|
195
|
+
try {
|
|
196
|
+
return central.toRow(JSON.parse(l));
|
|
197
|
+
} catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
.filter(Boolean);
|
|
202
|
+
if (rows.length) await central.writeRows(project.id, rows);
|
|
203
|
+
}
|
|
204
|
+
} catch {
|
|
205
|
+
/* optional central events */
|
|
206
|
+
}
|
|
207
|
+
analytics.configure(falkor);
|
|
208
|
+
const stats = await analytics.run(project.id, { full: true });
|
|
209
|
+
falkor.notify?.(project.id, { rebuilt: true, stats });
|
|
210
|
+
return {
|
|
211
|
+
projectId: project.id,
|
|
212
|
+
path: project.path,
|
|
213
|
+
events: history.length,
|
|
214
|
+
peers: peers.length,
|
|
215
|
+
ingest: result,
|
|
216
|
+
analytics: stats,
|
|
217
|
+
ms: Date.now() - t,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = { demoGraphId, buildDemoHistory, buildPeerSnapshot, seedDemoGraph, rebuildProjectGraph };
|
|
@@ -1,56 +1,56 @@
|
|
|
1
|
-
// Versioned in-place graph migrations (#18): run at schema.ensure() and recorded on
|
|
2
|
-
// Meta.migrations so schema/ingest version bumps preserve history older than memory.json's cap
|
|
3
|
-
// instead of requiring a destructive drop-and-rebuild.
|
|
4
|
-
|
|
5
|
-
const MIGRATIONS = [
|
|
6
|
-
{
|
|
7
|
-
id: "001_meta_versions_v6",
|
|
8
|
-
description: "Ensure Session.stub defaults and stamp Meta to schemaVersion=1, ingestVersion=6 in place",
|
|
9
|
-
async up(client, projectId, { schemaVersion, ingestVersion }) {
|
|
10
|
-
await client.write(
|
|
11
|
-
projectId,
|
|
12
|
-
`MATCH (s:Session) WHERE s.stub IS NULL SET s.stub = false RETURN count(s) AS n`
|
|
13
|
-
);
|
|
14
|
-
await client.write(
|
|
15
|
-
projectId,
|
|
16
|
-
`MATCH (m:Meta {key: 'meta'})
|
|
17
|
-
SET m.schemaVersion = $schema, m.ingestVersion = $ingest, m.migratedAt = $now
|
|
18
|
-
RETURN 1 AS ok`,
|
|
19
|
-
{ schema: schemaVersion, ingest: ingestVersion, now: Date.now() }
|
|
20
|
-
);
|
|
21
|
-
},
|
|
22
|
-
},
|
|
23
|
-
];
|
|
24
|
-
|
|
25
|
-
async function runMigrations(client, projectId, { schemaVersion, ingestVersion }) {
|
|
26
|
-
const rows = await client.read(
|
|
27
|
-
projectId,
|
|
28
|
-
"MATCH (m:Meta {key: 'meta'}) RETURN coalesce(m.migrations, []) AS migrations, m.schemaVersion AS s, m.ingestVersion AS i"
|
|
29
|
-
);
|
|
30
|
-
const meta = rows[0] || {};
|
|
31
|
-
const applied = new Set(Array.isArray(meta.migrations) ? meta.migrations : []);
|
|
32
|
-
const ran = [];
|
|
33
|
-
|
|
34
|
-
for (const mig of MIGRATIONS) {
|
|
35
|
-
if (applied.has(mig.id)) continue;
|
|
36
|
-
await mig.up(client, projectId, { schemaVersion, ingestVersion });
|
|
37
|
-
applied.add(mig.id);
|
|
38
|
-
ran.push(mig.id);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// If the graph had an older schemaVersion or ingestVersion, migrate it forward in place
|
|
42
|
-
// so existing Turn/Edit/Session/Decision history is preserved without dropping the graph.
|
|
43
|
-
if (ran.length || meta.s !== schemaVersion || meta.i !== ingestVersion) {
|
|
44
|
-
await client.write(
|
|
45
|
-
projectId,
|
|
46
|
-
`MATCH (m:Meta {key: 'meta'})
|
|
47
|
-
SET m.migrations = $migrations, m.schemaVersion = $schema, m.ingestVersion = $ingest
|
|
48
|
-
RETURN 1 AS ok`,
|
|
49
|
-
{ migrations: [...applied], schema: schemaVersion, ingest: ingestVersion }
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return { applied: [...applied], ran };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
module.exports = { MIGRATIONS, runMigrations };
|
|
1
|
+
// Versioned in-place graph migrations (#18): run at schema.ensure() and recorded on
|
|
2
|
+
// Meta.migrations so schema/ingest version bumps preserve history older than memory.json's cap
|
|
3
|
+
// instead of requiring a destructive drop-and-rebuild.
|
|
4
|
+
|
|
5
|
+
const MIGRATIONS = [
|
|
6
|
+
{
|
|
7
|
+
id: "001_meta_versions_v6",
|
|
8
|
+
description: "Ensure Session.stub defaults and stamp Meta to schemaVersion=1, ingestVersion=6 in place",
|
|
9
|
+
async up(client, projectId, { schemaVersion, ingestVersion }) {
|
|
10
|
+
await client.write(
|
|
11
|
+
projectId,
|
|
12
|
+
`MATCH (s:Session) WHERE s.stub IS NULL SET s.stub = false RETURN count(s) AS n`
|
|
13
|
+
);
|
|
14
|
+
await client.write(
|
|
15
|
+
projectId,
|
|
16
|
+
`MATCH (m:Meta {key: 'meta'})
|
|
17
|
+
SET m.schemaVersion = $schema, m.ingestVersion = $ingest, m.migratedAt = $now
|
|
18
|
+
RETURN 1 AS ok`,
|
|
19
|
+
{ schema: schemaVersion, ingest: ingestVersion, now: Date.now() }
|
|
20
|
+
);
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
async function runMigrations(client, projectId, { schemaVersion, ingestVersion }) {
|
|
26
|
+
const rows = await client.read(
|
|
27
|
+
projectId,
|
|
28
|
+
"MATCH (m:Meta {key: 'meta'}) RETURN coalesce(m.migrations, []) AS migrations, m.schemaVersion AS s, m.ingestVersion AS i"
|
|
29
|
+
);
|
|
30
|
+
const meta = rows[0] || {};
|
|
31
|
+
const applied = new Set(Array.isArray(meta.migrations) ? meta.migrations : []);
|
|
32
|
+
const ran = [];
|
|
33
|
+
|
|
34
|
+
for (const mig of MIGRATIONS) {
|
|
35
|
+
if (applied.has(mig.id)) continue;
|
|
36
|
+
await mig.up(client, projectId, { schemaVersion, ingestVersion });
|
|
37
|
+
applied.add(mig.id);
|
|
38
|
+
ran.push(mig.id);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// If the graph had an older schemaVersion or ingestVersion, migrate it forward in place
|
|
42
|
+
// so existing Turn/Edit/Session/Decision history is preserved without dropping the graph.
|
|
43
|
+
if (ran.length || meta.s !== schemaVersion || meta.i !== ingestVersion) {
|
|
44
|
+
await client.write(
|
|
45
|
+
projectId,
|
|
46
|
+
`MATCH (m:Meta {key: 'meta'})
|
|
47
|
+
SET m.migrations = $migrations, m.schemaVersion = $schema, m.ingestVersion = $ingest
|
|
48
|
+
RETURN 1 AS ok`,
|
|
49
|
+
{ migrations: [...applied], schema: schemaVersion, ingest: ingestVersion }
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { applied: [...applied], ran };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { MIGRATIONS, runMigrations };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { runNamed, names } = require("./query");
|
|
2
2
|
const { projectIdFor } = require("./coord");
|
|
3
|
+
const { demoGraphId, seedDemoGraph, rebuildProjectGraph } = require("./demo");
|
|
3
4
|
const { loadRoom } = require("../room");
|
|
4
5
|
const { loadRegistry, loadSession } = require("../store");
|
|
5
6
|
|
|
@@ -11,6 +12,10 @@ function argsFrom(req) {
|
|
|
11
12
|
return q;
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
function isDemoMode(args) {
|
|
16
|
+
return args?.demo === "1" || args?.demo === 1 || args?.demo === true || args?.mode === "demo";
|
|
17
|
+
}
|
|
18
|
+
|
|
14
19
|
function resolveProjectId(args) {
|
|
15
20
|
if (args.projectId) return String(args.projectId);
|
|
16
21
|
const byPath = projectIdFor(args.workspace);
|
|
@@ -41,9 +46,11 @@ async function askHost(room, name, args) {
|
|
|
41
46
|
|
|
42
47
|
/** Local graph first unless this machine is a room guest, in which case the host's graph is the room brain. */
|
|
43
48
|
async function query(name, args) {
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
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) {
|
|
47
54
|
try {
|
|
48
55
|
return await askHost(room, name, args);
|
|
49
56
|
} catch (err) {
|
|
@@ -57,14 +64,24 @@ async function query(name, args) {
|
|
|
57
64
|
}
|
|
58
65
|
if (!api.state.started) return { ok: false, reason: "falkor_disabled", hint: "set RELAY_FALKOR_URL and restart relay serve" };
|
|
59
66
|
if (!api.client.available()) return { ok: false, reason: "falkor_unavailable", breaker: api.client.status() };
|
|
60
|
-
await api.ensureProject(
|
|
61
|
-
return {
|
|
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
|
+
};
|
|
62
76
|
}
|
|
63
77
|
|
|
64
78
|
function register(app, falkor) {
|
|
65
|
-
api = {
|
|
66
|
-
|
|
67
|
-
|
|
79
|
+
api = {
|
|
80
|
+
...falkor,
|
|
81
|
+
get client() {
|
|
82
|
+
return falkor.state.client;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
68
85
|
const guard = falkor.state.deps.requireRoomMember || ((_req, _res, next) => next());
|
|
69
86
|
const handler = async (req, res) => {
|
|
70
87
|
try {
|
|
@@ -76,6 +93,33 @@ function register(app, falkor) {
|
|
|
76
93
|
app.get("/api/graph/queries", (_req, res) => res.json({ queries: names() }));
|
|
77
94
|
app.get("/api/graph/query/:name", guard, handler);
|
|
78
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
|
+
});
|
|
79
123
|
}
|
|
80
124
|
|
|
81
125
|
module.exports = { register, query };
|
|
@@ -1,109 +1,109 @@
|
|
|
1
|
-
// Backs the "Add workspace" window: a local directory browser and the signed-in
|
|
2
|
-
// GitHub account's repositories. These used to be Next.js server routes; living
|
|
3
|
-
// here lets Mission Control ship as a static build that `relay serve` hosts.
|
|
4
|
-
// Both are host-only — a room member must never browse the host's disk.
|
|
5
|
-
|
|
6
|
-
const fs = require("fs");
|
|
7
|
-
const path = require("path");
|
|
8
|
-
const { spawn } = require("child_process");
|
|
9
|
-
|
|
10
|
-
function listDrives() {
|
|
11
|
-
const drives = [];
|
|
12
|
-
for (let c = 65; c <= 90; c++) {
|
|
13
|
-
const root = `${String.fromCharCode(c)}:\\`;
|
|
14
|
-
try {
|
|
15
|
-
if (fs.existsSync(root)) drives.push({ name: root, path: root, isDir: true });
|
|
16
|
-
} catch {
|
|
17
|
-
/* drive not ready */
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
return drives;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
async function listDir(dir) {
|
|
24
|
-
if (!dir) {
|
|
25
|
-
const entries = process.platform === "win32" ? listDrives() : [{ name: "/", path: "/", isDir: true }];
|
|
26
|
-
return { status: 200, body: { path: null, parent: null, entries } };
|
|
27
|
-
}
|
|
28
|
-
const resolved = path.resolve(dir);
|
|
29
|
-
try {
|
|
30
|
-
const dirents = await fs.promises.readdir(resolved, { withFileTypes: true });
|
|
31
|
-
const entries = dirents
|
|
32
|
-
.map((d) => ({ name: d.name, path: path.join(resolved, d.name), isDir: d.isDirectory() }))
|
|
33
|
-
.sort((a, b) => (a.isDir === b.isDir ? a.name.localeCompare(b.name) : a.isDir ? -1 : 1));
|
|
34
|
-
const parent = path.dirname(resolved);
|
|
35
|
-
return { status: 200, body: { path: resolved, parent: parent === resolved ? null : parent, entries } };
|
|
36
|
-
} catch (err) {
|
|
37
|
-
return { status: 400, body: { error: err.message } };
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function runGh(args) {
|
|
42
|
-
return new Promise((resolve, reject) => {
|
|
43
|
-
const child = spawn("gh", args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true, shell: false });
|
|
44
|
-
let out = "";
|
|
45
|
-
let err = "";
|
|
46
|
-
const timer = setTimeout(() => {
|
|
47
|
-
child.kill();
|
|
48
|
-
reject(new Error("gh timed out"));
|
|
49
|
-
}, 25_000);
|
|
50
|
-
child.stdout.on("data", (c) => (out += c));
|
|
51
|
-
child.stderr.on("data", (c) => (err += c));
|
|
52
|
-
child.on("error", () => {
|
|
53
|
-
clearTimeout(timer);
|
|
54
|
-
reject(new Error("gh_not_installed"));
|
|
55
|
-
});
|
|
56
|
-
child.on("exit", (code) => {
|
|
57
|
-
clearTimeout(timer);
|
|
58
|
-
if (code === 0) resolve(out.trim());
|
|
59
|
-
else reject(new Error(err.trim() || `gh exited ${code}`));
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function classify(message) {
|
|
65
|
-
if (message === "gh_not_installed") return "gh_not_installed";
|
|
66
|
-
if (/not logged|authentication|gh auth login|HTTP 401/i.test(message)) return "gh_not_authenticated";
|
|
67
|
-
return message;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const CACHE_MS = 60_000;
|
|
71
|
-
let cache = null;
|
|
72
|
-
|
|
73
|
-
async function listGithubRepos({ refresh = false } = {}) {
|
|
74
|
-
if (!refresh && cache && Date.now() - cache.at < CACHE_MS) return cache.body;
|
|
75
|
-
try {
|
|
76
|
-
const raw = await runGh([
|
|
77
|
-
"api",
|
|
78
|
-
"user/repos?per_page=100&sort=updated&affiliation=owner,collaborator,organization_member",
|
|
79
|
-
"--paginate",
|
|
80
|
-
]);
|
|
81
|
-
// --paginate concatenates JSON arrays; normalise "][" joins into one array.
|
|
82
|
-
const list = JSON.parse(raw.replace(/\]\s*\[/g, ","));
|
|
83
|
-
const repos = list.map((r) => ({
|
|
84
|
-
id: `gh_${r.id}`,
|
|
85
|
-
name: r.name,
|
|
86
|
-
fullName: r.full_name,
|
|
87
|
-
owner: r.owner?.login ?? null,
|
|
88
|
-
ownerAvatarUrl: r.owner?.avatar_url ?? null,
|
|
89
|
-
description: r.description,
|
|
90
|
-
private: Boolean(r.private),
|
|
91
|
-
fork: Boolean(r.fork),
|
|
92
|
-
language: r.language,
|
|
93
|
-
defaultBranch: r.default_branch,
|
|
94
|
-
cloneUrl: r.clone_url,
|
|
95
|
-
sshUrl: r.ssh_url,
|
|
96
|
-
htmlUrl: r.html_url,
|
|
97
|
-
updatedAt: r.pushed_at || r.updated_at,
|
|
98
|
-
stars: r.stargazers_count || 0,
|
|
99
|
-
}));
|
|
100
|
-
const body = { repos, error: null };
|
|
101
|
-
cache = { at: Date.now(), body };
|
|
102
|
-
return body;
|
|
103
|
-
} catch (err) {
|
|
104
|
-
// Never 500 here — the window renders a helpful empty state off `error`.
|
|
105
|
-
return { repos: [], error: classify(err.message) };
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
module.exports = { listDir, listGithubRepos };
|
|
1
|
+
// Backs the "Add workspace" window: a local directory browser and the signed-in
|
|
2
|
+
// GitHub account's repositories. These used to be Next.js server routes; living
|
|
3
|
+
// here lets Mission Control ship as a static build that `relay serve` hosts.
|
|
4
|
+
// Both are host-only — a room member must never browse the host's disk.
|
|
5
|
+
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
const { spawn } = require("child_process");
|
|
9
|
+
|
|
10
|
+
function listDrives() {
|
|
11
|
+
const drives = [];
|
|
12
|
+
for (let c = 65; c <= 90; c++) {
|
|
13
|
+
const root = `${String.fromCharCode(c)}:\\`;
|
|
14
|
+
try {
|
|
15
|
+
if (fs.existsSync(root)) drives.push({ name: root, path: root, isDir: true });
|
|
16
|
+
} catch {
|
|
17
|
+
/* drive not ready */
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return drives;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function listDir(dir) {
|
|
24
|
+
if (!dir) {
|
|
25
|
+
const entries = process.platform === "win32" ? listDrives() : [{ name: "/", path: "/", isDir: true }];
|
|
26
|
+
return { status: 200, body: { path: null, parent: null, entries } };
|
|
27
|
+
}
|
|
28
|
+
const resolved = path.resolve(dir);
|
|
29
|
+
try {
|
|
30
|
+
const dirents = await fs.promises.readdir(resolved, { withFileTypes: true });
|
|
31
|
+
const entries = dirents
|
|
32
|
+
.map((d) => ({ name: d.name, path: path.join(resolved, d.name), isDir: d.isDirectory() }))
|
|
33
|
+
.sort((a, b) => (a.isDir === b.isDir ? a.name.localeCompare(b.name) : a.isDir ? -1 : 1));
|
|
34
|
+
const parent = path.dirname(resolved);
|
|
35
|
+
return { status: 200, body: { path: resolved, parent: parent === resolved ? null : parent, entries } };
|
|
36
|
+
} catch (err) {
|
|
37
|
+
return { status: 400, body: { error: err.message } };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function runGh(args) {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const child = spawn("gh", args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true, shell: false });
|
|
44
|
+
let out = "";
|
|
45
|
+
let err = "";
|
|
46
|
+
const timer = setTimeout(() => {
|
|
47
|
+
child.kill();
|
|
48
|
+
reject(new Error("gh timed out"));
|
|
49
|
+
}, 25_000);
|
|
50
|
+
child.stdout.on("data", (c) => (out += c));
|
|
51
|
+
child.stderr.on("data", (c) => (err += c));
|
|
52
|
+
child.on("error", () => {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
reject(new Error("gh_not_installed"));
|
|
55
|
+
});
|
|
56
|
+
child.on("exit", (code) => {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
if (code === 0) resolve(out.trim());
|
|
59
|
+
else reject(new Error(err.trim() || `gh exited ${code}`));
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function classify(message) {
|
|
65
|
+
if (message === "gh_not_installed") return "gh_not_installed";
|
|
66
|
+
if (/not logged|authentication|gh auth login|HTTP 401/i.test(message)) return "gh_not_authenticated";
|
|
67
|
+
return message;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const CACHE_MS = 60_000;
|
|
71
|
+
let cache = null;
|
|
72
|
+
|
|
73
|
+
async function listGithubRepos({ refresh = false } = {}) {
|
|
74
|
+
if (!refresh && cache && Date.now() - cache.at < CACHE_MS) return cache.body;
|
|
75
|
+
try {
|
|
76
|
+
const raw = await runGh([
|
|
77
|
+
"api",
|
|
78
|
+
"user/repos?per_page=100&sort=updated&affiliation=owner,collaborator,organization_member",
|
|
79
|
+
"--paginate",
|
|
80
|
+
]);
|
|
81
|
+
// --paginate concatenates JSON arrays; normalise "][" joins into one array.
|
|
82
|
+
const list = JSON.parse(raw.replace(/\]\s*\[/g, ","));
|
|
83
|
+
const repos = list.map((r) => ({
|
|
84
|
+
id: `gh_${r.id}`,
|
|
85
|
+
name: r.name,
|
|
86
|
+
fullName: r.full_name,
|
|
87
|
+
owner: r.owner?.login ?? null,
|
|
88
|
+
ownerAvatarUrl: r.owner?.avatar_url ?? null,
|
|
89
|
+
description: r.description,
|
|
90
|
+
private: Boolean(r.private),
|
|
91
|
+
fork: Boolean(r.fork),
|
|
92
|
+
language: r.language,
|
|
93
|
+
defaultBranch: r.default_branch,
|
|
94
|
+
cloneUrl: r.clone_url,
|
|
95
|
+
sshUrl: r.ssh_url,
|
|
96
|
+
htmlUrl: r.html_url,
|
|
97
|
+
updatedAt: r.pushed_at || r.updated_at,
|
|
98
|
+
stars: r.stargazers_count || 0,
|
|
99
|
+
}));
|
|
100
|
+
const body = { repos, error: null };
|
|
101
|
+
cache = { at: Date.now(), body };
|
|
102
|
+
return body;
|
|
103
|
+
} catch (err) {
|
|
104
|
+
// Never 500 here — the window renders a helpful empty state off `error`.
|
|
105
|
+
return { repos: [], error: classify(err.message) };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = { listDir, listGithubRepos };
|