ltcai 3.6.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/README.md +39 -31
  2. package/docs/CHANGELOG.md +64 -0
  3. package/docs/REALTIME_COLLABORATION.md +3 -3
  4. package/docs/V3_FRONTEND.md +9 -8
  5. package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
  6. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +552 -0
  7. package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
  8. package/docs/kg-schema.md +51 -53
  9. package/docs/spec-vs-impl.md +10 -10
  10. package/kg_schema.py +2 -520
  11. package/knowledge_graph.py +37 -4629
  12. package/knowledge_graph_api.py +11 -127
  13. package/latticeai/__init__.py +1 -1
  14. package/latticeai/api/admin.py +16 -17
  15. package/latticeai/api/agents.py +20 -7
  16. package/latticeai/api/auth.py +46 -15
  17. package/latticeai/api/chat.py +112 -76
  18. package/latticeai/api/health.py +1 -1
  19. package/latticeai/api/hooks.py +1 -1
  20. package/latticeai/api/invitations.py +100 -0
  21. package/latticeai/api/knowledge_graph.py +139 -0
  22. package/latticeai/api/local_files.py +1 -1
  23. package/latticeai/api/mcp.py +23 -11
  24. package/latticeai/api/memory.py +1 -1
  25. package/latticeai/api/models.py +1 -1
  26. package/latticeai/api/network.py +81 -0
  27. package/latticeai/api/plugins.py +3 -6
  28. package/latticeai/api/realtime.py +5 -8
  29. package/latticeai/api/search.py +26 -2
  30. package/latticeai/api/security_dashboard.py +2 -3
  31. package/latticeai/api/setup.py +2 -2
  32. package/latticeai/api/static_routes.py +11 -16
  33. package/latticeai/api/tools.py +3 -0
  34. package/latticeai/api/ui_redirects.py +26 -0
  35. package/latticeai/api/workflow_designer.py +85 -6
  36. package/latticeai/api/workspace.py +93 -57
  37. package/latticeai/app_factory.py +1781 -0
  38. package/latticeai/brain/__init__.py +18 -0
  39. package/latticeai/brain/_kg_common.py +1123 -0
  40. package/latticeai/brain/context.py +213 -0
  41. package/latticeai/brain/conversations.py +236 -0
  42. package/latticeai/brain/discovery.py +1455 -0
  43. package/latticeai/brain/documents.py +218 -0
  44. package/latticeai/brain/identity.py +175 -0
  45. package/latticeai/brain/ingest.py +644 -0
  46. package/latticeai/brain/memory.py +102 -0
  47. package/latticeai/brain/network.py +205 -0
  48. package/latticeai/brain/projection.py +561 -0
  49. package/latticeai/brain/provenance.py +401 -0
  50. package/latticeai/brain/retrieval.py +1316 -0
  51. package/latticeai/brain/schema.py +640 -0
  52. package/latticeai/brain/store.py +216 -0
  53. package/latticeai/brain/write_master.py +225 -0
  54. package/latticeai/core/agent.py +31 -7
  55. package/latticeai/core/audit.py +0 -7
  56. package/latticeai/core/config.py +1 -1
  57. package/latticeai/core/context_builder.py +1 -2
  58. package/latticeai/core/enterprise.py +1 -1
  59. package/latticeai/core/graph_curator.py +2 -2
  60. package/latticeai/core/invitations.py +131 -0
  61. package/latticeai/core/marketplace.py +1 -1
  62. package/latticeai/core/mcp_registry.py +791 -0
  63. package/latticeai/core/model_compat.py +1 -1
  64. package/latticeai/core/model_resolution.py +0 -1
  65. package/latticeai/core/multi_agent.py +238 -4
  66. package/latticeai/core/policy.py +54 -0
  67. package/latticeai/core/realtime.py +65 -44
  68. package/latticeai/core/security.py +1 -1
  69. package/latticeai/core/sessions.py +66 -10
  70. package/latticeai/core/users.py +147 -0
  71. package/latticeai/core/workflow_engine.py +114 -2
  72. package/latticeai/core/workspace_os.py +477 -29
  73. package/latticeai/models/__init__.py +7 -0
  74. package/latticeai/models/router.py +779 -0
  75. package/latticeai/server_app.py +29 -1536
  76. package/latticeai/services/agent_runtime.py +243 -4
  77. package/latticeai/services/app_context.py +75 -14
  78. package/latticeai/services/ingestion.py +47 -0
  79. package/latticeai/services/kg_portability.py +33 -3
  80. package/latticeai/services/memory_service.py +39 -11
  81. package/latticeai/services/model_runtime.py +2 -5
  82. package/latticeai/services/platform_runtime.py +100 -23
  83. package/latticeai/services/run_executor.py +328 -0
  84. package/latticeai/services/search_service.py +17 -8
  85. package/latticeai/services/tool_dispatch.py +12 -2
  86. package/latticeai/services/triggers.py +241 -0
  87. package/latticeai/services/upload_service.py +37 -12
  88. package/latticeai/services/workspace_service.py +55 -16
  89. package/llm_router.py +29 -772
  90. package/ltcai_cli.py +1 -2
  91. package/mcp_registry.py +25 -788
  92. package/p_reinforce.py +124 -14
  93. package/package.json +10 -20
  94. package/scripts/bump_version.py +99 -0
  95. package/scripts/generate_diagrams.py +0 -1
  96. package/scripts/lint_v3.mjs +105 -18
  97. package/scripts/validate_release_artifacts.py +0 -1
  98. package/scripts/wheel_smoke.py +142 -0
  99. package/server.py +11 -7
  100. package/setup_wizard.py +1142 -0
  101. package/static/sw.js +81 -52
  102. package/static/v3/asset-manifest.json +33 -25
  103. package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
  104. package/static/v3/css/lattice.base.css +1 -1
  105. package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
  106. package/static/v3/css/lattice.components.css +1 -1
  107. package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
  108. package/static/v3/css/lattice.shell.css +1 -1
  109. package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
  110. package/static/v3/css/lattice.tokens.css +3 -0
  111. package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
  112. package/static/v3/css/lattice.views.css +2 -2
  113. package/static/v3/index.html +3 -4
  114. package/static/v3/js/{app.c541f955.js → app.c5c80c46.js} +1 -1
  115. package/static/v3/js/core/{api.33d6320e.js → api.ba0fbf14.js} +58 -1
  116. package/static/v3/js/core/api.js +57 -0
  117. package/static/v3/js/core/i18n.880e1fec.js +575 -0
  118. package/static/v3/js/core/i18n.js +575 -0
  119. package/static/v3/js/core/routes.37522821.js +101 -0
  120. package/static/v3/js/core/routes.js +71 -63
  121. package/static/v3/js/core/{shell.8c163e0e.js → shell.e3f6bbfa.js} +68 -39
  122. package/static/v3/js/core/shell.js +66 -37
  123. package/static/v3/js/core/{store.34ebd5e6.js → store.7b2aa044.js} +11 -1
  124. package/static/v3/js/core/store.js +11 -1
  125. package/static/v3/js/views/account.eff40715.js +143 -0
  126. package/static/v3/js/views/account.js +143 -0
  127. package/static/v3/js/views/activity.0d271ef9.js +67 -0
  128. package/static/v3/js/views/activity.js +67 -0
  129. package/static/v3/js/views/{admin-users.03bac88c.js → admin-users.f7ac7b43.js} +4 -6
  130. package/static/v3/js/views/admin-users.js +4 -6
  131. package/static/v3/js/views/{agents.014d0b74.js → agents.17c5288d.js} +35 -12
  132. package/static/v3/js/views/agents.js +35 -12
  133. package/static/v3/js/views/{chat.e6dd7dd0.js → chat.e250e2cc.js} +23 -0
  134. package/static/v3/js/views/chat.js +23 -0
  135. package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
  136. package/static/v3/js/views/graph-canvas.js +509 -0
  137. package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
  138. package/static/v3/js/views/hybrid-search.js +1 -2
  139. package/static/v3/js/views/{knowledge-graph.a96040a5.js → knowledge-graph.4d09c537.js} +60 -44
  140. package/static/v3/js/views/knowledge-graph.js +60 -44
  141. package/static/v3/js/views/network.52a4f181.js +97 -0
  142. package/static/v3/js/views/network.js +97 -0
  143. package/static/v3/js/views/{planning.9ac3e313.js → planning.4876fd77.js} +26 -5
  144. package/static/v3/js/views/planning.js +26 -5
  145. package/static/v3/js/views/runs.b63b2afa.js +144 -0
  146. package/static/v3/js/views/runs.js +144 -0
  147. package/static/v3/js/views/{settings.8631fa5e.js → settings.b7140634.js} +7 -8
  148. package/static/v3/js/views/settings.js +7 -8
  149. package/static/v3/js/views/snapshots.6f5db095.js +135 -0
  150. package/static/v3/js/views/snapshots.js +135 -0
  151. package/static/v3/js/views/{workflows.26c57290.js → workflows.7752225a.js} +87 -2
  152. package/static/v3/js/views/workflows.js +87 -2
  153. package/static/v3/js/views/workspace-admin.c466029b.js +156 -0
  154. package/static/v3/js/views/workspace-admin.js +156 -0
  155. package/static/vendor/chart.umd.min.js +20 -0
  156. package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
  157. package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
  158. package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
  159. package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
  160. package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
  161. package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
  162. package/static/vendor/fonts/inter.css +44 -0
  163. package/static/vendor/icons/tabler-icons.min.css +4 -0
  164. package/static/vendor/icons/tabler-icons.woff2 +0 -0
  165. package/static/vendor/marked.min.js +69 -0
  166. package/telegram_bot.py +1 -2
  167. package/tools/commands.py +4 -2
  168. package/tools/computer.py +1 -1
  169. package/tools/documents.py +1 -3
  170. package/tools/filesystem.py +0 -4
  171. package/tools/knowledge.py +1 -3
  172. package/tools/network.py +1 -3
  173. package/codex_telegram_bot.py +0 -195
  174. package/docs/assets/v3.4.0/agent-run.png +0 -0
  175. package/docs/assets/v3.4.0/agents.png +0 -0
  176. package/docs/assets/v3.4.0/before/chat-before.png +0 -0
  177. package/docs/assets/v3.4.0/before/files-before.png +0 -0
  178. package/docs/assets/v3.4.0/chat.png +0 -0
  179. package/docs/assets/v3.4.0/connect-folder.png +0 -0
  180. package/docs/assets/v3.4.0/files.png +0 -0
  181. package/docs/assets/v3.4.0/home.png +0 -0
  182. package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
  183. package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
  184. package/docs/assets/v3.4.0/local-agent.png +0 -0
  185. package/docs/assets/v3.4.0/memory.png +0 -0
  186. package/docs/assets/v3.4.0/settings.png +0 -0
  187. package/docs/assets/v3.4.0/vision-input.png +0 -0
  188. package/docs/assets/v3.4.0/workflows.png +0 -0
  189. package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
  190. package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
  191. package/docs/assets/v3.4.1/local-agent.png +0 -0
  192. package/docs/images/admin-dashboard.png +0 -0
  193. package/docs/images/architecture.png +0 -0
  194. package/docs/images/enterprise.png +0 -0
  195. package/docs/images/graph.png +0 -0
  196. package/docs/images/hero.gif +0 -0
  197. package/docs/images/knowledge-graph.png +0 -0
  198. package/docs/images/lattice-ai-demo.gif +0 -0
  199. package/docs/images/lattice-ai-hero.png +0 -0
  200. package/docs/images/logo.svg +0 -33
  201. package/docs/images/mobile-responsive.png +0 -0
  202. package/docs/images/model-recommendation.png +0 -0
  203. package/docs/images/onboarding.png +0 -0
  204. package/docs/images/organization.png +0 -0
  205. package/docs/images/pipeline.png +0 -0
  206. package/docs/images/screenshot-admin.png +0 -0
  207. package/docs/images/screenshot-chat.png +0 -0
  208. package/docs/images/screenshot-graph.png +0 -0
  209. package/docs/images/skills.png +0 -0
  210. package/docs/images/workspace-dark.png +0 -0
  211. package/docs/images/workspace-light.png +0 -0
  212. package/docs/images/workspace.png +0 -0
  213. package/requirements.txt +0 -16
  214. package/static/account.html +0 -115
  215. package/static/activity.html +0 -73
  216. package/static/admin.html +0 -488
  217. package/static/agents.html +0 -139
  218. package/static/chat.html +0 -844
  219. package/static/css/reference/account.css +0 -439
  220. package/static/css/reference/admin.css +0 -610
  221. package/static/css/reference/base.css +0 -1661
  222. package/static/css/reference/chat.css +0 -4623
  223. package/static/css/reference/graph.css +0 -1016
  224. package/static/css/responsive.css +0 -861
  225. package/static/graph.html +0 -124
  226. package/static/platform.css +0 -104
  227. package/static/plugins.html +0 -136
  228. package/static/scripts/account.js +0 -238
  229. package/static/scripts/admin.js +0 -1614
  230. package/static/scripts/chat.js +0 -5081
  231. package/static/scripts/graph.js +0 -1804
  232. package/static/scripts/platform.js +0 -64
  233. package/static/scripts/ux.js +0 -167
  234. package/static/scripts/workspace.js +0 -948
  235. package/static/v3/js/core/routes.2ce3815a.js +0 -93
  236. package/static/workflows.html +0 -146
  237. package/static/workspace.css +0 -1121
  238. package/static/workspace.html +0 -357
@@ -0,0 +1,241 @@
1
+ """Trigger system (T7d) — workflows fire beyond 'manual'.
2
+
3
+ Two real trigger types:
4
+
5
+ * **interval** — a supervised scheduler loop fires the workflow every
6
+ ``interval_seconds``. Firings missed while the server was down are
7
+ SKIPPED with a recorded skip event (design-review amendment: no silent
8
+ gaps, no thundering catch-up).
9
+ * **brain_event** — the killer Digital Brain feature: "when new knowledge
10
+ enters the brain, run this workflow". Wired through the existing hooks
11
+ bus (the ingestion pipeline fires ``post_tool`` on ``kg_ingest.<source>``);
12
+ an optional ``source_type`` filter narrows it.
13
+
14
+ Trigger-fired runs carry provenance: their inputs include ``__trigger__``
15
+ describing what fired them, persisted in the run record like any other
16
+ input.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ import threading
24
+ import time
25
+ from pathlib import Path
26
+ from typing import Any, Callable, Dict, List, Optional
27
+
28
+ DEFAULT_TICK_SECONDS = 5.0
29
+ MIN_INTERVAL_SECONDS = 60
30
+
31
+ TRIGGER_HOOK_NAME = "brain-event-triggers"
32
+
33
+
34
+ class TriggerService:
35
+ """Scans workflow definitions for non-manual triggers and fires them."""
36
+
37
+ def __init__(
38
+ self,
39
+ *,
40
+ store: Any,
41
+ run_workflow: Callable[[str, Dict[str, Any]], Dict[str, Any]],
42
+ data_dir: Path,
43
+ clock: Callable[[], float] = time.time,
44
+ tick_seconds: float = DEFAULT_TICK_SECONDS,
45
+ ) -> None:
46
+ self._store = store
47
+ self._run_workflow = run_workflow
48
+ self._state_file = Path(data_dir) / "triggers_state.json"
49
+ self._clock = clock
50
+ self._tick = float(tick_seconds)
51
+ self._stop_event = threading.Event()
52
+ self._thread: Optional[threading.Thread] = None
53
+ self._lock = threading.Lock()
54
+
55
+ # ── durable state ──────────────────────────────────────────────────────
56
+ def _load_state(self) -> Dict[str, Any]:
57
+ if not self._state_file.exists():
58
+ return {}
59
+ try:
60
+ return json.loads(self._state_file.read_text(encoding="utf-8"))
61
+ except Exception:
62
+ return {}
63
+
64
+ def _save_state(self, state: Dict[str, Any]) -> None:
65
+ self._state_file.parent.mkdir(parents=True, exist_ok=True)
66
+ tmp = self._state_file.with_suffix(".tmp")
67
+ tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
68
+ tmp.replace(self._state_file)
69
+
70
+ def _record_event(self, state: Dict[str, Any], workflow_id: str, event: Dict[str, Any]) -> None:
71
+ entry = state.setdefault(workflow_id, {})
72
+ events = entry.setdefault("events", [])
73
+ events.append({**event, "at": self._clock()})
74
+ entry["events"] = events[-50:]
75
+
76
+ # ── definition scanning ────────────────────────────────────────────────
77
+ def _triggered_workflows(self) -> List[Dict[str, Any]]:
78
+ found = []
79
+ try:
80
+ workflows = list(self._store.load_state().get("workflows") or [])
81
+ except Exception:
82
+ return []
83
+ for wf in workflows:
84
+ for node in wf.get("nodes") or []:
85
+ if node.get("type") != "trigger":
86
+ continue
87
+ cfg = node.get("config") or {}
88
+ kind = str(cfg.get("trigger") or "manual")
89
+ if kind in ("interval", "brain_event"):
90
+ found.append({"workflow": wf, "node": node, "kind": kind, "config": cfg})
91
+ return found
92
+
93
+ def describe(self) -> Dict[str, Any]:
94
+ """Honest status surface: what is armed, when it last fired/skipped."""
95
+ state = self._load_state()
96
+ armed = []
97
+ for item in self._triggered_workflows():
98
+ wf_id = item["workflow"].get("id")
99
+ armed.append({
100
+ "workflow_id": wf_id,
101
+ "name": item["workflow"].get("name"),
102
+ "kind": item["kind"],
103
+ "config": {k: v for k, v in item["config"].items() if k != "trigger"},
104
+ "last_fired_at": (state.get(wf_id) or {}).get("last_fired_at"),
105
+ "recent_events": (state.get(wf_id) or {}).get("events", [])[-5:],
106
+ })
107
+ return {
108
+ "running": bool(self._thread and self._thread.is_alive()),
109
+ "tick_seconds": self._tick,
110
+ "armed": armed,
111
+ }
112
+
113
+ # ── interval scheduling ────────────────────────────────────────────────
114
+ def reconcile_missed(self) -> int:
115
+ """Startup pass: record (never replay) firings missed while down."""
116
+ now = self._clock()
117
+ skipped = 0
118
+ with self._lock:
119
+ state = self._load_state()
120
+ for item in self._triggered_workflows():
121
+ if item["kind"] != "interval":
122
+ continue
123
+ wf_id = item["workflow"].get("id")
124
+ interval = max(MIN_INTERVAL_SECONDS, int(item["config"].get("interval_seconds") or 0))
125
+ entry = state.setdefault(wf_id, {})
126
+ last = entry.get("last_fired_at")
127
+ if last is not None and now - float(last) > interval:
128
+ missed = int((now - float(last)) // interval)
129
+ self._record_event(state, wf_id, {
130
+ "type": "skipped",
131
+ "reason": f"{missed} interval firing(s) missed while the server was down",
132
+ })
133
+ skipped += missed
134
+ # Reset the cadence from now — no catch-up storm.
135
+ entry["last_fired_at"] = now if last is not None else entry.get("last_fired_at")
136
+ self._save_state(state)
137
+ return skipped
138
+
139
+ def tick_intervals(self) -> int:
140
+ """One scheduler pass; returns how many workflows fired."""
141
+ now = self._clock()
142
+ fired = 0
143
+ with self._lock:
144
+ state = self._load_state()
145
+ for item in self._triggered_workflows():
146
+ if item["kind"] != "interval":
147
+ continue
148
+ wf_id = item["workflow"].get("id")
149
+ interval = max(MIN_INTERVAL_SECONDS, int(item["config"].get("interval_seconds") or 0))
150
+ entry = state.setdefault(wf_id, {})
151
+ last = entry.get("last_fired_at")
152
+ if last is None:
153
+ # First sighting arms the schedule; it fires one interval later.
154
+ entry["last_fired_at"] = now
155
+ continue
156
+ if now - float(last) < interval:
157
+ continue
158
+ entry["last_fired_at"] = now
159
+ self._record_event(state, wf_id, {"type": "fired", "trigger": "interval"})
160
+ fired += 1
161
+ self._fire(wf_id, {
162
+ "type": "interval",
163
+ "interval_seconds": interval,
164
+ "fired_at": now,
165
+ })
166
+ self._save_state(state)
167
+ return fired
168
+
169
+ # ── brain events ───────────────────────────────────────────────────────
170
+ def on_brain_event(self, event: str, payload: Optional[Dict[str, Any]] = None) -> int:
171
+ """Fire workflows whose brain_event trigger matches this ingestion."""
172
+ payload = payload or {}
173
+ source_type = str(payload.get("source_type") or event.split(".", 1)[-1] or "")
174
+ fired = 0
175
+ with self._lock:
176
+ state = self._load_state()
177
+ for item in self._triggered_workflows():
178
+ if item["kind"] != "brain_event":
179
+ continue
180
+ wanted = str(item["config"].get("source_type") or "").strip()
181
+ if wanted and wanted != source_type:
182
+ continue
183
+ wf_id = item["workflow"].get("id")
184
+ state.setdefault(wf_id, {})["last_fired_at"] = self._clock()
185
+ self._record_event(state, wf_id, {
186
+ "type": "fired", "trigger": "brain_event", "source_type": source_type,
187
+ })
188
+ fired += 1
189
+ self._fire(wf_id, {
190
+ "type": "brain_event",
191
+ "event": event,
192
+ "source_type": source_type,
193
+ "node_id": payload.get("node_id"),
194
+ })
195
+ self._save_state(state)
196
+ return fired
197
+
198
+ def hook_runner(self):
199
+ """A post_tool hook runner: ingestion events fan into triggers."""
200
+ def runner(context):
201
+ event = str(getattr(context, "event", "") or "")
202
+ if not event.startswith("kg_ingest."):
203
+ return {"status": "ok", "output": "not an ingestion event"}
204
+ payload = context.payload if isinstance(context.payload, dict) else {}
205
+ fired = self.on_brain_event(event, payload)
206
+ return {"status": "ok", "output": f"fired {fired} workflow trigger(s)"}
207
+ return runner
208
+
209
+ # ── execution + lifecycle ──────────────────────────────────────────────
210
+ def _fire(self, workflow_id: str, trigger_info: Dict[str, Any]) -> None:
211
+ def _run():
212
+ try:
213
+ self._run_workflow(workflow_id, {"__trigger__": trigger_info})
214
+ except Exception as exc:
215
+ logging.warning("trigger run failed for %s: %s", workflow_id, exc)
216
+
217
+ threading.Thread(target=_run, name=f"trigger-{workflow_id}", daemon=True).start()
218
+
219
+ def start(self) -> None:
220
+ if self._thread and self._thread.is_alive():
221
+ return
222
+ self.reconcile_missed()
223
+ self._stop_event.clear()
224
+
225
+ def _loop():
226
+ while not self._stop_event.wait(self._tick):
227
+ try:
228
+ self.tick_intervals()
229
+ except Exception as exc:
230
+ logging.warning("trigger scheduler tick failed: %s", exc)
231
+
232
+ self._thread = threading.Thread(target=_loop, name="trigger-scheduler", daemon=True)
233
+ self._thread.start()
234
+
235
+ def stop(self) -> None:
236
+ self._stop_event.set()
237
+ if self._thread:
238
+ self._thread.join(timeout=2)
239
+
240
+
241
+ __all__ = ["TriggerService", "TRIGGER_HOOK_NAME", "MIN_INTERVAL_SECONDS"]
@@ -9,6 +9,7 @@ from pathlib import Path
9
9
 
10
10
  from fastapi import HTTPException, Request, UploadFile
11
11
 
12
+ from latticeai.services.ingestion import IngestionItem
12
13
  from tools import ToolError, read_document
13
14
 
14
15
 
@@ -19,6 +20,7 @@ async def process_uploaded_document(
19
20
  current_user: str,
20
21
  enable_graph: bool,
21
22
  knowledge_graph,
23
+ ingestion_pipeline=None,
22
24
  bytes_match_extension,
23
25
  classify_sensitive_message,
24
26
  append_audit_event,
@@ -71,18 +73,41 @@ async def process_uploaded_document(
71
73
  try:
72
74
  if not (enable_graph and knowledge_graph):
73
75
  raise RuntimeError("graph disabled")
74
- graph_result = knowledge_graph.ingest_document(
75
- Path(tmp_path),
76
- original_filename=file.filename,
77
- mime_type=file.content_type,
78
- uploader=current_user,
79
- conversation_id=request.query_params.get("conversation_id"),
80
- extracted=result,
81
- )
82
- result["knowledge_graph"] = {
83
- "node_id": graph_result["node_id"],
84
- "sha256": graph_result["sha256"],
85
- }
76
+ if ingestion_pipeline is not None:
77
+ # v4: uploads enter the brain through the unified ingestion
78
+ # pipeline (provenance + kg_ingest hook lifecycle).
79
+ ingest = ingestion_pipeline.ingest(
80
+ IngestionItem(
81
+ source_type="upload",
82
+ title=file.filename,
83
+ path=tmp_path,
84
+ mime_type=file.content_type,
85
+ owner=current_user,
86
+ conversation_id=request.query_params.get("conversation_id"),
87
+ metadata={"extracted": result},
88
+ ),
89
+ user_email=current_user,
90
+ )
91
+ if ingest.status != "ok":
92
+ raise RuntimeError(ingest.detail or f"ingestion {ingest.status}")
93
+ result["knowledge_graph"] = {
94
+ "node_id": ingest.node_id,
95
+ "sha256": ingest.content_hash,
96
+ "provenance_id": ingest.provenance_id,
97
+ }
98
+ else:
99
+ graph_result = knowledge_graph.ingest_document(
100
+ Path(tmp_path),
101
+ original_filename=file.filename,
102
+ mime_type=file.content_type,
103
+ uploader=current_user,
104
+ conversation_id=request.query_params.get("conversation_id"),
105
+ extracted=result,
106
+ )
107
+ result["knowledge_graph"] = {
108
+ "node_id": graph_result["node_id"],
109
+ "sha256": graph_result["sha256"],
110
+ }
86
111
  except Exception as graph_error:
87
112
  logging.warning("knowledge graph document ingest failed: %s", graph_error)
88
113
  result["knowledge_graph"] = {"error": str(graph_error)}
@@ -26,7 +26,7 @@ Guardrail summary (v1.2.0):
26
26
 
27
27
  from __future__ import annotations
28
28
 
29
- from typing import Any, Dict, Optional
29
+ from typing import Any, Callable, Dict, Optional
30
30
 
31
31
  from latticeai.core.workspace_os import WorkspaceOSStore
32
32
 
@@ -38,13 +38,20 @@ class WorkspaceService:
38
38
  # partitioned per workspace. Surfaced so the UI / docs can be explicit.
39
39
  SHARED_GLOBAL_AREAS = ("graph", "skills")
40
40
 
41
- def __init__(self, store: WorkspaceOSStore):
41
+ def __init__(self, store: WorkspaceOSStore, *, resolve_user_id: Optional[Callable[[Optional[str]], Optional[str]]] = None):
42
42
  self.store = store
43
+ self._resolve_user_id = resolve_user_id or (lambda user_id: user_id)
44
+
45
+ def _identity(self, user_id: Optional[str]) -> Optional[str]:
46
+ if isinstance(user_id, str) and user_id.startswith("user:"):
47
+ return user_id
48
+ return self._resolve_user_id(user_id)
43
49
 
44
50
  # ── scope resolution + gating ────────────────────────────────────────
45
51
 
46
52
  def _ensure_permission(self, workspace_id: str, user_id: Optional[str], permission: str) -> None:
47
- if not self.store.has_permission(workspace_id, user_id, permission):
53
+ resolved_user = self._identity(user_id)
54
+ if not self.store.has_permission(workspace_id, resolved_user, permission):
48
55
  raise PermissionError(
49
56
  f"'{user_id or 'anonymous'}' lacks '{permission}' on workspace '{workspace_id}'"
50
57
  )
@@ -67,51 +74,83 @@ class WorkspaceService:
67
74
  return workspace_id
68
75
 
69
76
  def can_read(self, workspace_id: str, user_id: Optional[str]) -> bool:
70
- return self.store.has_permission(workspace_id, user_id, "read")
77
+ return self.store.has_permission(workspace_id, self._identity(user_id), "read")
71
78
 
72
79
  def can_write(self, workspace_id: str, user_id: Optional[str]) -> bool:
73
- return self.store.has_permission(workspace_id, user_id, "write")
80
+ return self.store.has_permission(workspace_id, self._identity(user_id), "write")
81
+
82
+ # ── record-level authorization (by-id access must not bypass gating) ──
83
+
84
+ def authorize_record_read(self, record: Dict[str, Any], user_id: Optional[str]) -> None:
85
+ """Authorize reading a record against ITS OWN workspace.
86
+
87
+ Records predating workspace scoping carry no workspace_id and remain
88
+ readable (legacy-global compatibility); a scoped record requires read
89
+ permission on its workspace regardless of any caller-supplied header.
90
+ """
91
+ workspace_id = (record or {}).get("workspace_id")
92
+ if workspace_id:
93
+ self._ensure_permission(workspace_id, user_id, "read")
94
+
95
+ def authorize_memory_delete(self, record: Dict[str, Any], user_id: Optional[str]) -> None:
96
+ """Delete requires owning the memory or write access to its workspace.
97
+
98
+ Ownerless records with no workspace keep their pre-v4 behaviour
99
+ (deletable by any authenticated local user).
100
+ """
101
+ owner = (record or {}).get("user_email")
102
+ workspace_id = (record or {}).get("workspace_id")
103
+ resolved_user = self._identity(user_id)
104
+ if owner and owner in {user_id, resolved_user}:
105
+ return
106
+ if workspace_id:
107
+ self._ensure_permission(workspace_id, resolved_user, "write")
108
+ return
109
+ if owner and owner not in {user_id, resolved_user}:
110
+ raise PermissionError(
111
+ f"'{user_id or 'anonymous'}' is not the owner of memory '{record.get('id')}'"
112
+ )
74
113
 
75
114
  # ── workspace registry / summary ─────────────────────────────────────
76
115
 
77
116
  def summary(self, user_id: Optional[str]) -> Dict[str, Any]:
78
117
  data = self.store.summary()
79
- data["workspace_registry"] = self.store.list_workspaces(user_id=user_id)
118
+ data["workspace_registry"] = self.store.list_workspaces(user_id=self._identity(user_id))
80
119
  data["shared_global_areas"] = list(self.SHARED_GLOBAL_AREAS)
81
120
  return data
82
121
 
83
122
  def list_workspaces(self, user_id: Optional[str]) -> Dict[str, Any]:
84
- return self.store.list_workspaces(user_id=user_id)
123
+ return self.store.list_workspaces(user_id=self._identity(user_id))
85
124
 
86
125
  def get_workspace(self, workspace_id: str, user_id: Optional[str]) -> Dict[str, Any]:
87
126
  # Reading workspace metadata requires read access to that workspace.
88
127
  self._ensure_permission(workspace_id, user_id, "read")
89
- return self.store.get_workspace(workspace_id, user_id=user_id)
128
+ return self.store.get_workspace(workspace_id, user_id=self._identity(user_id))
90
129
 
91
130
  def workspace_summary(self, workspace_id: str, user_id: Optional[str]) -> Dict[str, Any]:
92
131
  self._ensure_permission(workspace_id, user_id, "read")
93
- return self.store.workspace_summary(workspace_id, user_id=user_id)
132
+ return self.store.workspace_summary(workspace_id, user_id=self._identity(user_id))
94
133
 
95
134
  # ── organization workspace management (delegates with actor) ─────────
96
135
 
97
136
  def create_organization_workspace(self, *, name: str, owner_user_id: Optional[str], settings: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
98
- return self.store.create_organization_workspace(name=name, owner_user_id=owner_user_id, settings=settings)
137
+ return self.store.create_organization_workspace(name=name, owner_user_id=self._identity(owner_user_id), settings=settings)
99
138
 
100
139
  def update_workspace(self, workspace_id: str, *, name=None, settings=None, actor=None) -> Dict[str, Any]:
101
- return self.store.update_workspace(workspace_id, name=name, settings=settings, actor=actor)
140
+ return self.store.update_workspace(workspace_id, name=name, settings=settings, actor=self._identity(actor))
102
141
 
103
142
  def archive_workspace(self, workspace_id: str, *, actor=None) -> Dict[str, Any]:
104
- return self.store.archive_workspace(workspace_id, actor=actor)
143
+ return self.store.archive_workspace(workspace_id, actor=self._identity(actor))
105
144
 
106
145
  def add_member(self, workspace_id: str, *, user_id: str, role: str = "member", actor=None) -> Dict[str, Any]:
107
- return self.store.add_member(workspace_id, user_id=user_id, role=role, actor=actor)
146
+ return self.store.add_member(workspace_id, user_id=self._identity(user_id) or user_id, role=role, actor=self._identity(actor))
108
147
 
109
148
  def update_member_role(self, workspace_id: str, *, user_id: str, role: str, actor=None) -> Dict[str, Any]:
110
- return self.store.update_member_role(workspace_id, user_id=user_id, role=role, actor=actor)
149
+ return self.store.update_member_role(workspace_id, user_id=self._identity(user_id) or user_id, role=role, actor=self._identity(actor))
111
150
 
112
151
  def remove_member(self, workspace_id: str, *, user_id: str, actor=None) -> Dict[str, Any]:
113
- return self.store.remove_member(workspace_id, user_id=user_id, actor=actor)
152
+ return self.store.remove_member(workspace_id, user_id=self._identity(user_id) or user_id, actor=self._identity(actor))
114
153
 
115
154
  def set_active_workspace(self, workspace_id: str, user_id: Optional[str]) -> Dict[str, Any]:
116
155
  # Membership is enforced inside the store for organization workspaces.
117
- return self.store.set_active_workspace(workspace_id, user_id=user_id)
156
+ return self.store.set_active_workspace(workspace_id, user_id=self._identity(user_id))