@rubytech/create-realagent 1.0.839 → 1.0.842

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.
Files changed (84) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/account-enumeration/dist/__tests__/enumerate.test.d.ts +2 -0
  3. package/payload/platform/lib/account-enumeration/dist/__tests__/enumerate.test.d.ts.map +1 -0
  4. package/payload/platform/lib/account-enumeration/dist/__tests__/enumerate.test.js +88 -0
  5. package/payload/platform/lib/account-enumeration/dist/__tests__/enumerate.test.js.map +1 -0
  6. package/payload/platform/lib/account-enumeration/dist/index.d.ts +23 -0
  7. package/payload/platform/lib/account-enumeration/dist/index.d.ts.map +1 -0
  8. package/payload/platform/lib/account-enumeration/dist/index.js +96 -0
  9. package/payload/platform/lib/account-enumeration/dist/index.js.map +1 -0
  10. package/payload/platform/lib/account-enumeration/src/__tests__/enumerate.test.ts +94 -0
  11. package/payload/platform/lib/account-enumeration/src/index.ts +96 -0
  12. package/payload/platform/lib/account-enumeration/tsconfig.json +8 -0
  13. package/payload/platform/lib/graph-write/dist/__tests__/account-id-gate.test.d.ts +2 -0
  14. package/payload/platform/lib/graph-write/dist/__tests__/account-id-gate.test.d.ts.map +1 -0
  15. package/payload/platform/lib/graph-write/dist/__tests__/account-id-gate.test.js +165 -0
  16. package/payload/platform/lib/graph-write/dist/__tests__/account-id-gate.test.js.map +1 -0
  17. package/payload/platform/lib/graph-write/dist/__tests__/action-provenance-gate.test.js +15 -5
  18. package/payload/platform/lib/graph-write/dist/__tests__/action-provenance-gate.test.js.map +1 -1
  19. package/payload/platform/lib/graph-write/dist/index.d.ts +12 -0
  20. package/payload/platform/lib/graph-write/dist/index.d.ts.map +1 -1
  21. package/payload/platform/lib/graph-write/dist/index.js +25 -0
  22. package/payload/platform/lib/graph-write/dist/index.js.map +1 -1
  23. package/payload/platform/lib/graph-write/src/__tests__/account-id-gate.test.ts +189 -0
  24. package/payload/platform/lib/graph-write/src/__tests__/action-provenance-gate.test.ts +16 -5
  25. package/payload/platform/lib/graph-write/src/index.ts +45 -1
  26. package/payload/platform/package.json +2 -2
  27. package/payload/platform/plugins/docs/references/attachments.md +2 -2
  28. package/payload/platform/plugins/docs/references/internals.md +1 -1
  29. package/payload/platform/plugins/docs/references/platform.md +1 -1
  30. package/payload/platform/plugins/docs/references/troubleshooting.md +1 -1
  31. package/payload/platform/plugins/memory/mcp/dist/lib/uuid.js +7 -7
  32. package/payload/platform/plugins/memory/mcp/dist/lib/uuid.js.map +1 -1
  33. package/payload/platform/plugins/whatsapp/PLUGIN.md +1 -1
  34. package/payload/platform/templates/agents/admin/IDENTITY.md +4 -0
  35. package/payload/server/adminuser-self-heal-RY4NFCI7.js +45 -0
  36. package/payload/server/chunk-2YG3AYAH.js +1508 -0
  37. package/payload/server/chunk-7DFOKDNM.js +2098 -0
  38. package/payload/server/chunk-CJWFM3WX.js +2098 -0
  39. package/payload/server/chunk-D5U4XQ66.js +656 -0
  40. package/payload/server/chunk-DJXPAH7T.js +1480 -0
  41. package/payload/server/chunk-DTWW35TK.js +667 -0
  42. package/payload/server/chunk-HTYXRFT6.js +727 -0
  43. package/payload/server/chunk-NPVEOM3D.js +1508 -0
  44. package/payload/server/chunk-QGM4M3NI.js +37 -0
  45. package/payload/server/chunk-S27QCBFQ.js +10071 -0
  46. package/payload/server/chunk-T2MQIKBT.js +10001 -0
  47. package/payload/server/chunk-TS6CKCGU.js +727 -0
  48. package/payload/server/chunk-XECKT3YB.js +10071 -0
  49. package/payload/server/client-pool-2WQ2Q3TF.js +32 -0
  50. package/payload/server/client-pool-M25CGILI.js +32 -0
  51. package/payload/server/client-pool-SMWCZMZG.js +32 -0
  52. package/payload/server/cloudflare-task-tracker-GQFKLY62.js +20 -0
  53. package/payload/server/cloudflare-task-tracker-NQK7A2EQ.js +20 -0
  54. package/payload/server/cloudflare-task-tracker-O4ZA4TAS.js +20 -0
  55. package/payload/server/cloudflare-task-tracker-XFGXO7ZV.js +20 -0
  56. package/payload/server/maxy-edge.js +3 -4
  57. package/payload/server/public/assets/{Checkbox-Bq6ORjz2.js → Checkbox-aCc0UGp3.js} +1 -1
  58. package/payload/server/public/assets/{admin-CstEkw-G.js → admin-CvwOOG4D.js} +2 -2
  59. package/payload/server/public/assets/data-DsItQm8c.js +1 -0
  60. package/payload/server/public/assets/graph-C-HOmfmU.js +1 -0
  61. package/payload/server/public/assets/{jsx-runtime-DidQeNoZ.css → jsx-runtime-BKoartnM.css} +1 -1
  62. package/payload/server/public/assets/{page-CFWoVkgV.js → page-D7LchjvY.js} +1 -1
  63. package/payload/server/public/assets/{page-Bpi_jPw6.js → page-DTmTvkNo.js} +1 -1
  64. package/payload/server/public/assets/{public-BWMwq5Jj.js → public-Br9YjNs_.js} +2 -2
  65. package/payload/server/public/assets/{useAdminFetch-B93ig7ef.js → useAdminFetch-BgDL3JGd.js} +1 -1
  66. package/payload/server/public/assets/{useVoiceRecorder-Cb0nAtOo.js → useVoiceRecorder-Bx903Mk1.js} +1 -1
  67. package/payload/server/public/data.html +5 -5
  68. package/payload/server/public/graph.html +6 -6
  69. package/payload/server/public/index.html +8 -8
  70. package/payload/server/public/public.html +5 -5
  71. package/payload/server/server.js +81 -67
  72. package/payload/platform/neo4j/migrations/001-backfill-scope.cypher +0 -30
  73. package/payload/platform/neo4j/migrations/002-project-public-agents.ts +0 -191
  74. package/payload/platform/neo4j/migrations/003-person-name-eradicate.cypher +0 -24
  75. package/payload/platform/neo4j/migrations/004-project-admin-agent.ts +0 -348
  76. package/payload/platform/neo4j/migrations/004-prune-alien-accounts.ts +0 -133
  77. package/payload/platform/neo4j/migrations/005-removed-review-feature.ts +0 -102
  78. package/payload/platform/neo4j/migrations/006-prune-bogus-whatsapp-persons.ts +0 -132
  79. package/payload/platform/neo4j/migrations/007-conversation-archive-source.ts +0 -116
  80. package/payload/platform/neo4j/migrations/008-adminuser-accountid-backfill.ts +0 -85
  81. package/payload/platform/neo4j/migrations/009-conversation-archive-title.ts +0 -197
  82. package/payload/server/public/assets/data-DwZZ7qbH.js +0 -1
  83. package/payload/server/public/assets/graph-DceEv42K.js +0 -1
  84. /package/payload/server/public/assets/{jsx-runtime-DH5S-MwB.js → jsx-runtime-WW3O7tSz.js} +0 -0
@@ -1,132 +0,0 @@
1
- /**
2
- * Migration 006 — Prune bogus auto-created WhatsApp :Person nodes (Task 887).
3
- *
4
- * Sweeps the residue from pre-887 ingests where the parser miss (LRM-prefixed
5
- * timestamp headers — Adam Mackay archive, log
6
- * `claude-agent-stream-preflush-9048663e-489.log`) glued the polluted line
7
- * onto the previous body. The next clean header parsed its senderName off
8
- * the polluted body, leaking entries like
9
- * "Adam Mackay:\n‎[04/02/2026, 11:52:16] Adam Mackay"
10
- * into `:Person {source:'whatsapp', participantStatus:'auto-created', name}`.
11
- * 23 leaked from one ingest of the Adam Mackay archive.
12
- *
13
- * Why a backstop, not just a writer fix: the writer fix (Task 887 §A0)
14
- * deletes the auto-create path entirely so the leak structurally cannot
15
- * recur. This migration cleans the graphs that were already polluted —
16
- * idempotent, runs once, deletes=0 on every subsequent boot.
17
- *
18
- * Match shape: `:Person {source:'whatsapp', participantStatus:'auto-created'}`
19
- * whose `name` contains `\n` or `[`. Both characters are impossible in a
20
- * legitimate WhatsApp display name; their presence is a parser-miss
21
- * fingerprint. DETACH DELETE removes the node and any incident edges
22
- * (`:SENT`, `:PARTICIPANT_IN`) so the chronology stays intact for the
23
- * canonical participants.
24
- *
25
- * Loud-fail discipline: a separate read pass first surfaces any :Person
26
- * with the polluted-name shape but NOT carrying `participantStatus =
27
- * 'auto-created'`. Such a node is operator-curated and the migration
28
- * refuses to delete it — we log the elementId for manual review and
29
- * proceed with the auto-created sweep. Better to leave one suspect node
30
- * standing than to delete a hand-edited canonical Person.
31
- *
32
- * Observability:
33
- * `[migration:whatsapp-bogus-person-prune] matched=N deleted=N
34
- * skipped-non-auto=N` — info, once per boot.
35
- * `[migration:whatsapp-bogus-person-prune] non-auto-match elementId=…
36
- * name=…` — warn, once per offending non-auto-created node.
37
- *
38
- * REMOVE WHEN: every install we ship has been booted at least once on
39
- * a version ≥ Task 887. The user controls all installs and will remove
40
- * this once that condition is met.
41
- */
42
-
43
- type Neo4jDriverLike = {
44
- session(): {
45
- run(
46
- cypher: string,
47
- params?: Record<string, unknown>,
48
- ): Promise<{ records: Array<{ get(key: string): unknown }> }>;
49
- close(): Promise<void>;
50
- };
51
- };
52
-
53
- // Match shape: name carries either a newline or a `[` — both are
54
- // parser-miss fingerprints. The `(?s)` DOTALL flag is mandatory: Java
55
- // regex (which Neo4j's `=~` uses) defaults `.` to "any char EXCEPT
56
- // newline", so the trailing `.*` would fail to span across a second
57
- // newline in a doubly-polluted name (e.g. `Adam:\n[ts] Adam:\n[ts]`).
58
- // Without DOTALL, the doubly-polluted residue slips past the sweep.
59
- const POLLUTED_NAME_REGEX = "(?s).*[\\n\\[].*";
60
-
61
- export async function pruneBogusWhatsappPersons(
62
- driver: Neo4jDriverLike,
63
- ): Promise<void> {
64
- const session = driver.session();
65
- try {
66
- // Step 1 — surface non-auto-created Persons whose name carries the
67
- // pollution shape. Operator-curated nodes that happen to share the
68
- // shape are kept and named in the log; the sweep runs only over
69
- // auto-created residue.
70
- const nonAutoRes = await session.run(
71
- `MATCH (p:Person {source: 'whatsapp'})
72
- WHERE coalesce(p.participantStatus, '') <> 'auto-created'
73
- AND p.name =~ $regex
74
- RETURN elementId(p) AS elementId, p.name AS name`,
75
- { regex: POLLUTED_NAME_REGEX },
76
- );
77
- const skippedNonAuto = nonAutoRes.records.length;
78
- for (const record of nonAutoRes.records) {
79
- const elementId = record.get("elementId") as string;
80
- const name = record.get("name") as string;
81
- console.error(
82
- `[migration:whatsapp-bogus-person-prune] non-auto-match elementId=${elementId} name=${JSON.stringify(name)}`,
83
- );
84
- }
85
-
86
- // Step 2 — count the auto-created matches (separate from delete so
87
- // `matched=N` reflects the discovered set even when the delete runs
88
- // against a partially-overlapping snapshot).
89
- const matchedRes = await session.run(
90
- `MATCH (p:Person {source: 'whatsapp', participantStatus: 'auto-created'})
91
- WHERE p.name =~ $regex
92
- RETURN count(p) AS matched`,
93
- { regex: POLLUTED_NAME_REGEX },
94
- );
95
- const matched = toNumber(matchedRes.records[0]?.get("matched"));
96
-
97
- if (matched === 0) {
98
- console.error(
99
- `[migration:whatsapp-bogus-person-prune] matched=0 deleted=0 skipped-non-auto=${skippedNonAuto}`,
100
- );
101
- return;
102
- }
103
-
104
- // Step 3 — DETACH DELETE auto-created matches. `count(p)` over the
105
- // matched-then-deleted node yields the deleted count for the log.
106
- const deleteRes = await session.run(
107
- `MATCH (p:Person {source: 'whatsapp', participantStatus: 'auto-created'})
108
- WHERE p.name =~ $regex
109
- DETACH DELETE p
110
- RETURN count(p) AS deleted`,
111
- { regex: POLLUTED_NAME_REGEX },
112
- );
113
- const deleted = toNumber(deleteRes.records[0]?.get("deleted"));
114
-
115
- console.error(
116
- `[migration:whatsapp-bogus-person-prune] matched=${matched} deleted=${deleted} skipped-non-auto=${skippedNonAuto}`,
117
- );
118
- } finally {
119
- await session.close();
120
- }
121
- }
122
-
123
- function toNumber(v: unknown): number {
124
- if (typeof v === "number") return v;
125
- if (
126
- v &&
127
- typeof (v as { toNumber?: () => number }).toNumber === "function"
128
- ) {
129
- return (v as { toNumber: () => number }).toNumber();
130
- }
131
- return 0;
132
- }
@@ -1,116 +0,0 @@
1
- /**
2
- * Migration 007 — Backfill `source` and rename provenance on existing
3
- * :ConversationArchive parents and :Section:Conversation chunks (Task 894).
4
- *
5
- * Pre-Task-894 the conversation-archive write path stamped every node with
6
- * `createdByAgent = 'whatsapp-import'` and `source = 'whatsapp'`. Task 894
7
- * generalises the path: `createdByAgent` becomes `'conversation-archive'`
8
- * (one orchestrator, one skill, regardless of source); `source` is a property
9
- * carrying the source format ('whatsapp', 'telegram', 'slack', …).
10
- *
11
- * Without this migration the diagnostic surface fragments — queries that
12
- * filter by createdByAgent return only post-894 data; the new
13
- * (accountId, source) index is empty for legacy archives. This migration
14
- * rewrites both sides idempotently:
15
- *
16
- * 1. :ConversationArchive without `source` → SET source='whatsapp'
17
- * (every pre-894 archive came from the whatsapp-import bin script).
18
- * 2. :ConversationArchive.createdByAgent='whatsapp-import' →
19
- * SET createdByAgent='conversation-archive'.
20
- * 3. :Section:Conversation without `source` → SET source='whatsapp'.
21
- * 4. :Section:Conversation.createdByAgent='whatsapp-import' →
22
- * SET createdByAgent='conversation-archive'.
23
- * 5. :HAS_SECTION / :NEXT / :PARTICIPANT_IN edges with
24
- * createdByAgent='whatsapp-import' → SET createdByAgent='conversation-archive'.
25
- *
26
- * Re-runs are no-ops (each WHERE clause excludes the post-rename state).
27
- *
28
- * Observability:
29
- * `[migration:conversation-archive-source]
30
- * archives-source-set=N archives-agent-renamed=N
31
- * chunks-source-set=N chunks-agent-renamed=N edges-agent-renamed=N`
32
- *
33
- * REMOVE WHEN: every install we ship has been booted once on a version
34
- * ≥ Task 894 AND no live archive carries the legacy provenance string.
35
- */
36
-
37
- type Neo4jDriverLike = {
38
- session(): {
39
- run(
40
- cypher: string,
41
- params?: Record<string, unknown>,
42
- ): Promise<{ records: Array<{ get(key: string): unknown }> }>;
43
- close(): Promise<void>;
44
- };
45
- };
46
-
47
- export async function backfillConversationArchiveSource(
48
- driver: Neo4jDriverLike,
49
- ): Promise<void> {
50
- const session = driver.session();
51
- try {
52
- const archivesSourceSet = await runCount(
53
- session,
54
- `MATCH (a:ConversationArchive)
55
- WHERE a.source IS NULL
56
- SET a.source = 'whatsapp'
57
- RETURN count(a) AS n`,
58
- );
59
-
60
- const archivesAgentRenamed = await runCount(
61
- session,
62
- `MATCH (a:ConversationArchive)
63
- WHERE a.createdByAgent = 'whatsapp-import'
64
- SET a.createdByAgent = 'conversation-archive'
65
- RETURN count(a) AS n`,
66
- );
67
-
68
- const chunksSourceSet = await runCount(
69
- session,
70
- `MATCH (c:Section:Conversation)
71
- WHERE c.source IS NULL
72
- SET c.source = 'whatsapp'
73
- RETURN count(c) AS n`,
74
- );
75
-
76
- const chunksAgentRenamed = await runCount(
77
- session,
78
- `MATCH (c:Section:Conversation)
79
- WHERE c.createdByAgent = 'whatsapp-import'
80
- SET c.createdByAgent = 'conversation-archive'
81
- RETURN count(c) AS n`,
82
- );
83
-
84
- const edgesAgentRenamed = await runCount(
85
- session,
86
- `MATCH (a:ConversationArchive)-[r]-()
87
- WHERE r.createdByAgent = 'whatsapp-import'
88
- SET r.createdByAgent = 'conversation-archive'
89
- RETURN count(r) AS n`,
90
- );
91
-
92
- console.error(
93
- `[migration:conversation-archive-source] ` +
94
- `archives-source-set=${archivesSourceSet} ` +
95
- `archives-agent-renamed=${archivesAgentRenamed} ` +
96
- `chunks-source-set=${chunksSourceSet} ` +
97
- `chunks-agent-renamed=${chunksAgentRenamed} ` +
98
- `edges-agent-renamed=${edgesAgentRenamed}`,
99
- );
100
- } finally {
101
- await session.close();
102
- }
103
- }
104
-
105
- async function runCount(
106
- session: { run(c: string, p?: Record<string, unknown>): Promise<{ records: Array<{ get(k: string): unknown }> }> },
107
- cypher: string,
108
- ): Promise<number> {
109
- const res = await session.run(cypher);
110
- const v = res.records[0]?.get("n");
111
- if (typeof v === "number") return v;
112
- if (v && typeof (v as { toNumber?: () => number }).toNumber === "function") {
113
- return (v as { toNumber: () => number }).toNumber();
114
- }
115
- return 0;
116
- }
@@ -1,85 +0,0 @@
1
- /**
2
- * Migration 008 — Backfill `accountId` on legacy `:AdminUser` nodes (Task 897).
3
- *
4
- * Pre-Task-897 the `admin-add` MERGE in admin/mcp/src/index.ts stamped only
5
- * `userId/name/createdAt` on `:AdminUser` ON CREATE. Migration 004
6
- * (pruneAlienAccounts) DETACH DELETEs every node whose `accountId` is not
7
- * a valid on-disk account UUID, including null-accountId :AdminUser entries
8
- * — silently wiping the admin's pin and Person ownership.
9
- *
10
- * The fix in admin/mcp/src/index.ts now stamps `accountId` ON CREATE and
11
- * COALESCEs ON MATCH. This migration cleans the existing graphs that already
12
- * carry the legacy null-accountId fingerprint, by reading the surviving
13
- * `:ADMIN_OF` edge to `:LocalBusiness` and copying the LocalBusiness's
14
- * `accountId` onto the orphan AdminUser.
15
- *
16
- * Migration ordering: this runs AFTER pruneAlienAccounts (migration 004).
17
- * pruneAlienAccounts only deletes nodes whose `accountId` is *non-null but
18
- * unknown* — null-accountId nodes survive its sweep and reach this backfill.
19
- * After backfill, any remaining null-accountId AdminUser is a structural bug
20
- * (no ADMIN_OF edge → cannot derive accountId) and the boot fails loudly.
21
- *
22
- * Observability:
23
- * `[migration:adminuser-accountid] backfilled=N residual-null=M` — info
24
- * once per boot. residual-null > 0 throws and crashes boot.
25
- *
26
- * REMOVE WHEN: every install we ship has been booted at least once on a
27
- * version ≥ Task 897 AND no live `:AdminUser` carries a null accountId.
28
- */
29
-
30
- type Neo4jDriverLike = {
31
- session(): {
32
- run(
33
- cypher: string,
34
- params?: Record<string, unknown>,
35
- ): Promise<{ records: Array<{ get(key: string): unknown }> }>;
36
- close(): Promise<void>;
37
- };
38
- };
39
-
40
- export async function backfillAdminUserAccountId(
41
- driver: Neo4jDriverLike,
42
- ): Promise<void> {
43
- const session = driver.session();
44
- try {
45
- const backfilled = await runCount(
46
- session,
47
- `MATCH (au:AdminUser) WHERE au.accountId IS NULL
48
- OPTIONAL MATCH (au)-[:ADMIN_OF]->(b:LocalBusiness)
49
- WITH au, b WHERE b IS NOT NULL AND b.accountId IS NOT NULL
50
- SET au.accountId = b.accountId
51
- RETURN count(au) AS n`,
52
- );
53
-
54
- const residualNull = await runCount(
55
- session,
56
- `MATCH (au:AdminUser) WHERE au.accountId IS NULL
57
- RETURN count(au) AS n`,
58
- );
59
-
60
- console.error(
61
- `[migration:adminuser-accountid] backfilled=${backfilled} residual-null=${residualNull}`,
62
- );
63
-
64
- if (residualNull > 0) {
65
- throw new Error(
66
- `migration:adminuser-accountid: ${residualNull} :AdminUser node(s) still carry a null accountId after backfill — no ADMIN_OF edge to derive from. Manual reconciliation required (cypher-shell into the graph; either DELETE the orphan or attach a valid ADMIN_OF edge and re-run boot).`,
67
- );
68
- }
69
- } finally {
70
- await session.close();
71
- }
72
- }
73
-
74
- async function runCount(
75
- session: { run(c: string, p?: Record<string, unknown>): Promise<{ records: Array<{ get(k: string): unknown }> }> },
76
- cypher: string,
77
- ): Promise<number> {
78
- const res = await session.run(cypher);
79
- const v = res.records[0]?.get("n");
80
- if (typeof v === "number") return v;
81
- if (v && typeof (v as { toNumber?: () => number }).toNumber === "function") {
82
- return (v as { toNumber: () => number }).toNumber();
83
- }
84
- return 0;
85
- }
@@ -1,197 +0,0 @@
1
- /**
2
- * Migration 009 — Backfill `title` on every legacy `:ConversationArchive`
3
- * (Task 902 sub-scope A).
4
- *
5
- * Pre-902 the bin wrote per-session counter strings ("Session 1/N: K messages,
6
- * X chunks") into `:ConversationArchive.summary`. Because the MERGE Cypher
7
- * set `summary` ONLY in `ON CREATE SET`, the FIRST per-session call's counter
8
- * was frozen for the lifetime of the archive — and the UI's `pickDisplayName`
9
- * fell through to `summary` for `:ConversationArchive`, so every archive
10
- * rendered as the literal "Session 1/N…" string of its first checkpoint.
11
- *
12
- * Task 902 added a stable `title` property and a UI branch that prefers it.
13
- * This migration backfills `title` on every legacy archive that predates the
14
- * new contract, so the UI never label-falls-through:
15
- *
16
- * <source> · <owner> ↔ <other1>, <other2>, … · <YYYY-MM-DD>→<YYYY-MM-DD>
17
- *
18
- * Owner / participant resolution: query `(p)-[:PARTICIPANT_IN]->(a)` for the
19
- * archive's participant set, then pick a name per node by label:
20
- * :AdminUser → displayName, then slug
21
- * :Person → givenName + familyName
22
- * Owner is identified by AdminUser presence in the set; other participants
23
- * are everyone else. When neither name resolves on a node, fall back to a
24
- * short elementId prefix (mirrors the bin's degraded behaviour at
25
- * `computeArchiveTitle`, so the live ingest produces the same shape).
26
- *
27
- * Date range: head and tail `:Section:Conversation` chunks via
28
- * `firstMessageAt` / `lastMessageAt` properties (Task 891 chunk schema).
29
- * When chunks lack those properties (rare — pre-Task-891 archives), fall
30
- * back to the parent's `lastIngestedMessageAt` for both ends. When even
31
- * that is missing, the date segment is `?→?`. The migration NEVER leaves
32
- * `title` NULL — every row gets a string, even if degraded — so the
33
- * verification query `MATCH (a:ConversationArchive) WHERE a.title IS NULL
34
- * RETURN count(a)` returns 0 unconditionally.
35
- *
36
- * Idempotency: only archives where `title IS NULL` are touched. A second
37
- * boot finds zero rows and emits `archives-titled=0`.
38
- *
39
- * Observability:
40
- * `[migration:conversation-archive-title]
41
- * archives-titled=N degraded-no-dates=N degraded-no-names=N`
42
- *
43
- * REMOVE WHEN: every install we ship has been booted once on a version
44
- * ≥ Task 902 AND no live archive carries `title IS NULL`.
45
- */
46
-
47
- type Neo4jDriverLike = {
48
- session(): {
49
- run(
50
- cypher: string,
51
- params?: Record<string, unknown>,
52
- ): Promise<{ records: Array<{ get(key: string): unknown }> }>;
53
- close(): Promise<void>;
54
- };
55
- };
56
-
57
- interface ParticipantRow {
58
- elementId: string;
59
- labels: string[];
60
- displayName?: string;
61
- slug?: string;
62
- givenName?: string;
63
- familyName?: string;
64
- }
65
-
66
- const ISO_DATE_RE = /^(\d{4}-\d{2}-\d{2})/;
67
-
68
- function isoToYmd(iso: unknown): string {
69
- if (typeof iso !== "string") return "?";
70
- const m = iso.match(ISO_DATE_RE);
71
- return m ? m[1] : "?";
72
- }
73
-
74
- function pickName(row: ParticipantRow): string {
75
- if (row.labels.includes("AdminUser")) {
76
- if (row.displayName && row.displayName.trim()) return row.displayName.trim();
77
- if (row.slug && row.slug.trim()) return row.slug.trim();
78
- }
79
- if (row.labels.includes("Person")) {
80
- const full = [row.givenName, row.familyName]
81
- .filter((s): s is string => typeof s === "string" && s.trim().length > 0)
82
- .map((s) => s.trim())
83
- .join(" ");
84
- if (full) return full;
85
- }
86
- return row.elementId.slice(0, 8);
87
- }
88
-
89
- function asString(v: unknown): string | undefined {
90
- return typeof v === "string" ? v : undefined;
91
- }
92
-
93
- export async function backfillConversationArchiveTitle(
94
- driver: Neo4jDriverLike,
95
- ): Promise<void> {
96
- const session = driver.session();
97
- let archivesTitled = 0;
98
- let degradedNoDates = 0;
99
- let degradedNoNames = 0;
100
- try {
101
- // 1. Find every archive lacking a title. For each, pull the participant
102
- // set + head/tail chunk timestamps in one batch round-trip per archive.
103
- // Idempotent: re-runs return zero rows.
104
- const archives = await session.run(
105
- `MATCH (a:ConversationArchive)
106
- WHERE a.title IS NULL
107
- RETURN elementId(a) AS elemId,
108
- coalesce(a.source, 'whatsapp') AS source,
109
- a.lastIngestedMessageAt AS lastAt`,
110
- );
111
- for (const rec of archives.records) {
112
- const elemId = rec.get("elemId") as string;
113
- const source = rec.get("source") as string;
114
- const fallbackLastAt = rec.get("lastAt") as string | null;
115
-
116
- // 2. Pull the participant rows.
117
- const partRes = await session.run(
118
- `MATCH (p)-[:PARTICIPANT_IN]->(a:ConversationArchive)
119
- WHERE elementId(a) = $elemId AND (p:Person OR p:AdminUser)
120
- RETURN elementId(p) AS elemId, labels(p) AS labels, properties(p) AS props`,
121
- { elemId },
122
- );
123
- const owner: ParticipantRow[] = [];
124
- const others: ParticipantRow[] = [];
125
- let anyDegraded = false;
126
- for (const r of partRes.records) {
127
- const labels = (r.get("labels") as string[]) || [];
128
- const props = (r.get("props") as Record<string, unknown>) || {};
129
- const row: ParticipantRow = {
130
- elementId: r.get("elemId") as string,
131
- labels,
132
- displayName: asString(props.displayName),
133
- slug: asString(props.slug),
134
- givenName: asString(props.givenName),
135
- familyName: asString(props.familyName),
136
- };
137
- const name = pickName(row);
138
- if (name === row.elementId.slice(0, 8)) anyDegraded = true;
139
- if (labels.includes("AdminUser")) owner.push(row);
140
- else others.push(row);
141
- }
142
- if (anyDegraded) degradedNoNames += 1;
143
- const ownerName = owner.length > 0 ? pickName(owner[0]) : "?";
144
- const otherNames = others.length > 0
145
- ? others.map((o) => pickName(o)).join(", ")
146
- : "?";
147
-
148
- // 3. Pull the head and tail chunk timestamps. Chunks land in NEXT-chain
149
- // order; the chain head has no incoming :NEXT edge inside the
150
- // same archive, the tail has no outgoing :NEXT.
151
- const datesRes = await session.run(
152
- `MATCH (a:ConversationArchive)-[:HAS_SECTION]->(c:Section:Conversation)
153
- WHERE elementId(a) = $elemId
154
- WITH c, c.firstMessageAt AS first, c.lastMessageAt AS last
155
- RETURN min(first) AS firstAt, max(last) AS lastAt`,
156
- { elemId },
157
- );
158
- let firstAt = asString(datesRes.records[0]?.get("firstAt"));
159
- let lastAt = asString(datesRes.records[0]?.get("lastAt"));
160
- if (!firstAt || !lastAt) {
161
- if (fallbackLastAt) {
162
- firstAt = firstAt ?? fallbackLastAt;
163
- lastAt = lastAt ?? fallbackLastAt;
164
- }
165
- }
166
- if (!firstAt || !lastAt) {
167
- degradedNoDates += 1;
168
- }
169
- const firstYmd = isoToYmd(firstAt);
170
- const lastYmd = isoToYmd(lastAt);
171
-
172
- const title = `${source} · ${ownerName} ↔ ${otherNames} · ${firstYmd}→${lastYmd}`;
173
-
174
- // 4. Stamp the title. Guarded by `title IS NULL` so a concurrent live
175
- // ingest that has already written a real title is never overwritten.
176
- const upd = await session.run(
177
- `MATCH (a:ConversationArchive)
178
- WHERE elementId(a) = $elemId AND a.title IS NULL
179
- SET a.title = $title
180
- RETURN count(a) AS n`,
181
- { elemId, title },
182
- );
183
- const n = upd.records[0]?.get("n");
184
- const v = typeof n === "number" ? n : (n as { toNumber?: () => number })?.toNumber?.() ?? 0;
185
- if (v > 0) archivesTitled += 1;
186
- }
187
-
188
- console.error(
189
- `[migration:conversation-archive-title] ` +
190
- `archives-titled=${archivesTitled} ` +
191
- `degraded-no-dates=${degradedNoDates} ` +
192
- `degraded-no-names=${degradedNoNames}`,
193
- );
194
- } finally {
195
- await session.close();
196
- }
197
- }
@@ -1 +0,0 @@
1
- import{i as e,t}from"./jsx-runtime-DH5S-MwB.js";import{t as n}from"./page-CFWoVkgV.js";import"./useAdminFetch-B93ig7ef.js";var r=e(),i=t();(0,r.createRoot)(document.getElementById(`root`)).render((0,i.jsx)(n,{}));
@@ -1 +0,0 @@
1
- import{i as e,t}from"./jsx-runtime-DH5S-MwB.js";import{n}from"./page-Bpi_jPw6.js";import"./useAdminFetch-B93ig7ef.js";import"./Checkbox-Bq6ORjz2.js";var r=e(),i=t();(0,r.createRoot)(document.getElementById(`root`)).render((0,i.jsx)(n,{}));