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
@@ -11,6 +11,7 @@ from __future__ import annotations
11
11
  import json
12
12
  import os
13
13
  import shutil
14
+ import sqlite3
14
15
  import zipfile
15
16
  from collections import deque
16
17
  from datetime import datetime
@@ -18,7 +19,7 @@ from pathlib import Path
18
19
  from typing import Any, Callable, Dict, Iterable, List, Optional
19
20
 
20
21
 
21
- WORKSPACE_OS_VERSION = "3.6.0"
22
+ WORKSPACE_OS_VERSION = "4.0.1"
22
23
 
23
24
  # Workspace types separate single-user Personal workspaces from shared
24
25
  # Organization workspaces. Both keep the same local-first JSON store; the type
@@ -92,8 +93,12 @@ EXECUTION_EVENT_TYPES = {
92
93
  "plugin_completed",
93
94
  "execution_failed",
94
95
  "execution_cancelled",
96
+ "execution_interrupted",
95
97
  }
96
98
 
99
+ RUN_ACTIVE_STATUSES = {"queued", "running", "in_progress", "retrying", "cancelling"}
100
+ RUN_TERMINAL_STATUSES = {"ok", "retried_ok", "failed", "rejected", "cancelled", "interrupted", "partial"}
101
+
97
102
  DEFAULT_AGENTS = [
98
103
  {
99
104
  "id": "agent:planner",
@@ -173,6 +178,52 @@ def _listify(value: Any) -> List[Any]:
173
178
  return value if isinstance(value, list) else []
174
179
 
175
180
 
181
+ def _snapshot_graph_import_payload(graph_payload: Dict[str, Any], *, workspace_id: Optional[str]) -> Dict[str, Any]:
182
+ """Convert the UI graph snapshot shape into the logical import artifact."""
183
+
184
+ nodes = []
185
+ for node in _listify((graph_payload or {}).get("nodes")):
186
+ node_id = node.get("id")
187
+ if not node_id:
188
+ continue
189
+ metadata = node.get("metadata") if isinstance(node.get("metadata"), dict) else {}
190
+ raw = node.get("raw") if isinstance(node.get("raw"), dict) else {}
191
+ if workspace_id and not metadata.get("workspace_id"):
192
+ metadata = {**metadata, "workspace_id": workspace_id}
193
+ nodes.append({
194
+ "id": node_id,
195
+ "type": node.get("type") or "Concept",
196
+ "title": node.get("title") or node.get("label") or node_id,
197
+ "summary": node.get("summary") or "",
198
+ "metadata_json": json.dumps(metadata, ensure_ascii=False),
199
+ "raw_json": json.dumps(raw, ensure_ascii=False),
200
+ })
201
+
202
+ edges = []
203
+ for edge in _listify((graph_payload or {}).get("edges")):
204
+ source = edge.get("from_node") or edge.get("from") or edge.get("source")
205
+ target = edge.get("to_node") or edge.get("to") or edge.get("target")
206
+ if not source or not target:
207
+ continue
208
+ edges.append({
209
+ "from_node": source,
210
+ "to_node": target,
211
+ "type": edge.get("type") or "related_to",
212
+ "weight": edge.get("weight") or 1.0,
213
+ "metadata_json": json.dumps(edge.get("metadata") or {}, ensure_ascii=False),
214
+ })
215
+
216
+ return {
217
+ "header": {"graph_schema_version": 1, "workspace_id": workspace_id, "source": "workspace_snapshot"},
218
+ "nodes": nodes,
219
+ "edges": edges,
220
+ "chunks": [],
221
+ "knowledge_sources": [],
222
+ "provenance": [],
223
+ "counts": {"nodes": len(nodes), "edges": len(edges)},
224
+ }
225
+
226
+
176
227
  def _parse_iso(value: Optional[str]) -> Optional[datetime]:
177
228
  if not value:
178
229
  return None
@@ -188,6 +239,7 @@ class WorkspaceOSStore:
188
239
  def __init__(self, data_dir: Path | str, *, event_sink: Optional[Callable[[Dict[str, Any]], Any]] = None):
189
240
  self.data_dir = Path(data_dir).expanduser()
190
241
  self.state_path = self.data_dir / "workspace_os.json"
242
+ self.sqlite_path = self.data_dir / "knowledge_graph.sqlite"
191
243
  self.snapshots_dir = self.data_dir / "workspace_snapshots"
192
244
  self.exports_dir = self.data_dir / "workspace_exports"
193
245
  self.data_dir.mkdir(parents=True, exist_ok=True)
@@ -198,6 +250,59 @@ class WorkspaceOSStore:
198
250
  # Defaults to None → zero behavior change for existing callers/tests.
199
251
  self.event_sink = event_sink
200
252
 
253
+ def _connect_state_db(self) -> sqlite3.Connection:
254
+ conn = sqlite3.connect(self.sqlite_path)
255
+ conn.execute("PRAGMA journal_mode=WAL")
256
+ conn.execute(
257
+ "CREATE TABLE IF NOT EXISTS workspace_os_state ("
258
+ "id TEXT PRIMARY KEY, state_json TEXT NOT NULL, updated_at TEXT NOT NULL)"
259
+ )
260
+ conn.execute(
261
+ "CREATE TABLE IF NOT EXISTS workspace_os_meta ("
262
+ "key TEXT PRIMARY KEY, value TEXT NOT NULL)"
263
+ )
264
+ return conn
265
+
266
+ def _load_sqlite_state(self) -> Optional[Dict[str, Any]]:
267
+ try:
268
+ with self._connect_state_db() as conn:
269
+ row = conn.execute(
270
+ "SELECT state_json FROM workspace_os_state WHERE id='current'"
271
+ ).fetchone()
272
+ if not row:
273
+ return None
274
+ data = json.loads(row[0])
275
+ return data if isinstance(data, dict) else None
276
+ except Exception:
277
+ return None
278
+
279
+ def _save_sqlite_state(self, state: Dict[str, Any]) -> None:
280
+ payload = json.dumps(state, ensure_ascii=False)
281
+ with self._connect_state_db() as conn:
282
+ conn.execute(
283
+ "INSERT OR REPLACE INTO workspace_os_state(id, state_json, updated_at) VALUES('current', ?, ?)",
284
+ (payload, state.get("updated_at") or _now()),
285
+ )
286
+
287
+ def _import_json_state_once(self, default: Dict[str, Any]) -> Dict[str, Any]:
288
+ if not self.state_path.exists():
289
+ return default
290
+ try:
291
+ loaded = json.loads(self.state_path.read_text(encoding="utf-8"))
292
+ if not isinstance(loaded, dict):
293
+ return default
294
+ except Exception:
295
+ return default
296
+ try:
297
+ backup = self.state_path.with_name(
298
+ f"{self.state_path.name}.pre-sqlite.{_now().replace(':', '-')}.json"
299
+ )
300
+ if not any(self.state_path.parent.glob(f"{self.state_path.name}.pre-sqlite.*.json")):
301
+ shutil.copy2(self.state_path, backup)
302
+ except Exception:
303
+ pass
304
+ return _deep_merge(default, loaded)
305
+
201
306
  @staticmethod
202
307
  def _new_workspace_record(
203
308
  *,
@@ -272,6 +377,47 @@ class WorkspaceOSStore:
272
377
  state["active_workspace"] = DEFAULT_WORKSPACE_ID
273
378
  return state
274
379
 
380
+ def migrate_workspace_identities(self, email_to_id: Dict[str, str]) -> int:
381
+ """Rewrite workspace membership identities from legacy emails to UUIDs.
382
+
383
+ The migration is additive and in-place: workspace records, memberships,
384
+ and owner fields keep their shape, only identity string values change.
385
+ """
386
+ if not email_to_id:
387
+ return 0
388
+ normalized = {str(email).strip().lower(): user_id for email, user_id in email_to_id.items() if user_id}
389
+ state = self.load_state()
390
+ changed = 0
391
+ for ws in (state.get("workspaces") or {}).values():
392
+ owner = str(ws.get("owner_user_id") or "").strip().lower()
393
+ if owner in normalized and ws.get("owner_user_id") != normalized[owner]:
394
+ ws["owner_user_id"] = normalized[owner]
395
+ changed += 1
396
+ for member in _listify(ws.get("members")):
397
+ member_id = str(member.get("user_id") or "").strip().lower()
398
+ if member_id in normalized and member.get("user_id") != normalized[member_id]:
399
+ member["user_id"] = normalized[member_id]
400
+ member["updated_at"] = _now()
401
+ changed += 1
402
+ members = _listify(ws.get("members"))
403
+ deduped = []
404
+ seen_members = set()
405
+ for member in members:
406
+ member_id = member.get("user_id")
407
+ if member_id and member_id in seen_members:
408
+ changed += 1
409
+ continue
410
+ if member_id:
411
+ seen_members.add(member_id)
412
+ deduped.append(member)
413
+ if len(deduped) != len(members):
414
+ ws["members"] = deduped
415
+ if changed:
416
+ state["updated_at"] = _now()
417
+ self.save_state(state)
418
+ self.record_timeline_event("workspace", "identity_uuid_migrated", {"records": changed})
419
+ return changed
420
+
275
421
  def _default_state(self) -> Dict[str, Any]:
276
422
  return {
277
423
  "version": WORKSPACE_OS_VERSION,
@@ -351,23 +497,21 @@ class WorkspaceOSStore:
351
497
 
352
498
  def load_state(self) -> Dict[str, Any]:
353
499
  default = self._default_state()
354
- if not self.state_path.exists():
355
- self.save_state(default)
356
- return default
357
- try:
358
- loaded = json.loads(self.state_path.read_text(encoding="utf-8"))
359
- if not isinstance(loaded, dict):
360
- loaded = {}
361
- except Exception:
362
- loaded = {}
500
+ loaded = self._load_sqlite_state()
501
+ imported = loaded is None
502
+ if loaded is None:
503
+ loaded = self._import_json_state_once(default)
363
504
  state = _deep_merge(default, loaded)
364
505
  state["version"] = WORKSPACE_OS_VERSION
365
506
  self._migrate_workspaces(state)
507
+ if imported:
508
+ self.save_state(state)
366
509
  return state
367
510
 
368
511
  def save_state(self, state: Dict[str, Any]) -> Dict[str, Any]:
369
512
  state["version"] = WORKSPACE_OS_VERSION
370
513
  state["updated_at"] = _now()
514
+ self._save_sqlite_state(state)
371
515
  _atomic_write_json(self.state_path, state)
372
516
  return state
373
517
 
@@ -382,7 +526,6 @@ class WorkspaceOSStore:
382
526
  "payload": payload,
383
527
  }
384
528
  state.setdefault("timeline", []).append(event)
385
- state["timeline"] = state["timeline"][-500:]
386
529
  self.save_state(state)
387
530
  if self.event_sink is not None:
388
531
  try:
@@ -430,7 +573,10 @@ class WorkspaceOSStore:
430
573
  "version": WORKSPACE_OS_VERSION,
431
574
  "identity": state.get("identity"),
432
575
  "active_workspace": state.get("active_workspace"),
433
- "workspaces": state.get("workspaces"),
576
+ # The raw workspace registry (with member lists) must not leak to
577
+ # non-members; WorkspaceService.summary() adds a membership-filtered
578
+ # "workspace_registry" instead.
579
+ "workspace_count": len(state.get("workspaces") or {}),
434
580
  "navigation": list(WORKSPACE_AREAS),
435
581
  "feature_flags": state.get("feature_flags"),
436
582
  "updated_at": state.get("updated_at"),
@@ -900,7 +1046,6 @@ class WorkspaceOSStore:
900
1046
  **trace,
901
1047
  }
902
1048
  state.setdefault("traces", []).append(record)
903
- state["traces"] = state["traces"][-200:]
904
1049
  self.save_state(state)
905
1050
  self.record_timeline_event("graph", "answer_trace", {"trace_id": trace_id, "conversation_id": conversation_id})
906
1051
  return record
@@ -1042,7 +1187,6 @@ class WorkspaceOSStore:
1042
1187
  "indexed_folder_count": len(local_sources.get("sources") or []),
1043
1188
  }
1044
1189
  state.setdefault("snapshots", []).append(meta)
1045
- state["snapshots"] = state["snapshots"][-200:]
1046
1190
  self.save_state(state)
1047
1191
  self.record_timeline_event("snapshot", "snapshot_saved", {"snapshot_id": snapshot_id, "name": name})
1048
1192
  return {"snapshot": meta}
@@ -1086,6 +1230,52 @@ class WorkspaceOSStore:
1086
1230
  self.record_timeline_event("snapshot", "snapshot_exported", {"snapshot_id": snapshot_id, "path": str(export_path)})
1087
1231
  return {"snapshot_id": snapshot_id, "export_path": str(export_path), "bytes": export_path.stat().st_size}
1088
1232
 
1233
+ def restore_snapshot(
1234
+ self,
1235
+ snapshot_id: str,
1236
+ *,
1237
+ graph: Any,
1238
+ workspace_id: Optional[str] = None,
1239
+ user_email: Optional[str] = None,
1240
+ ) -> Dict[str, Any]:
1241
+ """Restore a snapshot additively, preserving all current user data.
1242
+
1243
+ v4 snapshots are immutable checkpoints. Restoring one must not delete
1244
+ newer graph nodes, chat history, memories, workspaces, or settings, so
1245
+ this operation imports the snapshot graph in ``merge`` mode and records a
1246
+ durable restore event. It is a real restore path for lost/missing graph
1247
+ data, with rollback safety because current state remains intact.
1248
+ """
1249
+
1250
+ snapshot = self.get_snapshot(snapshot_id)
1251
+ scope = self._resolve_scope(workspace_id or snapshot.get("workspace_id"))
1252
+ if graph is None or not hasattr(graph, "import_graph_data"):
1253
+ raise ValueError("knowledge graph import is required for snapshot restore")
1254
+ artifact = _snapshot_graph_import_payload(snapshot.get("graph") or {}, workspace_id=scope)
1255
+ import_result = graph.import_graph_data(artifact, mode="merge", dry_run=False)
1256
+ restore_id = f"restore-{datetime.now().strftime('%Y%m%d%H%M%S')}-{_json_hash([snapshot_id, scope, user_email, _now()])[:10]}"
1257
+ record = {
1258
+ "id": restore_id,
1259
+ "snapshot_id": snapshot_id,
1260
+ "workspace_id": scope,
1261
+ "restored_at": _now(),
1262
+ "restored_by": user_email,
1263
+ "mode": "merge",
1264
+ "graph": import_result,
1265
+ "settings_preserved": True,
1266
+ "chat_preserved": True,
1267
+ }
1268
+ state = self.load_state()
1269
+ state.setdefault("snapshot_restores", []).append(record)
1270
+ self.save_state(state)
1271
+ self.record_timeline_event(
1272
+ "snapshot",
1273
+ "snapshot_restored",
1274
+ {"snapshot_id": snapshot_id, "restore_id": restore_id, "mode": "merge", "graph": import_result},
1275
+ workspace_id=scope,
1276
+ )
1277
+ return {"restored": True, "restore": record}
1278
+
1089
1279
  def compare_snapshots(self, before_id: str, after_id: str) -> Dict[str, Any]:
1090
1280
  before = self.get_snapshot(before_id)
1091
1281
  after = self.get_snapshot(after_id)
@@ -1204,7 +1394,7 @@ class WorkspaceOSStore:
1204
1394
  record["graph_error"] = str(exc)
1205
1395
  if existing is None:
1206
1396
  memories.append(record)
1207
- state["memories"] = memories[-500:]
1397
+ state["memories"] = memories
1208
1398
  self.save_state(state)
1209
1399
  self.record_timeline_event("memory", "memory_upserted", {"memory_id": memory_id, "kind": kind}, workspace_id=record.get("workspace_id"))
1210
1400
  return record
@@ -1229,15 +1419,26 @@ class WorkspaceOSStore:
1229
1419
  ]
1230
1420
  return {"query": query, "memories": memories[: max(1, min(limit, 100))]}
1231
1421
 
1422
+ def get_memory(self, memory_id: str) -> Dict[str, Any]:
1423
+ record = next(
1424
+ (item for item in _listify(self.load_state().get("memories")) if item.get("id") == memory_id),
1425
+ None,
1426
+ )
1427
+ if record is None:
1428
+ raise FileNotFoundError(memory_id)
1429
+ return record
1430
+
1232
1431
  def delete_memory(self, memory_id: str) -> Dict[str, Any]:
1233
1432
  state = self.load_state()
1234
1433
  memories = _listify(state.get("memories"))
1235
- kept = [item for item in memories if item.get("id") != memory_id]
1236
- if len(kept) == len(memories):
1434
+ target = next((item for item in memories if item.get("id") == memory_id), None)
1435
+ if target is None:
1237
1436
  raise FileNotFoundError(memory_id)
1238
- state["memories"] = kept
1437
+ state["memories"] = [item for item in memories if item.get("id") != memory_id]
1239
1438
  self.save_state(state)
1240
- self.record_timeline_event("memory", "memory_deleted", {"memory_id": memory_id})
1439
+ self.record_timeline_event(
1440
+ "memory", "memory_deleted", {"memory_id": memory_id}, workspace_id=target.get("workspace_id")
1441
+ )
1241
1442
  return {"status": "ok", "memory_id": memory_id}
1242
1443
 
1243
1444
  def create_memory_snapshot(
@@ -1269,7 +1470,6 @@ class WorkspaceOSStore:
1269
1470
  "created_at": _now(),
1270
1471
  }
1271
1472
  state.setdefault("memory_snapshots", []).append(snapshot)
1272
- state["memory_snapshots"] = state["memory_snapshots"][-200:]
1273
1473
  self.save_state(state)
1274
1474
  self.record_timeline_event("memory", "memory_snapshot", {"snapshot_id": snapshot["id"], "memory_count": len(memories)}, workspace_id=scope)
1275
1475
  return snapshot
@@ -1306,12 +1506,15 @@ class WorkspaceOSStore:
1306
1506
  memory_snapshots: Optional[List[Dict[str, Any]]] = None,
1307
1507
  graph: Any = None,
1308
1508
  workspace_id: Optional[str] = None,
1509
+ mode: str = "simulation",
1309
1510
  ) -> Dict[str, Any]:
1310
1511
  state = self.load_state()
1311
1512
  resolved_workspace = self._resolve_scope(workspace_id, state)
1312
1513
  run = {
1313
1514
  "id": f"agent-run-{_json_hash([agent_id, input_text, output_text, _now()])[:16]}",
1515
+ "record_schema_version": 2,
1314
1516
  "agent_id": agent_id,
1517
+ "mode": mode,
1315
1518
  "status": status,
1316
1519
  "input": input_text,
1317
1520
  "output_preview": output_text[:1000],
@@ -1328,14 +1531,19 @@ class WorkspaceOSStore:
1328
1531
  "memory_snapshots": memory_snapshots or [],
1329
1532
  "created_at": _now(),
1330
1533
  }
1331
- if graph is not None:
1534
+ if mode == "simulation":
1535
+ # Simulated runs are replay scaffolding, not experiences — they must
1536
+ # never enter the knowledge graph as real provenance.
1537
+ run["graph_node_id"] = None
1538
+ run["graph_skipped"] = "simulation runs are not recorded in the knowledge graph"
1539
+ elif graph is not None:
1332
1540
  try:
1333
1541
  ingested = graph.ingest_event(
1334
1542
  "AgentRun",
1335
1543
  f"{agent_id} {status}",
1336
1544
  user_email=user_email,
1337
1545
  source="workspace_os",
1338
- metadata={"run_id": run["id"], "agent_id": agent_id, "status": status},
1546
+ metadata={"run_id": run["id"], "agent_id": agent_id, "status": status, "mode": mode},
1339
1547
  )
1340
1548
  run["graph_node_id"] = ingested.get("node_id")
1341
1549
  except Exception as exc:
@@ -1349,9 +1557,8 @@ class WorkspaceOSStore:
1349
1557
  "workspace_id": resolved_workspace,
1350
1558
  }
1351
1559
  stored_handoffs.append(stored)
1352
- state["handoffs"] = stored_handoffs[-500:]
1560
+ state["handoffs"] = stored_handoffs
1353
1561
  state.setdefault("agent_runs", []).append(run)
1354
- state["agent_runs"] = state["agent_runs"][-300:]
1355
1562
  self.save_state(state)
1356
1563
  self._emit_replayable_timeline_events(area="agent", run_id=run["id"], timeline=run["timeline"], workspace_id=resolved_workspace)
1357
1564
  if status == "failed":
@@ -1359,6 +1566,93 @@ class WorkspaceOSStore:
1359
1566
  self.record_timeline_event("agent", "agent_run", {"run_id": run["id"], "agent_id": agent_id, "status": status}, workspace_id=resolved_workspace)
1360
1567
  return run
1361
1568
 
1569
+ def update_agent_run(
1570
+ self,
1571
+ run_id: str,
1572
+ *,
1573
+ workspace_id: Optional[str] = None,
1574
+ graph: Any = None,
1575
+ patch: Optional[Dict[str, Any]] = None,
1576
+ **fields: Any,
1577
+ ) -> Dict[str, Any]:
1578
+ """Patch a persisted agent run without changing its id.
1579
+
1580
+ Async execution creates a durable queued/running row before work starts,
1581
+ then updates that same row as progress, cancellation, or a terminal
1582
+ result arrives. This keeps old run lists/read APIs compatible while
1583
+ avoiding duplicate "placeholder + final" records.
1584
+ """
1585
+ updates = {**(patch or {}), **fields}
1586
+ state = self.load_state()
1587
+ run = next((item for item in _listify(state.get("agent_runs")) if item.get("id") == run_id), None)
1588
+ if run is None or (workspace_id and self._record_workspace(run) != str(workspace_id)):
1589
+ raise FileNotFoundError(run_id)
1590
+ resolved_workspace = self._record_workspace(run)
1591
+ old_timeline_len = len(run.get("timeline") or [])
1592
+
1593
+ output_text = updates.pop("output_text", None)
1594
+ if output_text is not None:
1595
+ run["output_preview"] = str(output_text)[:1000]
1596
+ for key, value in updates.items():
1597
+ run[key] = value
1598
+ status = str(run.get("status") or "")
1599
+ run["updated_at"] = _now()
1600
+ if status in RUN_TERMINAL_STATUSES:
1601
+ run.setdefault("completed_at", _now())
1602
+
1603
+ handoffs = updates.get("handoffs")
1604
+ if isinstance(handoffs, list):
1605
+ stored_handoffs = [
1606
+ item for item in _listify(state.get("handoffs"))
1607
+ if item.get("run_id") != run_id
1608
+ ]
1609
+ for handoff in handoffs:
1610
+ if isinstance(handoff, dict):
1611
+ stored_handoffs.append({**handoff, "run_id": run_id, "workspace_id": resolved_workspace})
1612
+ state["handoffs"] = stored_handoffs
1613
+
1614
+ if (
1615
+ status in RUN_TERMINAL_STATUSES
1616
+ and run.get("mode") != "simulation"
1617
+ and graph is not None
1618
+ and not run.get("graph_node_id")
1619
+ ):
1620
+ try:
1621
+ ingested = graph.ingest_event(
1622
+ "AgentRun",
1623
+ f"{run.get('agent_id')} {status}",
1624
+ user_email=run.get("user_email"),
1625
+ source="workspace_os",
1626
+ metadata={
1627
+ "run_id": run_id,
1628
+ "agent_id": run.get("agent_id"),
1629
+ "status": status,
1630
+ "mode": run.get("mode"),
1631
+ },
1632
+ )
1633
+ run["graph_node_id"] = ingested.get("node_id")
1634
+ except Exception as exc:
1635
+ run["graph_error"] = str(exc)
1636
+
1637
+ self.save_state(state)
1638
+
1639
+ timeline = run.get("timeline") or []
1640
+ if len(timeline) > old_timeline_len:
1641
+ self._emit_replayable_timeline_events(
1642
+ area="agent",
1643
+ run_id=run_id,
1644
+ timeline=timeline[old_timeline_len:],
1645
+ workspace_id=resolved_workspace,
1646
+ )
1647
+ if status == "failed":
1648
+ 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)
1649
+ elif status == "cancelled":
1650
+ 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)
1651
+ elif status == "interrupted":
1652
+ 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)
1653
+ self.record_timeline_event("agent", "agent_run_update", {"run_id": run_id, "agent_id": run.get("agent_id"), "status": status}, workspace_id=resolved_workspace)
1654
+ return run
1655
+
1362
1656
  def get_agent_run(self, run_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
1363
1657
  run = next((item for item in _listify(self.load_state().get("agent_runs")) if item.get("id") == run_id), None)
1364
1658
  if not run or (workspace_id and self._record_workspace(run) != str(workspace_id)):
@@ -1411,7 +1705,6 @@ class WorkspaceOSStore:
1411
1705
  except Exception as exc:
1412
1706
  workflow["graph_error"] = str(exc)
1413
1707
  state.setdefault("workflows", []).append(workflow)
1414
- state["workflows"] = state["workflows"][-300:]
1415
1708
  self.save_state(state)
1416
1709
  self.record_timeline_event("workflow", "workflow_created", {"workflow_id": workflow["id"], "name": workflow["name"]})
1417
1710
  return workflow
@@ -1427,14 +1720,18 @@ class WorkspaceOSStore:
1427
1720
  user_email: Optional[str] = None,
1428
1721
  graph: Any = None,
1429
1722
  workspace_id: Optional[str] = None,
1723
+ mode: str = "simulation",
1724
+ pause: Optional[Dict[str, Any]] = None,
1430
1725
  ) -> Dict[str, Any]:
1431
1726
  """Persist a Workflow Designer execution into local-first run history."""
1432
1727
  state = self.load_state()
1433
1728
  resolved_workspace = self._resolve_scope(workspace_id, state)
1434
1729
  run = {
1435
1730
  "id": f"workflow-run-{_json_hash([workflow_id, name, status, _now()])[:16]}",
1731
+ "record_schema_version": 2,
1436
1732
  "workflow_id": workflow_id,
1437
1733
  "name": name or "workflow",
1734
+ "mode": mode,
1438
1735
  "status": status,
1439
1736
  "timeline": timeline or [],
1440
1737
  "outputs": outputs or {},
@@ -1442,20 +1739,26 @@ class WorkspaceOSStore:
1442
1739
  "workspace_id": resolved_workspace,
1443
1740
  "created_at": _now(),
1444
1741
  }
1445
- if graph is not None:
1742
+ if pause:
1743
+ run["pause"] = pause
1744
+ if mode == "simulation":
1745
+ # Record-only node runners do no real work; their runs must not be
1746
+ # written into the knowledge graph as if they were real executions.
1747
+ run["graph_node_id"] = None
1748
+ run["graph_skipped"] = "simulation runs are not recorded in the knowledge graph"
1749
+ elif graph is not None:
1446
1750
  try:
1447
1751
  ingested = graph.ingest_event(
1448
1752
  "WorkflowRun",
1449
1753
  f"{run['name']} {status}",
1450
1754
  user_email=user_email,
1451
1755
  source="workspace_os",
1452
- metadata={"run_id": run["id"], "workflow_id": workflow_id, "status": status},
1756
+ metadata={"run_id": run["id"], "workflow_id": workflow_id, "status": status, "mode": mode},
1453
1757
  )
1454
1758
  run["graph_node_id"] = ingested.get("node_id")
1455
1759
  except Exception as exc:
1456
1760
  run["graph_error"] = str(exc)
1457
1761
  state.setdefault("workflow_runs", []).append(run)
1458
- state["workflow_runs"] = state["workflow_runs"][-300:]
1459
1762
  # Attach the run id to the workflow's event log for cross-linking.
1460
1763
  for wf in _listify(state.get("workflows")):
1461
1764
  if wf.get("id") == workflow_id:
@@ -1472,18 +1775,164 @@ class WorkspaceOSStore:
1472
1775
  self.record_timeline_event("workflow", "workflow_run", {"run_id": run["id"], "workflow_id": workflow_id, "status": status}, workspace_id=resolved_workspace)
1473
1776
  return run
1474
1777
 
1778
+ def update_workflow_run(
1779
+ self,
1780
+ run_id: str,
1781
+ *,
1782
+ workspace_id: Optional[str] = None,
1783
+ graph: Any = None,
1784
+ patch: Optional[Dict[str, Any]] = None,
1785
+ **fields: Any,
1786
+ ) -> Dict[str, Any]:
1787
+ """Patch a persisted workflow run in place for async execution."""
1788
+ updates = {**(patch or {}), **fields}
1789
+ state = self.load_state()
1790
+ run = next((item for item in _listify(state.get("workflow_runs")) if item.get("id") == run_id), None)
1791
+ if run is None or (workspace_id and self._record_workspace(run) != str(workspace_id)):
1792
+ raise FileNotFoundError(run_id)
1793
+ resolved_workspace = self._record_workspace(run)
1794
+ old_timeline_len = len(run.get("timeline") or [])
1795
+
1796
+ for key, value in updates.items():
1797
+ if value is None and key == "pause":
1798
+ run.pop("pause", None)
1799
+ else:
1800
+ run[key] = value
1801
+ status = str(run.get("status") or "")
1802
+ run["updated_at"] = _now()
1803
+ if status in RUN_TERMINAL_STATUSES:
1804
+ run.setdefault("completed_at", _now())
1805
+
1806
+ workflow_id = run.get("workflow_id")
1807
+ for wf in _listify(state.get("workflows")):
1808
+ if wf.get("id") == workflow_id:
1809
+ wf.setdefault("events", []).append({"type": "run_update", "timestamp": _now(), "payload": {"run_id": run_id, "status": status}})
1810
+ wf["updated_at"] = _now()
1811
+ break
1812
+
1813
+ if (
1814
+ status in RUN_TERMINAL_STATUSES
1815
+ and run.get("mode") != "simulation"
1816
+ and graph is not None
1817
+ and not run.get("graph_node_id")
1818
+ ):
1819
+ try:
1820
+ ingested = graph.ingest_event(
1821
+ "WorkflowRun",
1822
+ f"{run.get('name')} {status}",
1823
+ user_email=run.get("user_email"),
1824
+ source="workspace_os",
1825
+ metadata={
1826
+ "run_id": run_id,
1827
+ "workflow_id": workflow_id,
1828
+ "status": status,
1829
+ "mode": run.get("mode"),
1830
+ },
1831
+ )
1832
+ run["graph_node_id"] = ingested.get("node_id")
1833
+ except Exception as exc:
1834
+ run["graph_error"] = str(exc)
1835
+
1836
+ self.save_state(state)
1837
+
1838
+ timeline = run.get("timeline") or []
1839
+ if len(timeline) > old_timeline_len:
1840
+ self._emit_replayable_timeline_events(
1841
+ area="workflow",
1842
+ run_id=run_id,
1843
+ timeline=timeline[old_timeline_len:],
1844
+ workspace_id=resolved_workspace,
1845
+ )
1846
+ if status == "failed":
1847
+ 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)
1848
+ elif status in {"ok", "partial"}:
1849
+ 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)
1850
+ elif status == "cancelled":
1851
+ 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)
1852
+ elif status == "interrupted":
1853
+ 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)
1854
+ self.record_timeline_event("workflow", "workflow_run_update", {"run_id": run_id, "workflow_id": workflow_id, "status": status}, workspace_id=resolved_workspace)
1855
+ return run
1856
+
1475
1857
  def list_workflow_runs(self, workflow_id: Optional[str] = None, limit: int = 50, workspace_id: Optional[str] = None) -> Dict[str, Any]:
1476
1858
  runs = self._scoped(_listify(self.load_state().get("workflow_runs")), workspace_id)
1477
1859
  if workflow_id:
1478
1860
  runs = [run for run in runs if run.get("workflow_id") == workflow_id]
1479
1861
  return {"runs": list(reversed(runs[-max(1, min(limit, 300)):]))}
1480
1862
 
1863
+ def mark_workflow_run_resolved(
1864
+ self, run_id: str, *, resumed_run_id: str, approved: bool,
1865
+ workspace_id: Optional[str] = None,
1866
+ ) -> Dict[str, Any]:
1867
+ """Close out a paused run after its approval decision (one decision only)."""
1868
+ state = self.load_state()
1869
+ run = next((item for item in _listify(state.get("workflow_runs")) if item.get("id") == run_id), None)
1870
+ if run is None or (workspace_id and self._record_workspace(run) != str(workspace_id)):
1871
+ raise FileNotFoundError(run_id)
1872
+ run["status"] = "resumed" if approved else "denied"
1873
+ run["resolved_at"] = _now()
1874
+ run["resumed_run_id"] = resumed_run_id
1875
+ self.save_state(state)
1876
+ return run
1877
+
1481
1878
  def get_workflow_run(self, run_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
1482
1879
  run = next((item for item in _listify(self.load_state().get("workflow_runs")) if item.get("id") == run_id), None)
1483
1880
  if not run or (workspace_id and self._record_workspace(run) != str(workspace_id)):
1484
1881
  raise FileNotFoundError(run_id)
1485
1882
  return run
1486
1883
 
1884
+ def reconcile_interrupted_runs(self, *, reason: str = "server_startup") -> Dict[str, Any]:
1885
+ """Mark durable active runs as interrupted after a process restart.
1886
+
1887
+ Queued/running/cancelling rows cannot have an owning asyncio task after
1888
+ startup. Paused approval runs are intentionally left untouched so their
1889
+ durable human decision cursor remains resumable.
1890
+ """
1891
+ state = self.load_state()
1892
+ interrupted: List[Dict[str, Any]] = []
1893
+ now = _now()
1894
+ collections = (("agent_runs", "agent"), ("workflow_runs", "workflow"))
1895
+ for key, area in collections:
1896
+ for run in _listify(state.get(key)):
1897
+ status = str(run.get("status") or "")
1898
+ if status not in RUN_ACTIVE_STATUSES:
1899
+ continue
1900
+ run["status"] = "interrupted"
1901
+ run["interrupted_at"] = now
1902
+ run["interrupt_reason"] = reason
1903
+ run["updated_at"] = now
1904
+ run.setdefault("timeline", []).append({
1905
+ "event": "execution_interrupted",
1906
+ "status": "interrupted",
1907
+ "reason": reason,
1908
+ "timestamp": now,
1909
+ })
1910
+ interrupted.append({
1911
+ "kind": area,
1912
+ "run_id": run.get("id"),
1913
+ "workspace_id": self._record_workspace(run),
1914
+ "previous_status": status,
1915
+ })
1916
+ if not interrupted:
1917
+ return {"count": 0, "interrupted": []}
1918
+ self.save_state(state)
1919
+ for item in interrupted:
1920
+ area = item["kind"]
1921
+ run_id = item["run_id"]
1922
+ workspace = item.get("workspace_id")
1923
+ self._emit_execution_event(
1924
+ area=area,
1925
+ event_type="execution_interrupted",
1926
+ payload={"run_id": run_id, "reason": reason, "previous_status": item.get("previous_status")},
1927
+ workspace_id=workspace,
1928
+ )
1929
+ self.record_timeline_event(
1930
+ "system",
1931
+ "startup_reconciliation",
1932
+ {"interrupted_runs": len(interrupted), "reason": reason},
1933
+ )
1934
+ return {"count": len(interrupted), "interrupted": interrupted}
1935
+
1487
1936
  @staticmethod
1488
1937
  def _replay_frames(run: Dict[str, Any], *, kind: str) -> List[Dict[str, Any]]:
1489
1938
  frames = []
@@ -1699,7 +2148,6 @@ class WorkspaceOSStore:
1699
2148
  **activity,
1700
2149
  }
1701
2150
  config.setdefault("activities", []).append(record)
1702
- config["activities"] = config["activities"][-500:]
1703
2151
  if graph is not None:
1704
2152
  try:
1705
2153
  graph.ingest_event(