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.
- package/README.md +42 -33
- package/desktop/electron/main.cjs +44 -0
- package/docs/CHANGELOG.md +106 -0
- package/docs/REALTIME_COLLABORATION.md +3 -3
- package/docs/V3_FRONTEND.md +9 -8
- package/docs/V4_1_FRONTEND_ARCHITECTURE_REVIEW.md +65 -0
- package/docs/V4_1_FRONTEND_MIGRATION_REPORT.md +70 -0
- package/docs/V4_1_VALIDATION_REPORT.md +47 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +95 -45
- package/docs/kg-schema.md +6 -2
- package/docs/spec-vs-impl.md +10 -10
- package/frontend/index.html +24 -0
- package/frontend/openapi.json +14190 -0
- package/frontend/src/App.tsx +184 -0
- package/frontend/src/api/client.ts +317 -0
- package/frontend/src/api/openapi.ts +16637 -0
- package/frontend/src/components/primitives.tsx +204 -0
- package/frontend/src/components/ui/badge.tsx +27 -0
- package/frontend/src/components/ui/button.tsx +37 -0
- package/frontend/src/components/ui/card.tsx +22 -0
- package/frontend/src/components/ui/input.tsx +16 -0
- package/frontend/src/components/ui/textarea.tsx +16 -0
- package/frontend/src/lib/utils.ts +33 -0
- package/frontend/src/main.tsx +23 -0
- package/frontend/src/pages/Act.tsx +245 -0
- package/frontend/src/pages/Ask.tsx +200 -0
- package/frontend/src/pages/Brain.tsx +267 -0
- package/frontend/src/pages/Capture.tsx +158 -0
- package/frontend/src/pages/Library.tsx +187 -0
- package/frontend/src/pages/System.tsx +344 -0
- package/frontend/src/routes.ts +85 -0
- package/frontend/src/store/appStore.ts +54 -0
- package/frontend/src/styles.css +107 -0
- package/kg_schema.py +2 -603
- package/knowledge_graph.py +37 -4958
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +15 -16
- package/latticeai/api/agents.py +13 -6
- package/latticeai/api/auth.py +19 -11
- package/latticeai/api/invitations.py +100 -0
- package/latticeai/api/knowledge_graph.py +4 -11
- package/latticeai/api/plugins.py +3 -6
- package/latticeai/api/realtime.py +4 -7
- package/latticeai/api/setup.py +5 -4
- package/latticeai/api/static_routes.py +13 -16
- package/latticeai/api/ui_redirects.py +26 -0
- package/latticeai/api/workflow_designer.py +39 -6
- package/latticeai/api/workspace.py +24 -10
- package/latticeai/app_factory.py +88 -17
- package/latticeai/brain/_kg_common.py +1123 -0
- package/latticeai/brain/discovery.py +1455 -0
- package/latticeai/brain/documents.py +218 -0
- package/latticeai/brain/ingest.py +644 -0
- package/latticeai/brain/projection.py +561 -0
- package/latticeai/brain/provenance.py +401 -0
- package/latticeai/brain/retrieval.py +1316 -0
- package/latticeai/brain/schema.py +640 -0
- package/latticeai/brain/store.py +216 -0
- package/latticeai/brain/write_master.py +225 -0
- package/latticeai/core/invitations.py +131 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/multi_agent.py +1 -1
- package/latticeai/core/policy.py +54 -0
- package/latticeai/core/realtime.py +65 -44
- package/latticeai/core/sessions.py +31 -5
- package/latticeai/core/users.py +147 -0
- package/latticeai/core/workspace_os.py +420 -20
- package/latticeai/services/agent_runtime.py +242 -4
- package/latticeai/services/run_executor.py +328 -0
- package/latticeai/services/workspace_service.py +27 -19
- package/package.json +54 -27
- package/scripts/build_frontend_assets.mjs +38 -0
- package/scripts/bump_version.py +1 -1
- package/scripts/export_openapi.py +31 -0
- package/scripts/lint_frontend.mjs +86 -0
- package/scripts/run_python.mjs +47 -0
- package/src-tauri/Cargo.lock +4833 -0
- package/src-tauri/Cargo.toml +19 -0
- package/src-tauri/build.rs +3 -0
- package/src-tauri/capabilities/default.json +7 -0
- package/src-tauri/src/main.rs +78 -0
- package/src-tauri/tauri.conf.json +36 -0
- package/static/app/asset-manifest.json +32 -0
- package/static/app/assets/core-CwxXejkd.js +2 -0
- package/static/app/assets/core-CwxXejkd.js.map +1 -0
- package/static/app/assets/index-CJRAzNnf.js +333 -0
- package/static/app/assets/index-CJRAzNnf.js.map +1 -0
- package/static/app/assets/index-CSwBBgf4.css +2 -0
- package/static/app/index.html +25 -0
- package/static/manifest.json +2 -2
- package/static/sw.js +4 -4
- package/scripts/build_v3_assets.mjs +0 -170
- package/scripts/lint_v3.mjs +0 -97
- package/static/account.html +0 -113
- package/static/activity.html +0 -73
- package/static/admin.html +0 -486
- package/static/agents.html +0 -139
- package/static/chat.html +0 -841
- package/static/css/reference/account.css +0 -439
- package/static/css/reference/admin.css +0 -610
- package/static/css/reference/base.css +0 -1661
- package/static/css/reference/chat.css +0 -4623
- package/static/css/reference/graph.css +0 -1016
- package/static/css/responsive.css +0 -861
- package/static/graph.html +0 -122
- package/static/platform.css +0 -104
- package/static/plugins.html +0 -136
- package/static/scripts/account.js +0 -238
- package/static/scripts/admin.js +0 -1614
- package/static/scripts/chat.js +0 -5081
- package/static/scripts/graph.js +0 -1804
- package/static/scripts/platform.js +0 -64
- package/static/scripts/ux.js +0 -167
- package/static/scripts/workspace.js +0 -948
- package/static/v3/asset-manifest.json +0 -56
- package/static/v3/css/lattice.base.49deefb5.css +0 -128
- package/static/v3/css/lattice.base.css +0 -128
- package/static/v3/css/lattice.components.cde18231.css +0 -472
- package/static/v3/css/lattice.components.css +0 -472
- package/static/v3/css/lattice.shell.29d36d85.css +0 -452
- package/static/v3/css/lattice.shell.css +0 -452
- package/static/v3/css/lattice.tokens.304cbc40.css +0 -135
- package/static/v3/css/lattice.tokens.css +0 -135
- package/static/v3/css/lattice.views.0a18b6c5.css +0 -360
- package/static/v3/css/lattice.views.css +0 -360
- package/static/v3/index.html +0 -68
- package/static/v3/js/app.356e6452.js +0 -26
- package/static/v3/js/app.js +0 -26
- package/static/v3/js/core/api.7a308b89.js +0 -568
- package/static/v3/js/core/api.js +0 -568
- package/static/v3/js/core/components.f25b3b93.js +0 -230
- package/static/v3/js/core/components.js +0 -230
- package/static/v3/js/core/dom.a2773eb0.js +0 -148
- package/static/v3/js/core/dom.js +0 -148
- package/static/v3/js/core/router.584570f2.js +0 -37
- package/static/v3/js/core/router.js +0 -37
- package/static/v3/js/core/routes.7222343d.js +0 -93
- package/static/v3/js/core/routes.js +0 -93
- package/static/v3/js/core/shell.a1657f20.js +0 -391
- package/static/v3/js/core/shell.js +0 -391
- package/static/v3/js/core/store.204a08b2.js +0 -113
- package/static/v3/js/core/store.js +0 -113
- package/static/v3/js/views/admin-audit.660a1fb1.js +0 -185
- package/static/v3/js/views/admin-audit.js +0 -185
- package/static/v3/js/views/admin-permissions.a7ae5f09.js +0 -177
- package/static/v3/js/views/admin-permissions.js +0 -177
- package/static/v3/js/views/admin-policies.3658fd86.js +0 -102
- package/static/v3/js/views/admin-policies.js +0 -102
- package/static/v3/js/views/admin-private-vpc.7d342d36.js +0 -135
- package/static/v3/js/views/admin-private-vpc.js +0 -135
- package/static/v3/js/views/admin-security.07c66b72.js +0 -180
- package/static/v3/js/views/admin-security.js +0 -180
- package/static/v3/js/views/admin-users.03bac88c.js +0 -168
- package/static/v3/js/views/admin-users.js +0 -168
- package/static/v3/js/views/agents.014d0b74.js +0 -541
- package/static/v3/js/views/agents.js +0 -541
- package/static/v3/js/views/chat.e6dd7dd0.js +0 -601
- package/static/v3/js/views/chat.js +0 -601
- package/static/v3/js/views/files.adad14c1.js +0 -365
- package/static/v3/js/views/files.js +0 -365
- package/static/v3/js/views/graph-canvas.17c15d65.js +0 -509
- package/static/v3/js/views/graph-canvas.js +0 -509
- package/static/v3/js/views/home.24f8b8ae.js +0 -200
- package/static/v3/js/views/home.js +0 -200
- package/static/v3/js/views/hooks.37895880.js +0 -220
- package/static/v3/js/views/hooks.js +0 -220
- package/static/v3/js/views/hybrid-search.2fb63ed9.js +0 -194
- package/static/v3/js/views/hybrid-search.js +0 -194
- package/static/v3/js/views/knowledge-graph.5e40cbeb.js +0 -509
- package/static/v3/js/views/knowledge-graph.js +0 -509
- package/static/v3/js/views/marketplace.ab0583d4.js +0 -141
- package/static/v3/js/views/marketplace.js +0 -141
- package/static/v3/js/views/mcp.99b5c6a7.js +0 -114
- package/static/v3/js/views/mcp.js +0 -114
- package/static/v3/js/views/memory.4ebdf474.js +0 -147
- package/static/v3/js/views/memory.js +0 -147
- package/static/v3/js/views/models.a1ffa147.js +0 -256
- package/static/v3/js/views/models.js +0 -256
- package/static/v3/js/views/my-computer.d9d9ae1c.js +0 -463
- package/static/v3/js/views/my-computer.js +0 -463
- package/static/v3/js/views/pipeline.c522f1ce.js +0 -157
- package/static/v3/js/views/pipeline.js +0 -157
- package/static/v3/js/views/planning.9ac3e313.js +0 -153
- package/static/v3/js/views/planning.js +0 -153
- package/static/v3/js/views/settings.8631fa5e.js +0 -318
- package/static/v3/js/views/settings.js +0 -318
- package/static/v3/js/views/skills.c6c2f965.js +0 -109
- package/static/v3/js/views/skills.js +0 -109
- package/static/v3/js/views/tools.e4f11276.js +0 -108
- package/static/v3/js/views/tools.js +0 -108
- package/static/v3/js/views/workflows.26c57290.js +0 -128
- package/static/v3/js/views/workflows.js +0 -128
- package/static/workflows.html +0 -146
- package/static/workspace.css +0 -1121
- 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.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
self._feed
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
sub.
|
|
105
|
-
|
|
106
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
record
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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)] = (
|
|
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
|
-
|
|
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
|
-
|
|
119
|
+
refreshed = (_entry_subject(entry), now, _entry_email(entry))
|
|
120
|
+
self._sessions[key] = refreshed
|
|
96
121
|
persist_sessions(self._sessions, self._data_dir)
|
|
97
|
-
|
|
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
|