@rubytech/create-realagent 1.0.839 → 1.0.840
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/package.json +1 -1
- package/payload/platform/plugins/docs/references/internals.md +1 -1
- package/payload/platform/plugins/docs/references/platform.md +1 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/uuid.js +7 -7
- package/payload/platform/plugins/memory/mcp/dist/lib/uuid.js.map +1 -1
- package/payload/platform/plugins/whatsapp/PLUGIN.md +1 -1
- package/payload/server/chunk-CJWFM3WX.js +2098 -0
- package/payload/server/chunk-D5U4XQ66.js +656 -0
- package/payload/server/chunk-DJXPAH7T.js +1480 -0
- package/payload/server/chunk-T2MQIKBT.js +10001 -0
- package/payload/server/client-pool-M25CGILI.js +32 -0
- package/payload/server/cloudflare-task-tracker-GQFKLY62.js +20 -0
- package/payload/server/maxy-edge.js +3 -4
- package/payload/server/public/assets/{Checkbox-Bq6ORjz2.js → Checkbox-aCc0UGp3.js} +1 -1
- package/payload/server/public/assets/{admin-CstEkw-G.js → admin-D678VwpH.js} +2 -2
- package/payload/server/public/assets/data-DsItQm8c.js +1 -0
- package/payload/server/public/assets/graph-C-HOmfmU.js +1 -0
- package/payload/server/public/assets/{jsx-runtime-DidQeNoZ.css → jsx-runtime-BKoartnM.css} +1 -1
- package/payload/server/public/assets/{page-CFWoVkgV.js → page-D7LchjvY.js} +1 -1
- package/payload/server/public/assets/{page-Bpi_jPw6.js → page-DTmTvkNo.js} +1 -1
- package/payload/server/public/assets/{public-BWMwq5Jj.js → public-C7mCgRX0.js} +1 -1
- package/payload/server/public/assets/{useAdminFetch-B93ig7ef.js → useAdminFetch-BgDL3JGd.js} +1 -1
- package/payload/server/public/assets/{useVoiceRecorder-Cb0nAtOo.js → useVoiceRecorder-Bx903Mk1.js} +1 -1
- package/payload/server/public/data.html +5 -5
- package/payload/server/public/graph.html +6 -6
- package/payload/server/public/index.html +8 -8
- package/payload/server/public/public.html +5 -5
- package/payload/server/server.js +21 -31
- package/payload/platform/neo4j/migrations/001-backfill-scope.cypher +0 -30
- package/payload/platform/neo4j/migrations/002-project-public-agents.ts +0 -191
- package/payload/platform/neo4j/migrations/003-person-name-eradicate.cypher +0 -24
- package/payload/platform/neo4j/migrations/004-project-admin-agent.ts +0 -348
- package/payload/platform/neo4j/migrations/004-prune-alien-accounts.ts +0 -133
- package/payload/platform/neo4j/migrations/005-removed-review-feature.ts +0 -102
- package/payload/platform/neo4j/migrations/006-prune-bogus-whatsapp-persons.ts +0 -132
- package/payload/platform/neo4j/migrations/007-conversation-archive-source.ts +0 -116
- package/payload/platform/neo4j/migrations/008-adminuser-accountid-backfill.ts +0 -85
- package/payload/platform/neo4j/migrations/009-conversation-archive-title.ts +0 -197
- package/payload/server/public/assets/data-DwZZ7qbH.js +0 -1
- package/payload/server/public/assets/graph-DceEv42K.js +0 -1
- /package/payload/server/public/assets/{jsx-runtime-DH5S-MwB.js → jsx-runtime-WW3O7tSz.js} +0 -0
|
@@ -1,348 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Migration 004 — Project the admin agent into the graph and clean up
|
|
3
|
-
* Conversation channel data (Task 864). Numbered 004 because the 003
|
|
4
|
-
* slot is held by `003-person-name-eradicate.cypher` (boot-time apply).
|
|
5
|
-
*
|
|
6
|
-
* Three idempotent passes:
|
|
7
|
-
*
|
|
8
|
-
* 1. For every account directory under data/accounts/<accountId>/agents/admin/
|
|
9
|
-
* that carries a config.json, call projectAgent(accountId, accountDir,
|
|
10
|
-
* 'admin'). The projector is the same function migration 002 uses for
|
|
11
|
-
* public agents and is content-agnostic — it reads config.json plus any
|
|
12
|
-
* IDENTITY/SOUL/KNOWLEDGE/KNOWLEDGE-SUMMARY files present and MERGEs the
|
|
13
|
-
* :Agent node + four owned :KnowledgeDocument projections. Re-running
|
|
14
|
-
* this migration produces no duplicate nodes or edges.
|
|
15
|
-
*
|
|
16
|
-
* Migration 002 explicitly SKIPS admin (line 60: `if (entry.name ===
|
|
17
|
-
* "admin") continue`); doing the projection here keeps that skip valid
|
|
18
|
-
* and isolates admin-specific concerns to one file.
|
|
19
|
-
*
|
|
20
|
-
* 2. For every existing :AdminConversation that does NOT yet have a
|
|
21
|
-
* :HANDLED_BY edge, MATCH the freshly-projected admin :Agent and MERGE
|
|
22
|
-
* the edge. Guarded with `WHERE NOT EXISTS((c)-[:HANDLED_BY]->(:Agent))`
|
|
23
|
-
* so re-runs short-circuit per conversation.
|
|
24
|
-
*
|
|
25
|
-
* 3. Backfill `c.channel = 'webchat'` for every Conversation node where
|
|
26
|
-
* channel IS NULL. Pre-Task-863 conversations were written without the
|
|
27
|
-
* property; channel='webchat' is the correct default — only WhatsApp
|
|
28
|
-
* and Telegram sessions ever set non-webchat values, and those have
|
|
29
|
-
* always come through sessionKeys prefixed `whatsapp:` or `telegram:`
|
|
30
|
-
* (see neo4j-store.ts:171). Idempotent: subsequent runs no-op because
|
|
31
|
-
* the WHERE clause matches zero rows.
|
|
32
|
-
*
|
|
33
|
-
* Boot-time entry point (Task 871): {@link applyAdminAgentBackfill}. The
|
|
34
|
-
* neo4j-migrations runner calls it from `applyBootMigrations` AFTER
|
|
35
|
-
* `pruneAlienAccounts` so we never project :Agent for an account that the
|
|
36
|
-
* preceding pass would prune.
|
|
37
|
-
*
|
|
38
|
-
* Manual standalone entry point (legacy, retained for ad-hoc operator
|
|
39
|
-
* runs):
|
|
40
|
-
*
|
|
41
|
-
* cd platform/ui && \
|
|
42
|
-
* NEO4J_URI=bolt://… NEO4J_PASSWORD=… \
|
|
43
|
-
* npx tsx ../neo4j/migrations/004-project-admin-agent.ts
|
|
44
|
-
*
|
|
45
|
-
* Output: structured `[admin-agent-graph-backfill]` lines per account + a
|
|
46
|
-
* final totals line. Both entry points emit identical log lines.
|
|
47
|
-
*/
|
|
48
|
-
|
|
49
|
-
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
50
|
-
import { resolve } from "node:path";
|
|
51
|
-
import { projectAgent, getSession } from "../../ui/app/lib/neo4j-store";
|
|
52
|
-
import { ACCOUNTS_DIR } from "../../ui/app/lib/claude-agent/account";
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Account-json filter (Task 900 sub-scope F) — admits only directories whose
|
|
56
|
-
* `account.json` parses. Stub directories (e.g. `0dbf29ef-…/logs/` left
|
|
57
|
-
* behind after install 1's `account.json` was lost) are SKIPPED with a
|
|
58
|
-
* one-line `[platform] accounts-state STUB-DIR id=<dir>` log. Mirrors
|
|
59
|
-
* `listValidAccounts()` in `account.ts`; copied here because this migration
|
|
60
|
-
* runs at boot from a separate node_modules-resolution context (see the
|
|
61
|
-
* structural-typing rationale at the top of `004-prune-alien-accounts.ts`).
|
|
62
|
-
*/
|
|
63
|
-
function readValidAccountDirs(accountsDir: string): string[] {
|
|
64
|
-
const valid: string[] = [];
|
|
65
|
-
const entries = readdirSync(accountsDir, { withFileTypes: true })
|
|
66
|
-
.filter((e) => e.isDirectory())
|
|
67
|
-
.filter((e) => !e.name.startsWith("."));
|
|
68
|
-
for (const e of entries) {
|
|
69
|
-
const configPath = resolve(accountsDir, e.name, "account.json");
|
|
70
|
-
if (!existsSync(configPath)) {
|
|
71
|
-
console.error(
|
|
72
|
-
`[platform] accounts-state STUB-DIR id=${e.name} — onboarding leak; remove or repair`,
|
|
73
|
-
);
|
|
74
|
-
continue;
|
|
75
|
-
}
|
|
76
|
-
try {
|
|
77
|
-
JSON.parse(readFileSync(configPath, "utf-8"));
|
|
78
|
-
valid.push(e.name);
|
|
79
|
-
} catch {
|
|
80
|
-
console.error(
|
|
81
|
-
`[platform] accounts-state CORRUPT-JSON id=${e.name} — account.json failed to parse`,
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
return valid;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Structural alias for the `Driver` instance the runner passes in. Same
|
|
90
|
-
* rationale as `004-prune-alien-accounts.ts`: this file lives outside
|
|
91
|
-
* `platform/ui/`, and depending on the workspace's dedupe state,
|
|
92
|
-
* `neo4j-driver` resolves to a different node_modules copy here than at
|
|
93
|
-
* the boot-loader site. Structural typing sidesteps the nominal-distinct-
|
|
94
|
-
* types-from-different-paths trap.
|
|
95
|
-
*/
|
|
96
|
-
type Neo4jDriverLike = {
|
|
97
|
-
session(): {
|
|
98
|
-
run(
|
|
99
|
-
cypher: string,
|
|
100
|
-
params?: Record<string, unknown>,
|
|
101
|
-
): Promise<{ records: Array<{ get(key: string): unknown }> }>;
|
|
102
|
-
close(): Promise<void>;
|
|
103
|
-
};
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
interface PerAccountStats {
|
|
107
|
-
accountId: string;
|
|
108
|
-
projected: 0 | 1;
|
|
109
|
-
failed: 0 | 1;
|
|
110
|
-
handledByCandidates: number;
|
|
111
|
-
handledByEdges: number;
|
|
112
|
-
channelBackfilled: number;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
async function projectAccountAdmin(
|
|
116
|
-
accountId: string,
|
|
117
|
-
accountDir: string,
|
|
118
|
-
): Promise<{ projected: 0 | 1; failed: 0 | 1 }> {
|
|
119
|
-
const adminDir = resolve(accountDir, "agents", "admin");
|
|
120
|
-
const configPath = resolve(adminDir, "config.json");
|
|
121
|
-
if (!existsSync(adminDir) || !existsSync(configPath)) {
|
|
122
|
-
return { projected: 0, failed: 0 };
|
|
123
|
-
}
|
|
124
|
-
try {
|
|
125
|
-
await projectAgent(accountId, accountDir, "admin");
|
|
126
|
-
return { projected: 1, failed: 0 };
|
|
127
|
-
} catch (err) {
|
|
128
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
129
|
-
console.error(
|
|
130
|
-
`[admin-agent-graph-backfill] account=${accountId.slice(0, 8)} project FAILED error="${msg}"`,
|
|
131
|
-
);
|
|
132
|
-
return { projected: 0, failed: 1 };
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
interface HandledByStats {
|
|
137
|
-
candidates: number;
|
|
138
|
-
edges: number;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Backfill HANDLED_BY edges from AdminConversation nodes to the admin Agent
|
|
143
|
-
* node. Guarded with NOT EXISTS so re-runs of this migration don't redo work
|
|
144
|
-
* — and so AdminConversations that already gained a HANDLED_BY edge through
|
|
145
|
-
* the forward path are skipped.
|
|
146
|
-
*
|
|
147
|
-
* `candidates` counts AdminConversations that LACK a HANDLED_BY edge; `edges`
|
|
148
|
-
* counts edges newly created. The admin :Agent must already exist (created
|
|
149
|
-
* by pass 1); if it doesn't, the OPTIONAL MATCH falls through and zero edges
|
|
150
|
-
* are written — surfaced through `candidates - edges`.
|
|
151
|
-
*/
|
|
152
|
-
async function backfillAdminHandledBy(
|
|
153
|
-
driver: Neo4jDriverLike,
|
|
154
|
-
accountId: string,
|
|
155
|
-
): Promise<HandledByStats> {
|
|
156
|
-
const session = driver.session();
|
|
157
|
-
try {
|
|
158
|
-
const result = await session.run(
|
|
159
|
-
`MATCH (c:AdminConversation {accountId: $accountId})
|
|
160
|
-
WHERE NOT EXISTS((c)-[:HANDLED_BY]->(:Agent))
|
|
161
|
-
OPTIONAL MATCH (a:Agent {accountId: $accountId, slug: 'admin'})
|
|
162
|
-
FOREACH (_ IN CASE WHEN a IS NULL THEN [] ELSE [1] END | MERGE (c)-[:HANDLED_BY]->(a))
|
|
163
|
-
RETURN
|
|
164
|
-
count(c) AS candidates,
|
|
165
|
-
sum(CASE WHEN a IS NULL THEN 0 ELSE 1 END) AS edges`,
|
|
166
|
-
{ accountId },
|
|
167
|
-
);
|
|
168
|
-
const toNum = (v: unknown): number => {
|
|
169
|
-
if (typeof v === "number") return v;
|
|
170
|
-
if (v && typeof (v as { toNumber: () => number }).toNumber === "function") {
|
|
171
|
-
return (v as { toNumber: () => number }).toNumber();
|
|
172
|
-
}
|
|
173
|
-
return 0;
|
|
174
|
-
};
|
|
175
|
-
return {
|
|
176
|
-
candidates: toNum(result.records[0]?.get("candidates")),
|
|
177
|
-
edges: toNum(result.records[0]?.get("edges")),
|
|
178
|
-
};
|
|
179
|
-
} finally {
|
|
180
|
-
await session.close();
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Backfill `c.channel = 'webchat'` on Conversation nodes that lack the
|
|
186
|
-
* property. Hits both AdminConversation and PublicConversation — the
|
|
187
|
-
* predicate is `c.channel IS NULL`, label-agnostic.
|
|
188
|
-
*
|
|
189
|
-
* Why default to 'webchat': new writes set channel from sessionKey prefix
|
|
190
|
-
* (neo4j-store.ts:171). Only `whatsapp:` and `telegram:` prefixes produce
|
|
191
|
-
* non-webchat values, and those prefixes have always existed. So a NULL
|
|
192
|
-
* channel can only mean "Conversation written before Task 863 added the
|
|
193
|
-
* SET clause" — which by definition was a webchat session.
|
|
194
|
-
*
|
|
195
|
-
* Idempotent: WHERE clause matches zero rows on re-run.
|
|
196
|
-
*/
|
|
197
|
-
async function backfillChannel(
|
|
198
|
-
driver: Neo4jDriverLike,
|
|
199
|
-
accountId: string,
|
|
200
|
-
): Promise<number> {
|
|
201
|
-
const session = driver.session();
|
|
202
|
-
try {
|
|
203
|
-
const result = await session.run(
|
|
204
|
-
`MATCH (c:Conversation {accountId: $accountId})
|
|
205
|
-
WHERE c.channel IS NULL
|
|
206
|
-
SET c.channel = 'webchat'
|
|
207
|
-
RETURN count(c) AS backfilled`,
|
|
208
|
-
{ accountId },
|
|
209
|
-
);
|
|
210
|
-
const raw = result.records[0]?.get("backfilled");
|
|
211
|
-
if (typeof raw === "number") return raw;
|
|
212
|
-
if (raw && typeof (raw as { toNumber: () => number }).toNumber === "function") {
|
|
213
|
-
return (raw as { toNumber: () => number }).toNumber();
|
|
214
|
-
}
|
|
215
|
-
return 0;
|
|
216
|
-
} finally {
|
|
217
|
-
await session.close();
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Boot-time entry point (Task 871). Idempotent across every pass; safe to
|
|
223
|
-
* run on every server boot. Throws on fatal failure; per-account failures
|
|
224
|
-
* are logged and counted but do not abort the run (subsequent accounts
|
|
225
|
-
* still get processed). The neo4j-migrations runner wraps this in its own
|
|
226
|
-
* try/catch so a throw here is logged as `[migration] failed
|
|
227
|
-
* admin-agent-graph-backfill error="…"` and boot continues.
|
|
228
|
-
*
|
|
229
|
-
* Refusal posture: if `<platformRoot>/../data/accounts` does not exist,
|
|
230
|
-
* logs and returns silently — nothing to do (matches pruneAlienAccounts).
|
|
231
|
-
*/
|
|
232
|
-
export async function applyAdminAgentBackfill(
|
|
233
|
-
driver: Neo4jDriverLike,
|
|
234
|
-
platformRoot: string,
|
|
235
|
-
): Promise<void> {
|
|
236
|
-
const accountsDir = resolve(platformRoot, "..", "data", "accounts");
|
|
237
|
-
const start = Date.now();
|
|
238
|
-
|
|
239
|
-
if (!existsSync(accountsDir)) {
|
|
240
|
-
console.error(
|
|
241
|
-
`[admin-agent-graph-backfill] accounts-dir missing at ${accountsDir} — nothing to do`,
|
|
242
|
-
);
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
const accountEntries = readValidAccountDirs(accountsDir).map((name) => ({ name }));
|
|
247
|
-
|
|
248
|
-
console.error(
|
|
249
|
-
`[admin-agent-graph-backfill] start accounts=${accountEntries.length}`,
|
|
250
|
-
);
|
|
251
|
-
|
|
252
|
-
let totalProjected = 0;
|
|
253
|
-
let totalFailed = 0;
|
|
254
|
-
let totalHandledByCandidates = 0;
|
|
255
|
-
let totalHandledByEdges = 0;
|
|
256
|
-
let totalChannelBackfilled = 0;
|
|
257
|
-
const perAccount: PerAccountStats[] = [];
|
|
258
|
-
|
|
259
|
-
for (const entry of accountEntries) {
|
|
260
|
-
const accountDir = resolve(accountsDir, entry.name);
|
|
261
|
-
const accountId = entry.name;
|
|
262
|
-
const accountStart = Date.now();
|
|
263
|
-
|
|
264
|
-
const { projected, failed } = await projectAccountAdmin(
|
|
265
|
-
accountId,
|
|
266
|
-
accountDir,
|
|
267
|
-
);
|
|
268
|
-
totalProjected += projected;
|
|
269
|
-
totalFailed += failed;
|
|
270
|
-
|
|
271
|
-
let handledByStats: HandledByStats = { candidates: 0, edges: 0 };
|
|
272
|
-
let channelBackfilled = 0;
|
|
273
|
-
|
|
274
|
-
try {
|
|
275
|
-
handledByStats = await backfillAdminHandledBy(driver, accountId);
|
|
276
|
-
totalHandledByCandidates += handledByStats.candidates;
|
|
277
|
-
totalHandledByEdges += handledByStats.edges;
|
|
278
|
-
} catch (err) {
|
|
279
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
280
|
-
console.error(
|
|
281
|
-
`[admin-agent-graph-backfill] account=${accountId.slice(0, 8)} handled-by-backfill FAILED error="${msg}"`,
|
|
282
|
-
);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
try {
|
|
286
|
-
channelBackfilled = await backfillChannel(driver, accountId);
|
|
287
|
-
totalChannelBackfilled += channelBackfilled;
|
|
288
|
-
} catch (err) {
|
|
289
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
290
|
-
console.error(
|
|
291
|
-
`[admin-agent-graph-backfill] account=${accountId.slice(0, 8)} channel-backfill FAILED error="${msg}"`,
|
|
292
|
-
);
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
perAccount.push({
|
|
296
|
-
accountId,
|
|
297
|
-
projected,
|
|
298
|
-
failed,
|
|
299
|
-
handledByCandidates: handledByStats.candidates,
|
|
300
|
-
handledByEdges: handledByStats.edges,
|
|
301
|
-
channelBackfilled,
|
|
302
|
-
});
|
|
303
|
-
const ms = Date.now() - accountStart;
|
|
304
|
-
console.error(
|
|
305
|
-
`[admin-agent-graph-backfill] account=${accountId.slice(0, 8)} projected=${projected} failed=${failed} handled-by-candidates=${handledByStats.candidates} handled-by-edges=${handledByStats.edges} channel-backfilled=${channelBackfilled} ms=${ms}`,
|
|
306
|
-
);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
const ms = Date.now() - start;
|
|
310
|
-
console.error(
|
|
311
|
-
`[admin-agent-graph-backfill] done totals: projected=${totalProjected} failed=${totalFailed} handled-by-candidates=${totalHandledByCandidates} handled-by-edges=${totalHandledByEdges} channel-backfilled=${totalChannelBackfilled} ms=${ms}`,
|
|
312
|
-
);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* Standalone entry point — retained for manual `npx tsx` runs (Task 871
|
|
317
|
-
* preserved this so an operator can re-run the backfill ad-hoc without a
|
|
318
|
-
* full server boot). Wraps `applyAdminAgentBackfill` with a structural
|
|
319
|
-
* driver-shape that delegates to the neo4j-store singleton's getSession,
|
|
320
|
-
* so the env-var resolution (NEO4J_URI / NEO4J_PASSWORD / config file)
|
|
321
|
-
* matches the boot path exactly.
|
|
322
|
-
*/
|
|
323
|
-
async function main(): Promise<void> {
|
|
324
|
-
if (!existsSync(ACCOUNTS_DIR)) {
|
|
325
|
-
console.error(
|
|
326
|
-
`[admin-agent-graph-backfill] ACCOUNTS_DIR missing at ${ACCOUNTS_DIR} — nothing to do`,
|
|
327
|
-
);
|
|
328
|
-
process.exit(0);
|
|
329
|
-
}
|
|
330
|
-
const driverShim: Neo4jDriverLike = { session: () => getSession() };
|
|
331
|
-
const platformRoot = resolve(ACCOUNTS_DIR, "..", "..", "platform");
|
|
332
|
-
try {
|
|
333
|
-
await applyAdminAgentBackfill(driverShim, platformRoot);
|
|
334
|
-
process.exit(0);
|
|
335
|
-
} catch (err) {
|
|
336
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
337
|
-
console.error(`[admin-agent-graph-backfill] fatal error="${msg}"`);
|
|
338
|
-
process.exit(2);
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
if (process.argv[1]?.endsWith("004-project-admin-agent.ts")) {
|
|
343
|
-
main().catch((err) => {
|
|
344
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
345
|
-
console.error(`[admin-agent-graph-backfill] fatal error="${msg}"`);
|
|
346
|
-
process.exit(2);
|
|
347
|
-
});
|
|
348
|
-
}
|
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Migration 004 — Prune alien-account nodes (Task 847).
|
|
3
|
-
*
|
|
4
|
-
* Deletes every node whose `accountId` is not present on disk under
|
|
5
|
-
* `${DATA_ROOT}/accounts/<uuid>/account.json`. Idempotent — silent when
|
|
6
|
-
* the graph is already clean.
|
|
7
|
-
*
|
|
8
|
-
* Why a backstop, not a writer fix: the leaked nodes were written by a
|
|
9
|
-
* since-removed writer. With no live writer to fix, this is the surface
|
|
10
|
-
* that catches future writer drift.
|
|
11
|
-
*
|
|
12
|
-
* Hard guard: refuses to run when the on-disk account set is empty
|
|
13
|
-
* (corrupt-install scenario). Refusing to wipe the graph is louder than
|
|
14
|
-
* silently wiping it.
|
|
15
|
-
*
|
|
16
|
-
* Doctrine in `.docs/neo4j.md` "Account isolation invariant" requires
|
|
17
|
-
* any writer that stamps `n.accountId` to verify the value against
|
|
18
|
-
* `${DATA_ROOT}/accounts/<id>/account.json` before write. This migration
|
|
19
|
-
* is a backstop, not a license.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { readFileSync, readdirSync } from "node:fs";
|
|
23
|
-
import { resolve } from "node:path";
|
|
24
|
-
|
|
25
|
-
const UUID_RE =
|
|
26
|
-
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Structural alias for the `Driver` instance the runner passes in. We type
|
|
30
|
-
* structurally rather than importing `Driver` from `neo4j-driver` because
|
|
31
|
-
* this file lives outside `platform/ui/` — depending on the workspace's
|
|
32
|
-
* dedupe state, `neo4j-driver` resolves to a different node_modules copy
|
|
33
|
-
* here than at the runner site, and TS treats two same-shape types from
|
|
34
|
-
* different paths as nominally distinct. Structural typing sidesteps that.
|
|
35
|
-
*/
|
|
36
|
-
type Neo4jDriverLike = {
|
|
37
|
-
session(): {
|
|
38
|
-
run(
|
|
39
|
-
cypher: string,
|
|
40
|
-
params?: Record<string, unknown>,
|
|
41
|
-
): Promise<{ records: Array<{ get(key: string): unknown }> }>;
|
|
42
|
-
close(): Promise<void>;
|
|
43
|
-
};
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
export async function pruneAlienAccounts(
|
|
47
|
-
driver: Neo4jDriverLike,
|
|
48
|
-
platformRoot: string,
|
|
49
|
-
): Promise<void> {
|
|
50
|
-
const accountsDir = resolve(platformRoot, "..", "data", "accounts");
|
|
51
|
-
const validIds = enumerateValidAccountIds(accountsDir);
|
|
52
|
-
|
|
53
|
-
if (validIds.size === 0) {
|
|
54
|
-
throw new Error(
|
|
55
|
-
`refusing to prune: no valid accounts found under ${accountsDir} — corrupt install? not deleting anything to avoid wiping the entire graph.`,
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const valid = Array.from(validIds);
|
|
60
|
-
const session = driver.session();
|
|
61
|
-
try {
|
|
62
|
-
// Two-step: first query collects the alien accountIds for the log
|
|
63
|
-
// line, second query deletes. Two cheap queries beat a single query
|
|
64
|
-
// that loses either the count or the id list under DELETE semantics.
|
|
65
|
-
const peek = await session.run(
|
|
66
|
-
`MATCH (n)
|
|
67
|
-
WHERE n.accountId IS NOT NULL AND NOT n.accountId IN $valid
|
|
68
|
-
RETURN DISTINCT n.accountId AS aid`,
|
|
69
|
-
{ valid },
|
|
70
|
-
);
|
|
71
|
-
const alienIds: string[] = [];
|
|
72
|
-
for (const record of peek.records) {
|
|
73
|
-
const aid: unknown = record.get("aid");
|
|
74
|
-
if (typeof aid === "string") alienIds.push(aid);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
if (alienIds.length === 0) return;
|
|
78
|
-
|
|
79
|
-
const result = await session.run(
|
|
80
|
-
`MATCH (n)
|
|
81
|
-
WHERE n.accountId IS NOT NULL AND NOT n.accountId IN $valid
|
|
82
|
-
DETACH DELETE n
|
|
83
|
-
RETURN count(n) AS pruned`,
|
|
84
|
-
{ valid },
|
|
85
|
-
);
|
|
86
|
-
const prunedRaw = result.records[0]?.get("pruned");
|
|
87
|
-
const pruned =
|
|
88
|
-
typeof prunedRaw === "number"
|
|
89
|
-
? prunedRaw
|
|
90
|
-
: (prunedRaw as { toNumber?: () => number })?.toNumber?.() ?? 0;
|
|
91
|
-
console.error(
|
|
92
|
-
`[graph-invariant] alien-accounts pruned=${pruned} accountIds=${alienIds.join(",")}`,
|
|
93
|
-
);
|
|
94
|
-
} finally {
|
|
95
|
-
await session.close();
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Enumerate accountIds with a parseable `account.json`. Directory name IS
|
|
101
|
-
* the canonical accountId (matches the UUID_RE.test(name) predicate at
|
|
102
|
-
* `platform/ui/server/routes/admin/files.ts`'s account-name resolver).
|
|
103
|
-
*
|
|
104
|
-
* Corruption discipline: a present-but-unparseable account.json is
|
|
105
|
-
* EXCLUDED from the valid set and emits a skip log line. Better to
|
|
106
|
-
* over-prune one suspect account than under-prune the leak it might
|
|
107
|
-
* be hiding.
|
|
108
|
-
*/
|
|
109
|
-
function enumerateValidAccountIds(accountsDir: string): Set<string> {
|
|
110
|
-
const valid = new Set<string>();
|
|
111
|
-
let names: string[];
|
|
112
|
-
try {
|
|
113
|
-
names = readdirSync(accountsDir);
|
|
114
|
-
} catch (err) {
|
|
115
|
-
if ((err as NodeJS.ErrnoException).code === "ENOENT") return valid;
|
|
116
|
-
throw err;
|
|
117
|
-
}
|
|
118
|
-
for (const name of names) {
|
|
119
|
-
if (!UUID_RE.test(name)) continue;
|
|
120
|
-
const configPath = resolve(accountsDir, name, "account.json");
|
|
121
|
-
try {
|
|
122
|
-
JSON.parse(readFileSync(configPath, "utf-8"));
|
|
123
|
-
valid.add(name);
|
|
124
|
-
} catch (err) {
|
|
125
|
-
const code = (err as NodeJS.ErrnoException).code ?? "parse-error";
|
|
126
|
-
if (code === "ENOENT") continue;
|
|
127
|
-
console.error(
|
|
128
|
-
`[graph-invariant] account-json-skip uuid=${name} reason=${code}`,
|
|
129
|
-
);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
return valid;
|
|
133
|
-
}
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Migration 005 — Remove review-detector / review-digest feature artefacts (Task 884).
|
|
3
|
-
*
|
|
4
|
-
* The review-detector subsystem was wired into boot, MERGEd a daily `:Event`
|
|
5
|
-
* with `actionTool="review-digest-compose"` on every start, and aggregated
|
|
6
|
-
* `:ReviewAlert` nodes via rule matches. The feature is deleted entirely;
|
|
7
|
-
* this migration is the boot-sweep that removes the residue from existing
|
|
8
|
-
* installs. Idempotent — once the graph is clean, both queries DETACH DELETE
|
|
9
|
-
* zero rows and the log line records `events=0 alerts=0`.
|
|
10
|
-
*
|
|
11
|
-
* Filesystem artefacts (`review.log`, `review-rules.json`, `review-state.json`,
|
|
12
|
-
* `review-pending-alerts.jsonl`) are also removed best-effort — ENOENT is the
|
|
13
|
-
* idempotent no-op; any other errno is logged and ignored so a sticky permission
|
|
14
|
-
* problem does not block boot.
|
|
15
|
-
*
|
|
16
|
-
* Same structural-type pattern as 004-prune-alien-accounts.ts: the runner sits
|
|
17
|
-
* outside `platform/ui/` so depending on the workspace dedupe state the
|
|
18
|
-
* `neo4j-driver` import resolves to a different node_modules copy, and TS
|
|
19
|
-
* treats two same-shape types from different paths as nominally distinct.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { resolve } from "node:path";
|
|
23
|
-
import { unlinkSync } from "node:fs";
|
|
24
|
-
import { homedir } from "node:os";
|
|
25
|
-
import { basename, dirname } from "node:path";
|
|
26
|
-
|
|
27
|
-
type Neo4jDriverLike = {
|
|
28
|
-
session(): {
|
|
29
|
-
run(
|
|
30
|
-
cypher: string,
|
|
31
|
-
params?: Record<string, unknown>,
|
|
32
|
-
): Promise<{ records: Array<{ get(key: string): unknown }> }>;
|
|
33
|
-
close(): Promise<void>;
|
|
34
|
-
};
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
export async function removeReviewFeature(
|
|
38
|
-
driver: Neo4jDriverLike,
|
|
39
|
-
platformRoot: string,
|
|
40
|
-
): Promise<void> {
|
|
41
|
-
const session = driver.session();
|
|
42
|
-
let eventsDeleted = 0;
|
|
43
|
-
let alertsDeleted = 0;
|
|
44
|
-
|
|
45
|
-
try {
|
|
46
|
-
const eventResult = await session.run(
|
|
47
|
-
`MATCH (e:Event)
|
|
48
|
-
WHERE e.actionTool = "review-digest-compose"
|
|
49
|
-
OR e.eventId STARTS WITH "review-digest-"
|
|
50
|
-
DETACH DELETE e
|
|
51
|
-
RETURN count(e) AS deleted`,
|
|
52
|
-
);
|
|
53
|
-
eventsDeleted = toNumber(eventResult.records[0]?.get("deleted"));
|
|
54
|
-
|
|
55
|
-
const alertResult = await session.run(
|
|
56
|
-
`MATCH (a:ReviewAlert)
|
|
57
|
-
DETACH DELETE a
|
|
58
|
-
RETURN count(a) AS deleted`,
|
|
59
|
-
);
|
|
60
|
-
alertsDeleted = toNumber(alertResult.records[0]?.get("deleted"));
|
|
61
|
-
} finally {
|
|
62
|
-
await session.close();
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Best-effort filesystem cleanup. The configDir convention is
|
|
66
|
-
// `~/.<installDirName>` where installDir = dirname(platformRoot)
|
|
67
|
-
// (e.g. `~/maxy/platform` → `~/.maxy`).
|
|
68
|
-
const installDirName = basename(dirname(platformRoot));
|
|
69
|
-
const configDir = resolve(homedir(), `.${installDirName}`);
|
|
70
|
-
const artefacts = [
|
|
71
|
-
resolve(configDir, "logs", "review.log"),
|
|
72
|
-
resolve(configDir, "review-rules.json"),
|
|
73
|
-
resolve(configDir, "review-state.json"),
|
|
74
|
-
resolve(configDir, "review-pending-alerts.jsonl"),
|
|
75
|
-
];
|
|
76
|
-
for (const path of artefacts) {
|
|
77
|
-
try {
|
|
78
|
-
unlinkSync(path);
|
|
79
|
-
} catch (err) {
|
|
80
|
-
const code = (err as NodeJS.ErrnoException).code;
|
|
81
|
-
if (code === "ENOENT") continue;
|
|
82
|
-
console.error(
|
|
83
|
-
`[migration] removed-review-feature unlink-skip path=${path} reason=${code ?? "unknown"}`,
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
console.error(
|
|
89
|
-
`[migration] removed-review-feature events=${eventsDeleted} alerts=${alertsDeleted}`,
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function toNumber(v: unknown): number {
|
|
94
|
-
if (typeof v === "number") return v;
|
|
95
|
-
if (
|
|
96
|
-
v &&
|
|
97
|
-
typeof (v as { toNumber?: () => number }).toNumber === "function"
|
|
98
|
-
) {
|
|
99
|
-
return (v as { toNumber: () => number }).toNumber();
|
|
100
|
-
}
|
|
101
|
-
return 0;
|
|
102
|
-
}
|