ltcai 8.0.0 → 8.2.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 (183) hide show
  1. package/README.md +33 -72
  2. package/docs/CHANGELOG.md +52 -874
  3. package/docs/DEVELOPMENT.md +26 -3
  4. package/docs/LEGACY_COMPATIBILITY.md +3 -1
  5. package/docs/TRUST_MODEL.md +26 -26
  6. package/docs/WHY_LATTICE.md +28 -25
  7. package/docs/kg-schema.md +6 -4
  8. package/lattice_brain/__init__.py +1 -1
  9. package/lattice_brain/embeddings.py +9 -1
  10. package/lattice_brain/graph/schema.py +6 -0
  11. package/lattice_brain/runtime/multi_agent.py +1 -1
  12. package/latticeai/__init__.py +1 -1
  13. package/latticeai/api/computer_use.py +2 -0
  14. package/latticeai/api/memory.py +13 -0
  15. package/latticeai/app_factory.py +43 -162
  16. package/latticeai/brain/__init__.py +1 -1
  17. package/latticeai/core/config.py +10 -0
  18. package/latticeai/core/local_embeddings.py +8 -0
  19. package/latticeai/core/marketplace.py +1 -1
  20. package/latticeai/core/mcp_registry.py +140 -1
  21. package/latticeai/core/tool_registry.py +10 -0
  22. package/latticeai/core/workspace_graph_trace.py +132 -0
  23. package/latticeai/core/workspace_memory.py +75 -0
  24. package/latticeai/core/workspace_os.py +135 -1283
  25. package/latticeai/core/workspace_os_utils.py +132 -0
  26. package/latticeai/core/workspace_permissions.py +99 -0
  27. package/latticeai/core/workspace_plugins.py +97 -0
  28. package/latticeai/core/workspace_runs.py +612 -0
  29. package/latticeai/core/workspace_skills.py +114 -0
  30. package/latticeai/core/workspace_snapshots.py +195 -0
  31. package/latticeai/core/workspace_timeline.py +107 -0
  32. package/latticeai/runtime/audit_runtime.py +64 -0
  33. package/latticeai/runtime/config_runtime.py +4 -0
  34. package/latticeai/runtime/sso_runtime.py +52 -0
  35. package/latticeai/services/architecture_readiness.py +6 -5
  36. package/latticeai/services/memory_service.py +215 -7
  37. package/latticeai/services/model_capability_registry.py +68 -0
  38. package/latticeai/services/model_engines.py +603 -0
  39. package/latticeai/services/model_loading.py +482 -0
  40. package/latticeai/services/model_recommendation.py +5 -0
  41. package/latticeai/services/model_runtime.py +146 -837
  42. package/latticeai/services/product_readiness.py +19 -18
  43. package/package.json +2 -2
  44. package/scripts/build_frontend_assets.mjs +11 -2
  45. package/src-tauri/Cargo.lock +1 -1
  46. package/src-tauri/Cargo.toml +1 -1
  47. package/src-tauri/tauri.conf.json +1 -1
  48. package/static/app/asset-manifest.json +11 -102
  49. package/static/app/assets/Act-D9jIknFd.js +1 -0
  50. package/static/app/assets/Brain-CFOtWbPN.js +321 -0
  51. package/static/app/assets/{Capture-B9Tlhzqr.js → Capture-Q4WYzwr5.js} +1 -2
  52. package/static/app/assets/Library-C5Q2yWee.js +1 -0
  53. package/static/app/assets/System-BLbjdr1_.js +1 -0
  54. package/static/app/assets/core-CwxXejkd.js +1 -2
  55. package/static/app/assets/index-BqammyNu.js +16 -0
  56. package/static/app/assets/index-ty1iGgZu.css +2 -0
  57. package/static/app/assets/primitives-Br8uSfZ4.js +1 -0
  58. package/static/app/assets/{textarea-BZk6ybp5.js → textarea-BnhNs1_X.js} +1 -2
  59. package/static/app/index.html +3 -10
  60. package/static/app/theme-boot.js +8 -0
  61. package/static/sw.js +1 -1
  62. package/tools/__init__.py +2 -1
  63. package/tools/computer.py +21 -0
  64. package/docs/CARRYOVER_AUDIT_v3.6.0.md +0 -61
  65. package/docs/HANDOVER_v3.6.0.md +0 -46
  66. package/docs/RUNTIME_HOOK_COVERAGE_v3.5.0.md +0 -56
  67. package/docs/RUNTIME_HOOK_COVERAGE_v3.6.0.md +0 -49
  68. package/docs/V2_ARCHITECTURE.md +0 -561
  69. package/docs/V3_2_AUDIT.md +0 -82
  70. package/docs/V3_BACKEND_ARCHITECTURE.md +0 -138
  71. package/docs/V3_FRONTEND.md +0 -140
  72. package/docs/V4_1_FRONTEND_ARCHITECTURE_REVIEW.md +0 -65
  73. package/docs/V4_1_FRONTEND_MIGRATION_REPORT.md +0 -70
  74. package/docs/V4_1_VALIDATION_REPORT.md +0 -47
  75. package/docs/V4_2_BRAIN_CORE_ARCHITECTURE.md +0 -97
  76. package/docs/V4_2_STORAGE_MIGRATION_REPORT.md +0 -91
  77. package/docs/V4_2_VALIDATION_REPORT.md +0 -89
  78. package/docs/V4_3_2_DEADCODE_AUDIT_REPORT.md +0 -174
  79. package/docs/V4_3_2_DOCUMENTATION_CLEANUP_REPORT.md +0 -81
  80. package/docs/V4_3_2_GITHUB_VERCEL_CHECK_REPORT.md +0 -75
  81. package/docs/V4_3_2_GRAPH_UX_REPORT.md +0 -48
  82. package/docs/V4_3_2_INDEPENDENT_AUDIT_PACKAGE.md +0 -209
  83. package/docs/V4_3_2_PRODUCT_POLISH_REPORT.md +0 -57
  84. package/docs/V4_3_2_SELF_AUDIT_REPORT.md +0 -63
  85. package/docs/V4_3_2_VALIDATION_REPORT.md +0 -97
  86. package/docs/V4_3_3_VALIDATION_REPORT.md +0 -46
  87. package/docs/V4_3_PORTABILITY_ARCHITECTURE.md +0 -69
  88. package/docs/V4_3_PRIVACY_AUDIT.md +0 -60
  89. package/docs/V4_3_PRODUCT_HARDENING_REPORT.md +0 -53
  90. package/docs/V4_3_VALIDATION_REPORT.md +0 -58
  91. package/docs/V4_4_0_EXTRACTION_REPORT.md +0 -239
  92. package/docs/V4_5_0_GEMMA_RUNTIME_COMPATIBILITY_REPORT.md +0 -49
  93. package/docs/V4_5_0_GRAPH_UX_REPORT.md +0 -34
  94. package/docs/V4_5_0_MODEL_RUNTIME_UX_REPORT.md +0 -40
  95. package/docs/V4_5_0_ONBOARDING_REPORT.md +0 -31
  96. package/docs/V4_5_0_PRODUCT_EXPERIENCE_RECOVERY_REPORT.md +0 -49
  97. package/docs/V4_5_0_VALIDATION_REPORT.md +0 -60
  98. package/docs/V4_5_1_GRAPH_EXPERIENCE_REPORT.md +0 -33
  99. package/docs/V4_5_1_MODEL_EXPERIENCE_REPORT.md +0 -37
  100. package/docs/V4_5_1_NAVIGATION_REPORT.md +0 -37
  101. package/docs/V4_5_1_ONBOARDING_REPORT.md +0 -29
  102. package/docs/V4_5_1_PRODUCT_REIMAGINING_REPORT.md +0 -61
  103. package/docs/V4_5_1_RC_ARTIFACTS.md +0 -44
  104. package/docs/V4_5_1_UX_REPORT.md +0 -45
  105. package/docs/V4_5_1_VALIDATION_REPORT.md +0 -55
  106. package/docs/V4_5_1_VISUAL_DESIGN_REPORT.md +0 -30
  107. package/docs/V4_6_0_LIVING_BRAIN_EXPERIENCE_REPORT.md +0 -72
  108. package/docs/V4_6_1_RELEASE_REFRESH_REPORT.md +0 -42
  109. package/docs/V4_7_0_ADMIN_SEPARATION_REPORT.md +0 -42
  110. package/docs/V4_7_1_ADMIN_OPERATIONS_REPORT.md +0 -49
  111. package/docs/V4_7_2_INTUITIVE_BRAIN_UX_REPORT.md +0 -62
  112. package/docs/V4_BRAIN_ARCHITECTURE.md +0 -322
  113. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +0 -555
  114. package/docs/V4_IMPLEMENTATION_PLAN.md +0 -470
  115. package/frontend/index.html +0 -24
  116. package/frontend/openapi.json +0 -15425
  117. package/frontend/src/App.tsx +0 -243
  118. package/frontend/src/api/client.ts +0 -580
  119. package/frontend/src/api/openapi.ts +0 -17892
  120. package/frontend/src/components/AdminAccessGate.tsx +0 -70
  121. package/frontend/src/components/FeedbackState.tsx +0 -45
  122. package/frontend/src/components/LanguageSwitcher.tsx +0 -23
  123. package/frontend/src/components/LivingBrain.tsx +0 -220
  124. package/frontend/src/components/ProductFlow.tsx +0 -171
  125. package/frontend/src/components/WorkspaceProfileSwitcher.tsx +0 -176
  126. package/frontend/src/components/onboarding/AnalysisScreen.tsx +0 -135
  127. package/frontend/src/components/onboarding/DownloadConsentPanel.tsx +0 -29
  128. package/frontend/src/components/onboarding/InstallScreen.tsx +0 -263
  129. package/frontend/src/components/onboarding/LanguageChooser.tsx +0 -23
  130. package/frontend/src/components/onboarding/LoginScreen.tsx +0 -131
  131. package/frontend/src/components/onboarding/ProductFlowScreens.tsx +0 -13
  132. package/frontend/src/components/onboarding/RecommendationScreen.tsx +0 -116
  133. package/frontend/src/components/onboarding/recommendationModel.ts +0 -189
  134. package/frontend/src/components/primitives.tsx +0 -392
  135. package/frontend/src/components/ui/badge.tsx +0 -27
  136. package/frontend/src/components/ui/button.tsx +0 -37
  137. package/frontend/src/components/ui/card.tsx +0 -22
  138. package/frontend/src/components/ui/input.tsx +0 -16
  139. package/frontend/src/components/ui/textarea.tsx +0 -16
  140. package/frontend/src/features/admin/AdminConsole.tsx +0 -334
  141. package/frontend/src/features/brain/BrainCarePanel.tsx +0 -254
  142. package/frontend/src/features/brain/BrainComposer.tsx +0 -85
  143. package/frontend/src/features/brain/BrainConversation.tsx +0 -688
  144. package/frontend/src/features/brain/BrainGraphLayer.tsx +0 -365
  145. package/frontend/src/features/brain/BrainHome.tsx +0 -624
  146. package/frontend/src/features/brain/BrainMemoryLayer.tsx +0 -45
  147. package/frontend/src/features/brain/BrainOverviewPanel.tsx +0 -149
  148. package/frontend/src/features/brain/BrainRelationshipLayer.tsx +0 -43
  149. package/frontend/src/features/brain/DepthEmergence.tsx +0 -53
  150. package/frontend/src/features/brain/brainData.ts +0 -246
  151. package/frontend/src/features/brain/graphLayout.ts +0 -37
  152. package/frontend/src/features/brain/types.ts +0 -150
  153. package/frontend/src/features/review/ReviewCard.tsx +0 -100
  154. package/frontend/src/features/review/ReviewInbox.tsx +0 -127
  155. package/frontend/src/features/review/reviewHelpers.ts +0 -69
  156. package/frontend/src/i18n.ts +0 -1303
  157. package/frontend/src/lib/utils.ts +0 -33
  158. package/frontend/src/main.tsx +0 -23
  159. package/frontend/src/pages/Act.tsx +0 -458
  160. package/frontend/src/pages/Ask.tsx +0 -14
  161. package/frontend/src/pages/Brain.tsx +0 -914
  162. package/frontend/src/pages/Capture.tsx +0 -258
  163. package/frontend/src/pages/Library.tsx +0 -486
  164. package/frontend/src/pages/System.tsx +0 -621
  165. package/frontend/src/routes.ts +0 -92
  166. package/frontend/src/store/appStore.ts +0 -94
  167. package/frontend/src/styles.css +0 -6579
  168. package/static/app/assets/Act-DOvf59ru.js +0 -2
  169. package/static/app/assets/Act-DOvf59ru.js.map +0 -1
  170. package/static/app/assets/Brain-C7_0mEiI.js +0 -322
  171. package/static/app/assets/Brain-C7_0mEiI.js.map +0 -1
  172. package/static/app/assets/Capture-B9Tlhzqr.js.map +0 -1
  173. package/static/app/assets/Library-BJPEEm5O.js +0 -2
  174. package/static/app/assets/Library-BJPEEm5O.js.map +0 -1
  175. package/static/app/assets/System-D6t9jo9V.js +0 -2
  176. package/static/app/assets/System-D6t9jo9V.js.map +0 -1
  177. package/static/app/assets/core-CwxXejkd.js.map +0 -1
  178. package/static/app/assets/index-C7g26IF6.css +0 -2
  179. package/static/app/assets/index-DbcEYJQ2.js +0 -17
  180. package/static/app/assets/index-DbcEYJQ2.js.map +0 -1
  181. package/static/app/assets/primitives-CD38lt4n.js +0 -2
  182. package/static/app/assets/primitives-CD38lt4n.js.map +0 -1
  183. package/static/app/assets/textarea-BZk6ybp5.js.map +0 -1
@@ -0,0 +1,612 @@
1
+ """Agent and workflow run persistence extracted from WorkspaceOSStore."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from lattice_brain.runtime.contracts import run_record_contract, workflow_run_contract
8
+
9
+ from .workspace_os_utils import _json_hash, _listify, _now
10
+
11
+ RUN_ACTIVE_STATUSES = {"queued", "running", "in_progress", "retrying", "cancelling"}
12
+ RUN_TERMINAL_STATUSES = {"ok", "retried_ok", "failed", "rejected", "cancelled", "interrupted", "partial"}
13
+
14
+
15
+ class WorkspaceRuns:
16
+ def __init__(self, store: Any):
17
+ self._store = store
18
+
19
+ def __getattr__(self, name: str) -> Any:
20
+ return getattr(self._store, name)
21
+
22
+ def list_agents(self, workspace_id: Optional[str] = None) -> Dict[str, Any]:
23
+ state = self.load_state()
24
+ runs = self._scoped(_listify(state.get("agent_runs")), workspace_id)
25
+ return {"agents": _listify(state.get("agents")), "runs": list(reversed(runs[-100:]))}
26
+
27
+ def record_agent_run(
28
+ self,
29
+ *,
30
+ agent_id: str,
31
+ status: str,
32
+ input_text: str,
33
+ output_text: str,
34
+ user_email: Optional[str],
35
+ timeline: Optional[List[Dict[str, Any]]] = None,
36
+ relationships: Optional[List[str]] = None,
37
+ handoffs: Optional[List[Dict[str, Any]]] = None,
38
+ context_packets: Optional[List[Dict[str, Any]]] = None,
39
+ plan: Optional[List[Dict[str, Any]]] = None,
40
+ plan_review: Optional[Dict[str, Any]] = None,
41
+ review_history: Optional[List[Dict[str, Any]]] = None,
42
+ retry_history: Optional[List[Dict[str, Any]]] = None,
43
+ memory_snapshots: Optional[List[Dict[str, Any]]] = None,
44
+ graph: Any = None,
45
+ workspace_id: Optional[str] = None,
46
+ mode: str = "simulation",
47
+ ) -> Dict[str, Any]:
48
+ state = self.load_state()
49
+ resolved_workspace = self._resolve_scope(workspace_id, state)
50
+ run = {
51
+ "id": f"agent-run-{_json_hash([agent_id, input_text, output_text, _now()])[:16]}",
52
+ "record_schema_version": 2,
53
+ "agent_id": agent_id,
54
+ "mode": mode,
55
+ "status": status,
56
+ "input": input_text,
57
+ "output_preview": output_text[:1000],
58
+ "user_email": user_email,
59
+ "workspace_id": resolved_workspace,
60
+ "relationships": relationships or [],
61
+ "timeline": timeline or [],
62
+ "handoffs": handoffs or [],
63
+ "context_packets": context_packets or [],
64
+ "plan": plan or [],
65
+ "plan_review": plan_review or {},
66
+ "review_history": review_history or [],
67
+ "retry_history": retry_history or [],
68
+ "memory_snapshots": memory_snapshots or [],
69
+ "created_at": _now(),
70
+ }
71
+ if mode == "simulation":
72
+ # Simulated runs are replay scaffolding, not experiences — they must
73
+ # never enter the knowledge graph as real provenance.
74
+ run["graph_node_id"] = None
75
+ run["graph_skipped"] = "simulation runs are not recorded in the knowledge graph"
76
+ elif graph is not None:
77
+ try:
78
+ ingested = graph.ingest_event(
79
+ "AgentRun",
80
+ f"{agent_id} {status}",
81
+ user_email=user_email,
82
+ source="workspace_os",
83
+ metadata={"run_id": run["id"], "agent_id": agent_id, "status": status, "mode": mode},
84
+ )
85
+ run["graph_node_id"] = ingested.get("node_id")
86
+ except Exception as exc:
87
+ run["graph_error"] = str(exc)
88
+ if handoffs:
89
+ stored_handoffs = state.setdefault("handoffs", [])
90
+ for handoff in handoffs:
91
+ stored = {
92
+ **handoff,
93
+ "run_id": run["id"],
94
+ "workspace_id": resolved_workspace,
95
+ }
96
+ stored_handoffs.append(stored)
97
+ state["handoffs"] = stored_handoffs
98
+ state.setdefault("agent_runs", []).append(run)
99
+ self.save_state(state)
100
+ self._emit_replayable_timeline_events(area="agent", run_id=run["id"], timeline=run["timeline"], workspace_id=resolved_workspace)
101
+ if status == "failed":
102
+ self._emit_execution_event(area="agent", event_type="execution_failed", payload={"run_id": run["id"], "agent_id": agent_id, "status": status}, workspace_id=resolved_workspace)
103
+ self.record_timeline_event("agent", "agent_run", {"run_id": run["id"], "agent_id": agent_id, "status": status}, workspace_id=resolved_workspace)
104
+ run["contract"] = run_record_contract(run)
105
+ state = self.load_state()
106
+ for item in _listify(state.get("agent_runs")):
107
+ if item.get("id") == run["id"]:
108
+ item["contract"] = run["contract"]
109
+ break
110
+ self.save_state(state)
111
+ return run
112
+
113
+ def update_agent_run(
114
+ self,
115
+ run_id: str,
116
+ *,
117
+ workspace_id: Optional[str] = None,
118
+ graph: Any = None,
119
+ patch: Optional[Dict[str, Any]] = None,
120
+ **fields: Any,
121
+ ) -> Dict[str, Any]:
122
+ """Patch a persisted agent run without changing its id.
123
+
124
+ Async execution creates a durable queued/running row before work starts,
125
+ then updates that same row as progress, cancellation, or a terminal
126
+ result arrives. This keeps old run lists/read APIs compatible while
127
+ avoiding duplicate "placeholder + final" records.
128
+ """
129
+ updates = {**(patch or {}), **fields}
130
+ state = self.load_state()
131
+ run = next((item for item in _listify(state.get("agent_runs")) if item.get("id") == run_id), None)
132
+ if run is None or (workspace_id and self._record_workspace(run) != str(workspace_id)):
133
+ raise FileNotFoundError(run_id)
134
+ resolved_workspace = self._record_workspace(run)
135
+ old_timeline_len = len(run.get("timeline") or [])
136
+
137
+ output_text = updates.pop("output_text", None)
138
+ if output_text is not None:
139
+ run["output_preview"] = str(output_text)[:1000]
140
+ for key, value in updates.items():
141
+ run[key] = value
142
+ status = str(run.get("status") or "")
143
+ run["updated_at"] = _now()
144
+ if status in RUN_TERMINAL_STATUSES:
145
+ run.setdefault("completed_at", _now())
146
+
147
+ handoffs = updates.get("handoffs")
148
+ if isinstance(handoffs, list):
149
+ stored_handoffs = [
150
+ item for item in _listify(state.get("handoffs"))
151
+ if item.get("run_id") != run_id
152
+ ]
153
+ for handoff in handoffs:
154
+ if isinstance(handoff, dict):
155
+ stored_handoffs.append({**handoff, "run_id": run_id, "workspace_id": resolved_workspace})
156
+ state["handoffs"] = stored_handoffs
157
+
158
+ if (
159
+ status in RUN_TERMINAL_STATUSES
160
+ and run.get("mode") != "simulation"
161
+ and graph is not None
162
+ and not run.get("graph_node_id")
163
+ ):
164
+ try:
165
+ ingested = graph.ingest_event(
166
+ "AgentRun",
167
+ f"{run.get('agent_id')} {status}",
168
+ user_email=run.get("user_email"),
169
+ source="workspace_os",
170
+ metadata={
171
+ "run_id": run_id,
172
+ "agent_id": run.get("agent_id"),
173
+ "status": status,
174
+ "mode": run.get("mode"),
175
+ },
176
+ )
177
+ run["graph_node_id"] = ingested.get("node_id")
178
+ except Exception as exc:
179
+ run["graph_error"] = str(exc)
180
+
181
+ self.save_state(state)
182
+ run["contract"] = run_record_contract(run)
183
+ state = self.load_state()
184
+ for item in _listify(state.get("agent_runs")):
185
+ if item.get("id") == run_id:
186
+ item["contract"] = run["contract"]
187
+ break
188
+ self.save_state(state)
189
+
190
+ timeline = run.get("timeline") or []
191
+ if len(timeline) > old_timeline_len:
192
+ self._emit_replayable_timeline_events(
193
+ area="agent",
194
+ run_id=run_id,
195
+ timeline=timeline[old_timeline_len:],
196
+ workspace_id=resolved_workspace,
197
+ )
198
+ if status == "failed":
199
+ self._emit_execution_event(area="agent", event_type="execution_failed", payload={"run_id": run_id, "agent_id": run.get("agent_id"), "status": status}, workspace_id=resolved_workspace)
200
+ elif status == "cancelled":
201
+ self._emit_execution_event(area="agent", event_type="execution_cancelled", payload={"run_id": run_id, "agent_id": run.get("agent_id"), "status": status}, workspace_id=resolved_workspace)
202
+ elif status == "interrupted":
203
+ self._emit_execution_event(area="agent", event_type="execution_interrupted", payload={"run_id": run_id, "agent_id": run.get("agent_id"), "status": status}, workspace_id=resolved_workspace)
204
+ self.record_timeline_event("agent", "agent_run_update", {"run_id": run_id, "agent_id": run.get("agent_id"), "status": status}, workspace_id=resolved_workspace)
205
+ return run
206
+
207
+ def get_agent_run(self, run_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
208
+ run = next((item for item in _listify(self.load_state().get("agent_runs")) if item.get("id") == run_id), None)
209
+ if not run or (workspace_id and self._record_workspace(run) != str(workspace_id)):
210
+ raise FileNotFoundError(run_id)
211
+ return run
212
+
213
+ def list_handoffs(self, workspace_id: Optional[str] = None, run_id: Optional[str] = None) -> Dict[str, Any]:
214
+ handoffs = self._scoped(_listify(self.load_state().get("handoffs")), workspace_id)
215
+ if run_id:
216
+ handoffs = [item for item in handoffs if item.get("run_id") == run_id]
217
+ return {"handoffs": list(reversed(handoffs[-200:]))}
218
+
219
+ def create_workflow(
220
+ self,
221
+ *,
222
+ name: str,
223
+ steps: List[Dict[str, Any]],
224
+ user_email: Optional[str],
225
+ metadata: Optional[Dict[str, Any]] = None,
226
+ graph: Any = None,
227
+ workspace_id: Optional[str] = None,
228
+ nodes: Optional[List[Dict[str, Any]]] = None,
229
+ ) -> Dict[str, Any]:
230
+ state = self.load_state()
231
+ workflow = {
232
+ "id": f"workflow-{_json_hash([name, steps, user_email, _now()])[:16]}",
233
+ "name": name or "Untitled workflow",
234
+ "steps": steps,
235
+ "user_email": user_email,
236
+ "workspace_id": self._resolve_scope(workspace_id, state),
237
+ "metadata": metadata or {},
238
+ "events": [{"type": "created", "timestamp": _now()}],
239
+ "created_at": _now(),
240
+ "updated_at": _now(),
241
+ }
242
+ # Workflow Designer stores a typed-node graph alongside the legacy
243
+ # ``steps`` list so older history keeps working and new editors get nodes.
244
+ if nodes is not None:
245
+ workflow["nodes"] = nodes
246
+ if graph is not None:
247
+ try:
248
+ ingested = graph.ingest_event(
249
+ "Workflow",
250
+ workflow["name"],
251
+ user_email=user_email,
252
+ source="workspace_os",
253
+ metadata={"workflow_id": workflow["id"], "steps": steps},
254
+ )
255
+ workflow["graph_node_id"] = ingested.get("node_id")
256
+ except Exception as exc:
257
+ workflow["graph_error"] = str(exc)
258
+ state.setdefault("workflows", []).append(workflow)
259
+ self.save_state(state)
260
+ self.record_timeline_event("workflow", "workflow_created", {"workflow_id": workflow["id"], "name": workflow["name"]})
261
+ return workflow
262
+
263
+ def record_workflow_run(
264
+ self,
265
+ *,
266
+ workflow_id: Optional[str],
267
+ name: str,
268
+ status: str,
269
+ timeline: List[Dict[str, Any]],
270
+ outputs: Optional[Dict[str, Any]] = None,
271
+ user_email: Optional[str] = None,
272
+ graph: Any = None,
273
+ workspace_id: Optional[str] = None,
274
+ mode: str = "simulation",
275
+ pause: Optional[Dict[str, Any]] = None,
276
+ ) -> Dict[str, Any]:
277
+ """Persist a Workflow Designer execution into local-first run history."""
278
+ state = self.load_state()
279
+ resolved_workspace = self._resolve_scope(workspace_id, state)
280
+ run = {
281
+ "id": f"workflow-run-{_json_hash([workflow_id, name, status, _now()])[:16]}",
282
+ "record_schema_version": 2,
283
+ "workflow_id": workflow_id,
284
+ "name": name or "workflow",
285
+ "mode": mode,
286
+ "status": status,
287
+ "timeline": timeline or [],
288
+ "outputs": outputs or {},
289
+ "user_email": user_email,
290
+ "workspace_id": resolved_workspace,
291
+ "created_at": _now(),
292
+ }
293
+ if pause:
294
+ run["pause"] = pause
295
+ if mode == "simulation":
296
+ # Record-only node runners do no real work; their runs must not be
297
+ # written into the knowledge graph as if they were real executions.
298
+ run["graph_node_id"] = None
299
+ run["graph_skipped"] = "simulation runs are not recorded in the knowledge graph"
300
+ elif graph is not None:
301
+ try:
302
+ ingested = graph.ingest_event(
303
+ "WorkflowRun",
304
+ f"{run['name']} {status}",
305
+ user_email=user_email,
306
+ source="workspace_os",
307
+ metadata={"run_id": run["id"], "workflow_id": workflow_id, "status": status, "mode": mode},
308
+ )
309
+ run["graph_node_id"] = ingested.get("node_id")
310
+ except Exception as exc:
311
+ run["graph_error"] = str(exc)
312
+ state.setdefault("workflow_runs", []).append(run)
313
+ # Attach the run id to the workflow's event log for cross-linking.
314
+ for wf in _listify(state.get("workflows")):
315
+ if wf.get("id") == workflow_id:
316
+ wf.setdefault("events", []).append({"type": "run", "timestamp": _now(), "payload": {"run_id": run["id"], "status": status}})
317
+ wf["updated_at"] = _now()
318
+ break
319
+ self.save_state(state)
320
+ self._emit_execution_event(area="workflow", event_type="workflow_started", payload={"run_id": run["id"], "workflow_id": workflow_id, "name": name}, workspace_id=resolved_workspace)
321
+ self._emit_replayable_timeline_events(area="workflow", run_id=run["id"], timeline=run["timeline"], workspace_id=resolved_workspace)
322
+ if status == "failed":
323
+ self._emit_execution_event(area="workflow", event_type="execution_failed", payload={"run_id": run["id"], "workflow_id": workflow_id, "status": status}, workspace_id=resolved_workspace)
324
+ elif status in {"ok", "partial"}:
325
+ self._emit_execution_event(area="workflow", event_type="workflow_completed", payload={"run_id": run["id"], "workflow_id": workflow_id, "status": status}, workspace_id=resolved_workspace)
326
+ self.record_timeline_event("workflow", "workflow_run", {"run_id": run["id"], "workflow_id": workflow_id, "status": status}, workspace_id=resolved_workspace)
327
+ run["contract"] = workflow_run_contract(run)
328
+ state = self.load_state()
329
+ for item in _listify(state.get("workflow_runs")):
330
+ if item.get("id") == run["id"]:
331
+ item["contract"] = run["contract"]
332
+ break
333
+ self.save_state(state)
334
+ return run
335
+
336
+ def update_workflow_run(
337
+ self,
338
+ run_id: str,
339
+ *,
340
+ workspace_id: Optional[str] = None,
341
+ graph: Any = None,
342
+ patch: Optional[Dict[str, Any]] = None,
343
+ **fields: Any,
344
+ ) -> Dict[str, Any]:
345
+ """Patch a persisted workflow run in place for async execution."""
346
+ updates = {**(patch or {}), **fields}
347
+ state = self.load_state()
348
+ run = next((item for item in _listify(state.get("workflow_runs")) if item.get("id") == run_id), None)
349
+ if run is None or (workspace_id and self._record_workspace(run) != str(workspace_id)):
350
+ raise FileNotFoundError(run_id)
351
+ resolved_workspace = self._record_workspace(run)
352
+ old_timeline_len = len(run.get("timeline") or [])
353
+
354
+ for key, value in updates.items():
355
+ if value is None and key == "pause":
356
+ run.pop("pause", None)
357
+ else:
358
+ run[key] = value
359
+ status = str(run.get("status") or "")
360
+ run["updated_at"] = _now()
361
+ if status in RUN_TERMINAL_STATUSES:
362
+ run.setdefault("completed_at", _now())
363
+
364
+ workflow_id = run.get("workflow_id")
365
+ for wf in _listify(state.get("workflows")):
366
+ if wf.get("id") == workflow_id:
367
+ wf.setdefault("events", []).append({"type": "run_update", "timestamp": _now(), "payload": {"run_id": run_id, "status": status}})
368
+ wf["updated_at"] = _now()
369
+ break
370
+
371
+ if (
372
+ status in RUN_TERMINAL_STATUSES
373
+ and run.get("mode") != "simulation"
374
+ and graph is not None
375
+ and not run.get("graph_node_id")
376
+ ):
377
+ try:
378
+ ingested = graph.ingest_event(
379
+ "WorkflowRun",
380
+ f"{run.get('name')} {status}",
381
+ user_email=run.get("user_email"),
382
+ source="workspace_os",
383
+ metadata={
384
+ "run_id": run_id,
385
+ "workflow_id": workflow_id,
386
+ "status": status,
387
+ "mode": run.get("mode"),
388
+ },
389
+ )
390
+ run["graph_node_id"] = ingested.get("node_id")
391
+ except Exception as exc:
392
+ run["graph_error"] = str(exc)
393
+
394
+ self.save_state(state)
395
+ run["contract"] = workflow_run_contract(run)
396
+ state = self.load_state()
397
+ for item in _listify(state.get("workflow_runs")):
398
+ if item.get("id") == run_id:
399
+ item["contract"] = run["contract"]
400
+ break
401
+ self.save_state(state)
402
+
403
+ timeline = run.get("timeline") or []
404
+ if len(timeline) > old_timeline_len:
405
+ self._emit_replayable_timeline_events(
406
+ area="workflow",
407
+ run_id=run_id,
408
+ timeline=timeline[old_timeline_len:],
409
+ workspace_id=resolved_workspace,
410
+ )
411
+ if status == "failed":
412
+ self._emit_execution_event(area="workflow", event_type="execution_failed", payload={"run_id": run_id, "workflow_id": workflow_id, "status": status}, workspace_id=resolved_workspace)
413
+ elif status in {"ok", "partial"}:
414
+ self._emit_execution_event(area="workflow", event_type="workflow_completed", payload={"run_id": run_id, "workflow_id": workflow_id, "status": status}, workspace_id=resolved_workspace)
415
+ elif status == "cancelled":
416
+ self._emit_execution_event(area="workflow", event_type="execution_cancelled", payload={"run_id": run_id, "workflow_id": workflow_id, "status": status}, workspace_id=resolved_workspace)
417
+ elif status == "interrupted":
418
+ self._emit_execution_event(area="workflow", event_type="execution_interrupted", payload={"run_id": run_id, "workflow_id": workflow_id, "status": status}, workspace_id=resolved_workspace)
419
+ self.record_timeline_event("workflow", "workflow_run_update", {"run_id": run_id, "workflow_id": workflow_id, "status": status}, workspace_id=resolved_workspace)
420
+ return run
421
+
422
+ def list_workflow_runs(self, workflow_id: Optional[str] = None, limit: int = 50, workspace_id: Optional[str] = None) -> Dict[str, Any]:
423
+ runs = self._scoped(_listify(self.load_state().get("workflow_runs")), workspace_id)
424
+ if workflow_id:
425
+ runs = [run for run in runs if run.get("workflow_id") == workflow_id]
426
+ return {"runs": list(reversed(runs[-max(1, min(limit, 300)):]))}
427
+
428
+ def mark_workflow_run_resolved(
429
+ self, run_id: str, *, resumed_run_id: str, approved: bool,
430
+ workspace_id: Optional[str] = None,
431
+ ) -> Dict[str, Any]:
432
+ """Close out a paused run after its approval decision (one decision only)."""
433
+ state = self.load_state()
434
+ run = next((item for item in _listify(state.get("workflow_runs")) if item.get("id") == run_id), None)
435
+ if run is None or (workspace_id and self._record_workspace(run) != str(workspace_id)):
436
+ raise FileNotFoundError(run_id)
437
+ run["status"] = "resumed" if approved else "denied"
438
+ run["resolved_at"] = _now()
439
+ run["resumed_run_id"] = resumed_run_id
440
+ self.save_state(state)
441
+ return run
442
+
443
+ def get_workflow_run(self, run_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
444
+ run = next((item for item in _listify(self.load_state().get("workflow_runs")) if item.get("id") == run_id), None)
445
+ if not run or (workspace_id and self._record_workspace(run) != str(workspace_id)):
446
+ raise FileNotFoundError(run_id)
447
+ return run
448
+
449
+ def reconcile_interrupted_runs(self, *, reason: str = "server_startup") -> Dict[str, Any]:
450
+ """Mark durable active runs as interrupted after a process restart.
451
+
452
+ Queued/running/cancelling rows cannot have an owning asyncio task after
453
+ startup. Paused approval runs are intentionally left untouched so their
454
+ durable human decision cursor remains resumable.
455
+ """
456
+ state = self.load_state()
457
+ interrupted: List[Dict[str, Any]] = []
458
+ now = _now()
459
+ collections = (("agent_runs", "agent"), ("workflow_runs", "workflow"))
460
+ for key, area in collections:
461
+ for run in _listify(state.get(key)):
462
+ status = str(run.get("status") or "")
463
+ if status not in RUN_ACTIVE_STATUSES:
464
+ continue
465
+ run["status"] = "interrupted"
466
+ run["interrupted_at"] = now
467
+ run["interrupt_reason"] = reason
468
+ run["updated_at"] = now
469
+ run.setdefault("timeline", []).append({
470
+ "event": "execution_interrupted",
471
+ "status": "interrupted",
472
+ "reason": reason,
473
+ "timestamp": now,
474
+ })
475
+ interrupted.append({
476
+ "kind": area,
477
+ "run_id": run.get("id"),
478
+ "workspace_id": self._record_workspace(run),
479
+ "previous_status": status,
480
+ })
481
+ if not interrupted:
482
+ return {"count": 0, "interrupted": []}
483
+ self.save_state(state)
484
+ for item in interrupted:
485
+ area = item["kind"]
486
+ run_id = item["run_id"]
487
+ workspace = item.get("workspace_id")
488
+ self._emit_execution_event(
489
+ area=area,
490
+ event_type="execution_interrupted",
491
+ payload={"run_id": run_id, "reason": reason, "previous_status": item.get("previous_status")},
492
+ workspace_id=workspace,
493
+ )
494
+ self.record_timeline_event(
495
+ "system",
496
+ "startup_reconciliation",
497
+ {"interrupted_runs": len(interrupted), "reason": reason},
498
+ )
499
+ return {"count": len(interrupted), "interrupted": interrupted}
500
+
501
+ @staticmethod
502
+ def _replay_frames(run: Dict[str, Any], *, kind: str) -> List[Dict[str, Any]]:
503
+ frames = []
504
+ for index, item in enumerate(run.get("timeline") or []):
505
+ event = item.get("event") or item.get("event_type") or item.get("type") or "event"
506
+ actor = (
507
+ item.get("agent_id")
508
+ or item.get("role")
509
+ or item.get("source_agent")
510
+ or item.get("target_agent")
511
+ or item.get("node")
512
+ or kind
513
+ )
514
+ result = item.get("result") if "result" in item else item.get("output")
515
+ decision = item.get("outcome") or item.get("verdict") or item.get("status")
516
+ frames.append({
517
+ "index": index,
518
+ "event": event,
519
+ "actor": actor,
520
+ "when": item.get("timestamp") or item.get("started_at") or run.get("created_at"),
521
+ "why": item.get("reason") or item.get("note") or item.get("name") or "",
522
+ "input": item.get("context_packet") or item.get("trigger") or run.get("input"),
523
+ "output": result,
524
+ "decision": decision,
525
+ "raw": item,
526
+ })
527
+ return frames
528
+
529
+ def replay_agent_run(self, run_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
530
+ run = self.get_agent_run(run_id, workspace_id=workspace_id)
531
+ return {
532
+ "kind": "agent",
533
+ "run_id": run_id,
534
+ "status": run.get("status"),
535
+ "workspace_id": self._record_workspace(run),
536
+ "contract": run.get("contract") or run_record_contract(run),
537
+ "replayable": True,
538
+ "frames": self._replay_frames(run, kind="agent"),
539
+ "handoffs": run.get("handoffs") or [],
540
+ "context_packets": run.get("context_packets") or [],
541
+ "review_history": run.get("review_history") or [],
542
+ "retry_history": run.get("retry_history") or [],
543
+ }
544
+
545
+ def replay_workflow_run(self, run_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
546
+ run = self.get_workflow_run(run_id, workspace_id=workspace_id)
547
+ return {
548
+ "kind": "workflow",
549
+ "run_id": run_id,
550
+ "status": run.get("status"),
551
+ "workspace_id": self._record_workspace(run),
552
+ "contract": run.get("contract") or workflow_run_contract(run),
553
+ "replayable": True,
554
+ "frames": self._replay_frames(run, kind="workflow"),
555
+ "outputs": run.get("outputs") or {},
556
+ }
557
+
558
+ def get_workflow(self, workflow_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
559
+ workflow = next((wf for wf in _listify(self.load_state().get("workflows")) if wf.get("id") == workflow_id), None)
560
+ if not workflow or (workspace_id and self._record_workspace(workflow) != str(workspace_id)):
561
+ raise FileNotFoundError(workflow_id)
562
+ return workflow
563
+
564
+ def update_workflow_definition(
565
+ self,
566
+ workflow_id: str,
567
+ *,
568
+ name: Optional[str] = None,
569
+ nodes: Optional[List[Dict[str, Any]]] = None,
570
+ metadata: Optional[Dict[str, Any]] = None,
571
+ workspace_id: Optional[str] = None,
572
+ ) -> Dict[str, Any]:
573
+ """Edit a stored workflow's node graph / name without losing its history."""
574
+ state = self.load_state()
575
+ workflow = next((wf for wf in _listify(state.get("workflows")) if wf.get("id") == workflow_id), None)
576
+ if not workflow or (workspace_id and self._record_workspace(workflow) != str(workspace_id)):
577
+ raise FileNotFoundError(workflow_id)
578
+ if name is not None and str(name).strip():
579
+ workflow["name"] = str(name).strip()
580
+ if nodes is not None:
581
+ workflow["nodes"] = nodes
582
+ if metadata is not None:
583
+ workflow["metadata"] = {**(workflow.get("metadata") or {}), **metadata}
584
+ workflow.setdefault("events", []).append({"type": "edited", "timestamp": _now()})
585
+ workflow["updated_at"] = _now()
586
+ self.save_state(state)
587
+ self.record_timeline_event("workflow", "workflow_edited", {"workflow_id": workflow_id})
588
+ return workflow
589
+
590
+ def list_workflows(self, query: str = "", workspace_id: Optional[str] = None) -> Dict[str, Any]:
591
+ workflows = list(reversed(self._scoped(_listify(self.load_state().get("workflows")), workspace_id)))
592
+ q = str(query or "").lower().strip()
593
+ if q:
594
+ workflows = [
595
+ wf for wf in workflows
596
+ if q in str(wf.get("name") or "").lower()
597
+ or q in json.dumps(wf.get("steps") or [], ensure_ascii=False).lower()
598
+ ]
599
+ return {"workflows": workflows}
600
+
601
+ def record_workflow_event(self, workflow_id: str, event_type: str, payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
602
+ state = self.load_state()
603
+ workflows = _listify(state.get("workflows"))
604
+ workflow = next((item for item in workflows if item.get("id") == workflow_id), None)
605
+ if not workflow:
606
+ raise FileNotFoundError(workflow_id)
607
+ event = {"type": event_type, "timestamp": _now(), "payload": payload or {}}
608
+ workflow.setdefault("events", []).append(event)
609
+ workflow["updated_at"] = _now()
610
+ self.save_state(state)
611
+ self.record_timeline_event("workflow", "workflow_event", {"workflow_id": workflow_id, "event_type": event_type})
612
+ return workflow