ltcai 4.0.0 → 4.1.0

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 (195) hide show
  1. package/README.md +42 -33
  2. package/desktop/electron/main.cjs +44 -0
  3. package/docs/CHANGELOG.md +106 -0
  4. package/docs/REALTIME_COLLABORATION.md +3 -3
  5. package/docs/V3_FRONTEND.md +9 -8
  6. package/docs/V4_1_FRONTEND_ARCHITECTURE_REVIEW.md +65 -0
  7. package/docs/V4_1_FRONTEND_MIGRATION_REPORT.md +70 -0
  8. package/docs/V4_1_VALIDATION_REPORT.md +47 -0
  9. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +95 -45
  10. package/docs/kg-schema.md +6 -2
  11. package/docs/spec-vs-impl.md +10 -10
  12. package/frontend/index.html +24 -0
  13. package/frontend/openapi.json +14190 -0
  14. package/frontend/src/App.tsx +184 -0
  15. package/frontend/src/api/client.ts +317 -0
  16. package/frontend/src/api/openapi.ts +16637 -0
  17. package/frontend/src/components/primitives.tsx +204 -0
  18. package/frontend/src/components/ui/badge.tsx +27 -0
  19. package/frontend/src/components/ui/button.tsx +37 -0
  20. package/frontend/src/components/ui/card.tsx +22 -0
  21. package/frontend/src/components/ui/input.tsx +16 -0
  22. package/frontend/src/components/ui/textarea.tsx +16 -0
  23. package/frontend/src/lib/utils.ts +33 -0
  24. package/frontend/src/main.tsx +23 -0
  25. package/frontend/src/pages/Act.tsx +245 -0
  26. package/frontend/src/pages/Ask.tsx +200 -0
  27. package/frontend/src/pages/Brain.tsx +267 -0
  28. package/frontend/src/pages/Capture.tsx +158 -0
  29. package/frontend/src/pages/Library.tsx +187 -0
  30. package/frontend/src/pages/System.tsx +344 -0
  31. package/frontend/src/routes.ts +85 -0
  32. package/frontend/src/store/appStore.ts +54 -0
  33. package/frontend/src/styles.css +107 -0
  34. package/kg_schema.py +2 -603
  35. package/knowledge_graph.py +37 -4958
  36. package/latticeai/__init__.py +1 -1
  37. package/latticeai/api/admin.py +15 -16
  38. package/latticeai/api/agents.py +13 -6
  39. package/latticeai/api/auth.py +19 -11
  40. package/latticeai/api/invitations.py +100 -0
  41. package/latticeai/api/knowledge_graph.py +4 -11
  42. package/latticeai/api/plugins.py +3 -6
  43. package/latticeai/api/realtime.py +4 -7
  44. package/latticeai/api/setup.py +5 -4
  45. package/latticeai/api/static_routes.py +13 -16
  46. package/latticeai/api/ui_redirects.py +26 -0
  47. package/latticeai/api/workflow_designer.py +39 -6
  48. package/latticeai/api/workspace.py +24 -10
  49. package/latticeai/app_factory.py +88 -17
  50. package/latticeai/brain/_kg_common.py +1123 -0
  51. package/latticeai/brain/discovery.py +1455 -0
  52. package/latticeai/brain/documents.py +218 -0
  53. package/latticeai/brain/ingest.py +644 -0
  54. package/latticeai/brain/projection.py +561 -0
  55. package/latticeai/brain/provenance.py +401 -0
  56. package/latticeai/brain/retrieval.py +1316 -0
  57. package/latticeai/brain/schema.py +640 -0
  58. package/latticeai/brain/store.py +216 -0
  59. package/latticeai/brain/write_master.py +225 -0
  60. package/latticeai/core/invitations.py +131 -0
  61. package/latticeai/core/marketplace.py +1 -1
  62. package/latticeai/core/multi_agent.py +1 -1
  63. package/latticeai/core/policy.py +54 -0
  64. package/latticeai/core/realtime.py +65 -44
  65. package/latticeai/core/sessions.py +31 -5
  66. package/latticeai/core/users.py +147 -0
  67. package/latticeai/core/workspace_os.py +420 -20
  68. package/latticeai/services/agent_runtime.py +242 -4
  69. package/latticeai/services/run_executor.py +328 -0
  70. package/latticeai/services/workspace_service.py +27 -19
  71. package/package.json +54 -27
  72. package/scripts/build_frontend_assets.mjs +38 -0
  73. package/scripts/bump_version.py +1 -1
  74. package/scripts/export_openapi.py +31 -0
  75. package/scripts/lint_frontend.mjs +86 -0
  76. package/scripts/run_python.mjs +47 -0
  77. package/src-tauri/Cargo.lock +4833 -0
  78. package/src-tauri/Cargo.toml +19 -0
  79. package/src-tauri/build.rs +3 -0
  80. package/src-tauri/capabilities/default.json +7 -0
  81. package/src-tauri/src/main.rs +78 -0
  82. package/src-tauri/tauri.conf.json +36 -0
  83. package/static/app/asset-manifest.json +32 -0
  84. package/static/app/assets/core-CwxXejkd.js +2 -0
  85. package/static/app/assets/core-CwxXejkd.js.map +1 -0
  86. package/static/app/assets/index-CJRAzNnf.js +333 -0
  87. package/static/app/assets/index-CJRAzNnf.js.map +1 -0
  88. package/static/app/assets/index-CSwBBgf4.css +2 -0
  89. package/static/app/index.html +25 -0
  90. package/static/manifest.json +2 -2
  91. package/static/sw.js +4 -4
  92. package/scripts/build_v3_assets.mjs +0 -170
  93. package/scripts/lint_v3.mjs +0 -97
  94. package/static/account.html +0 -113
  95. package/static/activity.html +0 -73
  96. package/static/admin.html +0 -486
  97. package/static/agents.html +0 -139
  98. package/static/chat.html +0 -841
  99. package/static/css/reference/account.css +0 -439
  100. package/static/css/reference/admin.css +0 -610
  101. package/static/css/reference/base.css +0 -1661
  102. package/static/css/reference/chat.css +0 -4623
  103. package/static/css/reference/graph.css +0 -1016
  104. package/static/css/responsive.css +0 -861
  105. package/static/graph.html +0 -122
  106. package/static/platform.css +0 -104
  107. package/static/plugins.html +0 -136
  108. package/static/scripts/account.js +0 -238
  109. package/static/scripts/admin.js +0 -1614
  110. package/static/scripts/chat.js +0 -5081
  111. package/static/scripts/graph.js +0 -1804
  112. package/static/scripts/platform.js +0 -64
  113. package/static/scripts/ux.js +0 -167
  114. package/static/scripts/workspace.js +0 -948
  115. package/static/v3/asset-manifest.json +0 -56
  116. package/static/v3/css/lattice.base.49deefb5.css +0 -128
  117. package/static/v3/css/lattice.base.css +0 -128
  118. package/static/v3/css/lattice.components.cde18231.css +0 -472
  119. package/static/v3/css/lattice.components.css +0 -472
  120. package/static/v3/css/lattice.shell.29d36d85.css +0 -452
  121. package/static/v3/css/lattice.shell.css +0 -452
  122. package/static/v3/css/lattice.tokens.304cbc40.css +0 -135
  123. package/static/v3/css/lattice.tokens.css +0 -135
  124. package/static/v3/css/lattice.views.0a18b6c5.css +0 -360
  125. package/static/v3/css/lattice.views.css +0 -360
  126. package/static/v3/index.html +0 -68
  127. package/static/v3/js/app.356e6452.js +0 -26
  128. package/static/v3/js/app.js +0 -26
  129. package/static/v3/js/core/api.7a308b89.js +0 -568
  130. package/static/v3/js/core/api.js +0 -568
  131. package/static/v3/js/core/components.f25b3b93.js +0 -230
  132. package/static/v3/js/core/components.js +0 -230
  133. package/static/v3/js/core/dom.a2773eb0.js +0 -148
  134. package/static/v3/js/core/dom.js +0 -148
  135. package/static/v3/js/core/router.584570f2.js +0 -37
  136. package/static/v3/js/core/router.js +0 -37
  137. package/static/v3/js/core/routes.7222343d.js +0 -93
  138. package/static/v3/js/core/routes.js +0 -93
  139. package/static/v3/js/core/shell.a1657f20.js +0 -391
  140. package/static/v3/js/core/shell.js +0 -391
  141. package/static/v3/js/core/store.204a08b2.js +0 -113
  142. package/static/v3/js/core/store.js +0 -113
  143. package/static/v3/js/views/admin-audit.660a1fb1.js +0 -185
  144. package/static/v3/js/views/admin-audit.js +0 -185
  145. package/static/v3/js/views/admin-permissions.a7ae5f09.js +0 -177
  146. package/static/v3/js/views/admin-permissions.js +0 -177
  147. package/static/v3/js/views/admin-policies.3658fd86.js +0 -102
  148. package/static/v3/js/views/admin-policies.js +0 -102
  149. package/static/v3/js/views/admin-private-vpc.7d342d36.js +0 -135
  150. package/static/v3/js/views/admin-private-vpc.js +0 -135
  151. package/static/v3/js/views/admin-security.07c66b72.js +0 -180
  152. package/static/v3/js/views/admin-security.js +0 -180
  153. package/static/v3/js/views/admin-users.03bac88c.js +0 -168
  154. package/static/v3/js/views/admin-users.js +0 -168
  155. package/static/v3/js/views/agents.014d0b74.js +0 -541
  156. package/static/v3/js/views/agents.js +0 -541
  157. package/static/v3/js/views/chat.e6dd7dd0.js +0 -601
  158. package/static/v3/js/views/chat.js +0 -601
  159. package/static/v3/js/views/files.adad14c1.js +0 -365
  160. package/static/v3/js/views/files.js +0 -365
  161. package/static/v3/js/views/graph-canvas.17c15d65.js +0 -509
  162. package/static/v3/js/views/graph-canvas.js +0 -509
  163. package/static/v3/js/views/home.24f8b8ae.js +0 -200
  164. package/static/v3/js/views/home.js +0 -200
  165. package/static/v3/js/views/hooks.37895880.js +0 -220
  166. package/static/v3/js/views/hooks.js +0 -220
  167. package/static/v3/js/views/hybrid-search.2fb63ed9.js +0 -194
  168. package/static/v3/js/views/hybrid-search.js +0 -194
  169. package/static/v3/js/views/knowledge-graph.5e40cbeb.js +0 -509
  170. package/static/v3/js/views/knowledge-graph.js +0 -509
  171. package/static/v3/js/views/marketplace.ab0583d4.js +0 -141
  172. package/static/v3/js/views/marketplace.js +0 -141
  173. package/static/v3/js/views/mcp.99b5c6a7.js +0 -114
  174. package/static/v3/js/views/mcp.js +0 -114
  175. package/static/v3/js/views/memory.4ebdf474.js +0 -147
  176. package/static/v3/js/views/memory.js +0 -147
  177. package/static/v3/js/views/models.a1ffa147.js +0 -256
  178. package/static/v3/js/views/models.js +0 -256
  179. package/static/v3/js/views/my-computer.d9d9ae1c.js +0 -463
  180. package/static/v3/js/views/my-computer.js +0 -463
  181. package/static/v3/js/views/pipeline.c522f1ce.js +0 -157
  182. package/static/v3/js/views/pipeline.js +0 -157
  183. package/static/v3/js/views/planning.9ac3e313.js +0 -153
  184. package/static/v3/js/views/planning.js +0 -153
  185. package/static/v3/js/views/settings.8631fa5e.js +0 -318
  186. package/static/v3/js/views/settings.js +0 -318
  187. package/static/v3/js/views/skills.c6c2f965.js +0 -109
  188. package/static/v3/js/views/skills.js +0 -109
  189. package/static/v3/js/views/tools.e4f11276.js +0 -108
  190. package/static/v3/js/views/tools.js +0 -108
  191. package/static/v3/js/views/workflows.26c57290.js +0 -128
  192. package/static/v3/js/views/workflows.js +0 -128
  193. package/static/workflows.html +0 -146
  194. package/static/workspace.css +0 -1121
  195. package/static/workspace.html +0 -357
@@ -28,6 +28,7 @@ from __future__ import annotations
28
28
 
29
29
  import asyncio
30
30
  import json
31
+ import threading
31
32
  from datetime import datetime
32
33
  from typing import Any, AsyncIterator, Dict, List, Optional, Set
33
34
 
@@ -47,7 +48,7 @@ def sse_format(event: Dict[str, Any]) -> str:
47
48
 
48
49
 
49
50
  class _Subscriber:
50
- __slots__ = ("id", "queue", "workspace_scope", "user", "joined_at")
51
+ __slots__ = ("id", "queue", "workspace_scope", "user", "joined_at", "loop")
51
52
 
52
53
  def __init__(self, sub_id: str, workspace_scope: Optional[Set[str]], user: Optional[str]):
53
54
  self.id = sub_id
@@ -55,6 +56,10 @@ class _Subscriber:
55
56
  self.workspace_scope = workspace_scope
56
57
  self.user = user
57
58
  self.joined_at = _now()
59
+ try:
60
+ self.loop: Optional[asyncio.AbstractEventLoop] = asyncio.get_running_loop()
61
+ except RuntimeError:
62
+ self.loop = None
58
63
 
59
64
  def accepts(self, workspace_id: Optional[str]) -> bool:
60
65
  # ``None`` scope = see everything the local user can (personal/unscoped).
@@ -73,6 +78,7 @@ class RealtimeBus:
73
78
  self._feed: List[Dict[str, Any]] = []
74
79
  self._presence: Dict[str, Dict[str, Any]] = {}
75
80
  self._seq = 0
81
+ self._lock = threading.RLock()
76
82
 
77
83
  # ── publishing ────────────────────────────────────────────────────────
78
84
 
@@ -82,34 +88,41 @@ class RealtimeBus:
82
88
  Safe to call from sync code (e.g. the store's timeline hook). Never
83
89
  raises and never blocks the caller.
84
90
  """
85
- self._seq += 1
86
- enriched = {
87
- "seq": self._seq,
88
- "received_at": _now(),
89
- "area": event.get("area", "workspace"),
90
- "event_type": event.get("event_type", "event"),
91
- "workspace_id": event.get("workspace_id"),
92
- "payload": event.get("payload", {}),
93
- **{k: v for k, v in event.items() if k not in {"area", "event_type", "workspace_id", "payload"}},
94
- }
95
- self._feed.append(enriched)
96
- if len(self._feed) > _FEED_LIMIT:
97
- self._feed = self._feed[-_FEED_LIMIT:]
98
-
99
- workspace_id = enriched.get("workspace_id")
100
- for sub in list(self._subscribers.values()):
101
- if not sub.accepts(workspace_id):
102
- continue
103
- try:
104
- sub.queue.put_nowait(enriched)
105
- except asyncio.QueueFull:
106
- try:
107
- sub.queue.get_nowait() # drop oldest
108
- sub.queue.put_nowait(enriched)
109
- except Exception:
110
- pass
91
+ with self._lock:
92
+ self._seq += 1
93
+ enriched = {
94
+ "seq": self._seq,
95
+ "received_at": _now(),
96
+ "area": event.get("area", "workspace"),
97
+ "event_type": event.get("event_type", "event"),
98
+ "workspace_id": event.get("workspace_id"),
99
+ "payload": event.get("payload", {}),
100
+ **{k: v for k, v in event.items() if k not in {"area", "event_type", "workspace_id", "payload"}},
101
+ }
102
+ self._feed.append(enriched)
103
+ if len(self._feed) > _FEED_LIMIT:
104
+ self._feed = self._feed[-_FEED_LIMIT:]
105
+
106
+ workspace_id = enriched.get("workspace_id")
107
+ subscribers = [sub for sub in self._subscribers.values() if sub.accepts(workspace_id)]
108
+ for sub in subscribers:
109
+ if sub.loop is not None and sub.loop.is_running():
110
+ sub.loop.call_soon_threadsafe(self._enqueue, sub, enriched)
111
+ else:
112
+ self._enqueue(sub, enriched)
111
113
  return enriched
112
114
 
115
+ @staticmethod
116
+ def _enqueue(sub: _Subscriber, event: Dict[str, Any]) -> None:
117
+ try:
118
+ sub.queue.put_nowait(event)
119
+ except asyncio.QueueFull:
120
+ try:
121
+ sub.queue.get_nowait() # drop oldest
122
+ sub.queue.put_nowait(event)
123
+ except Exception:
124
+ pass
125
+
113
126
  # The store calls ``event_sink(event)`` positionally; expose a stable alias.
114
127
  def __call__(self, event: Dict[str, Any]) -> Dict[str, Any]:
115
128
  return self.publish(event)
@@ -118,11 +131,13 @@ class RealtimeBus:
118
131
 
119
132
  def add_subscriber(self, sub_id: str, *, workspace_scope: Optional[Set[str]] = None, user: Optional[str] = None) -> _Subscriber:
120
133
  sub = _Subscriber(sub_id, workspace_scope, user)
121
- self._subscribers[sub_id] = sub
134
+ with self._lock:
135
+ self._subscribers[sub_id] = sub
122
136
  return sub
123
137
 
124
138
  def remove_subscriber(self, sub_id: str) -> None:
125
- self._subscribers.pop(sub_id, None)
139
+ with self._lock:
140
+ self._subscribers.pop(sub_id, None)
126
141
 
127
142
  async def stream(self, sub: _Subscriber, *, heartbeat: float = 15.0) -> AsyncIterator[str]:
128
143
  """Yield SSE frames for a subscriber until the client disconnects.
@@ -146,7 +161,8 @@ class RealtimeBus:
146
161
  # ── feed + presence ─────────────────────────────────────────────────────
147
162
 
148
163
  def recent(self, *, limit: int = 50, workspace_scope: Optional[Set[str]] = None) -> List[Dict[str, Any]]:
149
- events = self._feed
164
+ with self._lock:
165
+ events = list(self._feed)
150
166
  if workspace_scope is not None:
151
167
  events = [e for e in events if e.get("workspace_id") is None or e.get("workspace_id") in workspace_scope]
152
168
  return list(reversed(events[-max(1, min(limit, _FEED_LIMIT)):]))
@@ -159,32 +175,37 @@ class RealtimeBus:
159
175
  "joined_at": _now(),
160
176
  "last_seen": _now(),
161
177
  }
162
- self._presence[client_id] = record
178
+ with self._lock:
179
+ self._presence[client_id] = record
163
180
  self.publish({"area": "presence", "event_type": "join", "workspace_id": workspace_id, "payload": {"user": user, "client_id": client_id}})
164
181
  return record
165
182
 
166
183
  def heartbeat(self, client_id: str) -> Optional[Dict[str, Any]]:
167
- record = self._presence.get(client_id)
168
- if record:
169
- record["last_seen"] = _now()
170
- return record
184
+ with self._lock:
185
+ record = self._presence.get(client_id)
186
+ if record:
187
+ record["last_seen"] = _now()
188
+ return record
171
189
 
172
190
  def leave(self, client_id: str) -> None:
173
- record = self._presence.pop(client_id, None)
191
+ with self._lock:
192
+ record = self._presence.pop(client_id, None)
174
193
  if record:
175
194
  self.publish({"area": "presence", "event_type": "leave", "workspace_id": record.get("workspace_id"), "payload": {"client_id": client_id}})
176
195
 
177
196
  def presence(self, *, workspace_scope: Optional[Set[str]] = None) -> List[Dict[str, Any]]:
178
- records = list(self._presence.values())
197
+ with self._lock:
198
+ records = list(self._presence.values())
179
199
  if workspace_scope is not None:
180
200
  records = [r for r in records if r.get("workspace_id") is None or r.get("workspace_id") in workspace_scope]
181
201
  return records
182
202
 
183
203
  def stats(self) -> Dict[str, Any]:
184
- return {
185
- "version": REALTIME_VERSION,
186
- "subscribers": len(self._subscribers),
187
- "presence": len(self._presence),
188
- "feed_size": len(self._feed),
189
- "transport": "sse",
190
- }
204
+ with self._lock:
205
+ return {
206
+ "version": REALTIME_VERSION,
207
+ "subscribers": len(self._subscribers),
208
+ "presence": len(self._presence),
209
+ "feed_size": len(self._feed),
210
+ "transport": "sse",
211
+ }
@@ -67,34 +67,60 @@ def persist_sessions(sessions: Dict[str, tuple], data_dir: Optional[Path] = None
67
67
  logging.warning("persist_sessions failed: %s", e)
68
68
 
69
69
 
70
+ def _entry_subject(entry: tuple) -> Optional[str]:
71
+ return entry[0] if entry else None
72
+
73
+
74
+ def _entry_email(entry: tuple) -> Optional[str]:
75
+ if len(entry) >= 3 and entry[2]:
76
+ return entry[2]
77
+ return entry[0] if entry else None
78
+
79
+
80
+ def _entry_created_at(entry: tuple) -> float:
81
+ if len(entry) >= 2:
82
+ return float(entry[1])
83
+ return 0.0
84
+
85
+
70
86
  class SessionStore:
71
87
  def __init__(self, data_dir: Optional[Path] = None):
72
88
  self._data_dir = data_dir
73
89
  self._sessions: Dict[str, tuple] = load_sessions(data_dir)
74
90
 
75
- def create(self, email: str) -> str:
91
+ def create(self, subject: str, *, email: Optional[str] = None) -> str:
76
92
  token = secrets.token_urlsafe(32)
77
93
  with _lock:
78
- self._sessions[_hash_token(token)] = (email, time.time())
94
+ self._sessions[_hash_token(token)] = (subject, time.time(), email or subject)
79
95
  persist_sessions(self._sessions, self._data_dir)
80
96
  return token
81
97
 
82
98
  def get_email(self, token: str) -> Optional[str]:
99
+ entry = self._get_entry(token)
100
+ return _entry_email(entry) if entry else None
101
+
102
+ def get_subject(self, token: str) -> Optional[str]:
103
+ entry = self._get_entry(token)
104
+ return _entry_subject(entry) if entry else None
105
+
106
+ def _get_entry(self, token: str) -> Optional[tuple]:
83
107
  now = time.time()
84
108
  key = _hash_token(token)
85
109
  with _lock:
86
110
  entry = self._sessions.get(key)
87
111
  if entry is None:
88
112
  return None
89
- email, created_at = entry
113
+ created_at = _entry_created_at(entry)
90
114
  if now - created_at > SESSION_TTL:
91
115
  self._sessions.pop(key, None)
92
116
  persist_sessions(self._sessions, self._data_dir)
93
117
  return None
94
118
  if now - created_at > SESSION_REFRESH_THRESHOLD:
95
- self._sessions[key] = (email, now)
119
+ refreshed = (_entry_subject(entry), now, _entry_email(entry))
120
+ self._sessions[key] = refreshed
96
121
  persist_sessions(self._sessions, self._data_dir)
97
- return email
122
+ return refreshed
123
+ return entry
98
124
 
99
125
  def invalidate(self, token: str) -> None:
100
126
  with _lock:
@@ -0,0 +1,147 @@
1
+ """User identity store and v4 UUID migration helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import sqlite3
8
+ import uuid
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Any, Dict, Optional
12
+
13
+
14
+ USER_NAMESPACE = uuid.UUID("5d6d4480-cf79-49c3-a6d0-4c6eec3224d6")
15
+
16
+
17
+ def _now() -> str:
18
+ return datetime.now().isoformat(timespec="seconds")
19
+
20
+
21
+ def _atomic_write_json(path: Path, data: Dict[str, Any]) -> None:
22
+ path.parent.mkdir(parents=True, exist_ok=True)
23
+ tmp = path.with_suffix(path.suffix + ".tmp")
24
+ tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
25
+ tmp.replace(path)
26
+
27
+
28
+ def normalize_email(email: str) -> str:
29
+ return str(email or "").strip().lower()
30
+
31
+
32
+ def stable_user_id(email: str) -> str:
33
+ return f"user:{uuid.uuid5(USER_NAMESPACE, normalize_email(email))}"
34
+
35
+
36
+ def ensure_user_identity(email: str, user: Dict[str, Any]) -> bool:
37
+ changed = False
38
+ normalized = normalize_email(email or user.get("email") or "")
39
+ if not user.get("id"):
40
+ user["id"] = stable_user_id(normalized)
41
+ changed = True
42
+ if user.get("email") != normalized:
43
+ user["email"] = normalized
44
+ changed = True
45
+ return changed
46
+
47
+
48
+ def migrate_users(users: Dict[str, Any]) -> tuple[Dict[str, Any], Dict[str, str], bool]:
49
+ migrated: Dict[str, Any] = {}
50
+ email_to_id: Dict[str, str] = {}
51
+ changed = False
52
+ for raw_email, raw_user in (users or {}).items():
53
+ if not isinstance(raw_user, dict):
54
+ continue
55
+ email = normalize_email(raw_user.get("email") or raw_email)
56
+ user = dict(raw_user)
57
+ changed = ensure_user_identity(email, user) or changed
58
+ if raw_email != email:
59
+ changed = True
60
+ if email in migrated:
61
+ existing = migrated[email]
62
+ merged = {**existing, **user}
63
+ merged["id"] = existing.get("id") or user.get("id") or stable_user_id(email)
64
+ if isinstance(existing.get("api_keys"), dict) or isinstance(user.get("api_keys"), dict):
65
+ merged["api_keys"] = {**(existing.get("api_keys") or {}), **(user.get("api_keys") or {})}
66
+ user = merged
67
+ changed = True
68
+ migrated[email] = user
69
+ email_to_id[email] = user["id"]
70
+ return migrated, email_to_id, changed
71
+
72
+
73
+ def load_users_file(path: Path) -> Dict[str, Any]:
74
+ if not path.exists():
75
+ return {}
76
+ try:
77
+ loaded = json.loads(path.read_text(encoding="utf-8"))
78
+ if not isinstance(loaded, dict):
79
+ loaded = {}
80
+ except Exception:
81
+ loaded = {}
82
+ migrated, _, changed = migrate_users(loaded)
83
+ if changed:
84
+ backup = path.with_name(f"{path.name}.pre-user-uuid.{_now().replace(':', '-')}.json")
85
+ try:
86
+ shutil.copy2(path, backup)
87
+ except Exception:
88
+ pass
89
+ _atomic_write_json(path, migrated)
90
+ return migrated
91
+
92
+
93
+ def save_users_file(path: Path, users: Dict[str, Any]) -> None:
94
+ migrated, _, _ = migrate_users(users)
95
+ _atomic_write_json(path, migrated)
96
+
97
+
98
+ def user_id_for_email(users: Dict[str, Any], email: Optional[str]) -> Optional[str]:
99
+ if not email:
100
+ return None
101
+ if str(email).startswith("user:"):
102
+ return str(email)
103
+ normalized = normalize_email(email)
104
+ user = (users or {}).get(normalized)
105
+ if isinstance(user, dict):
106
+ return user.get("id") or stable_user_id(normalized)
107
+ return stable_user_id(normalized)
108
+
109
+
110
+ def email_for_user_id(users: Dict[str, Any], user_id: Optional[str]) -> Optional[str]:
111
+ if not user_id:
112
+ return None
113
+ for email, user in (users or {}).items():
114
+ if isinstance(user, dict) and user.get("id") == user_id:
115
+ return email
116
+ return None
117
+
118
+
119
+ def migrate_knowledge_graph_identity(db_path: Path, email_to_id: Dict[str, str]) -> int:
120
+ """Rewrite KG owner/creator identity columns from email to stable UUIDs."""
121
+ if not db_path.exists() or not email_to_id:
122
+ return 0
123
+ changed = 0
124
+ with sqlite3.connect(db_path) as conn:
125
+ tables = {
126
+ row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
127
+ }
128
+ for email, user_id in email_to_id.items():
129
+ normalized = normalize_email(email)
130
+ if "nodes_v2" in tables:
131
+ cur = conn.execute("UPDATE nodes_v2 SET owner_id=? WHERE LOWER(owner_id)=?", (user_id, normalized))
132
+ changed += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
133
+ if "edges_v2" in tables:
134
+ cur = conn.execute("UPDATE edges_v2 SET created_by=? WHERE LOWER(created_by)=?", (user_id, normalized))
135
+ changed += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
136
+ if "ingestion_provenance" in tables:
137
+ cur = conn.execute("UPDATE ingestion_provenance SET owner=? WHERE LOWER(owner)=?", (user_id, normalized))
138
+ changed += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
139
+ if changed:
140
+ conn.execute(
141
+ "CREATE TABLE IF NOT EXISTS kg_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
142
+ )
143
+ conn.execute(
144
+ "INSERT OR REPLACE INTO kg_meta(key, value) VALUES('identity_uuid_migrated_at', ?)",
145
+ (_now(),),
146
+ )
147
+ return changed