ltcai 4.0.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 (108) hide show
  1. package/README.md +37 -33
  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_DIGITAL_BRAIN_RECOVERY.md +86 -43
  6. package/docs/kg-schema.md +6 -2
  7. package/docs/spec-vs-impl.md +10 -10
  8. package/kg_schema.py +2 -603
  9. package/knowledge_graph.py +37 -4958
  10. package/latticeai/__init__.py +1 -1
  11. package/latticeai/api/admin.py +15 -16
  12. package/latticeai/api/agents.py +13 -6
  13. package/latticeai/api/auth.py +19 -11
  14. package/latticeai/api/invitations.py +100 -0
  15. package/latticeai/api/knowledge_graph.py +4 -11
  16. package/latticeai/api/plugins.py +3 -6
  17. package/latticeai/api/realtime.py +4 -7
  18. package/latticeai/api/static_routes.py +9 -12
  19. package/latticeai/api/ui_redirects.py +26 -0
  20. package/latticeai/api/workflow_designer.py +39 -6
  21. package/latticeai/api/workspace.py +24 -10
  22. package/latticeai/app_factory.py +88 -17
  23. package/latticeai/brain/_kg_common.py +1123 -0
  24. package/latticeai/brain/discovery.py +1455 -0
  25. package/latticeai/brain/documents.py +218 -0
  26. package/latticeai/brain/ingest.py +644 -0
  27. package/latticeai/brain/projection.py +561 -0
  28. package/latticeai/brain/provenance.py +401 -0
  29. package/latticeai/brain/retrieval.py +1316 -0
  30. package/latticeai/brain/schema.py +640 -0
  31. package/latticeai/brain/store.py +216 -0
  32. package/latticeai/brain/write_master.py +225 -0
  33. package/latticeai/core/invitations.py +131 -0
  34. package/latticeai/core/marketplace.py +1 -1
  35. package/latticeai/core/multi_agent.py +1 -1
  36. package/latticeai/core/policy.py +54 -0
  37. package/latticeai/core/realtime.py +65 -44
  38. package/latticeai/core/sessions.py +31 -5
  39. package/latticeai/core/users.py +147 -0
  40. package/latticeai/core/workspace_os.py +420 -20
  41. package/latticeai/services/agent_runtime.py +242 -4
  42. package/latticeai/services/run_executor.py +328 -0
  43. package/latticeai/services/workspace_service.py +27 -19
  44. package/package.json +2 -14
  45. package/scripts/lint_v3.mjs +23 -0
  46. package/static/v3/asset-manifest.json +21 -14
  47. package/static/v3/js/{app.356e6452.js → app.c5c80c46.js} +1 -1
  48. package/static/v3/js/core/{api.7a308b89.js → api.ba0fbf14.js} +58 -1
  49. package/static/v3/js/core/api.js +57 -0
  50. package/static/v3/js/core/i18n.880e1fec.js +575 -0
  51. package/static/v3/js/core/i18n.js +575 -0
  52. package/static/v3/js/core/routes.37522821.js +101 -0
  53. package/static/v3/js/core/routes.js +71 -63
  54. package/static/v3/js/core/{shell.a1657f20.js → shell.e3f6bbfa.js} +67 -38
  55. package/static/v3/js/core/shell.js +65 -36
  56. package/static/v3/js/core/{store.204a08b2.js → store.7b2aa044.js} +10 -0
  57. package/static/v3/js/core/store.js +10 -0
  58. package/static/v3/js/views/account.eff40715.js +143 -0
  59. package/static/v3/js/views/account.js +143 -0
  60. package/static/v3/js/views/activity.0d271ef9.js +67 -0
  61. package/static/v3/js/views/activity.js +67 -0
  62. package/static/v3/js/views/{admin-users.03bac88c.js → admin-users.f7ac7b43.js} +4 -6
  63. package/static/v3/js/views/admin-users.js +4 -6
  64. package/static/v3/js/views/{agents.014d0b74.js → agents.17c5288d.js} +35 -12
  65. package/static/v3/js/views/agents.js +35 -12
  66. package/static/v3/js/views/{chat.e6dd7dd0.js → chat.e250e2cc.js} +23 -0
  67. package/static/v3/js/views/chat.js +23 -0
  68. package/static/v3/js/views/{knowledge-graph.5e40cbeb.js → knowledge-graph.4d09c537.js} +27 -7
  69. package/static/v3/js/views/knowledge-graph.js +27 -7
  70. package/static/v3/js/views/network.52a4f181.js +97 -0
  71. package/static/v3/js/views/network.js +97 -0
  72. package/static/v3/js/views/{planning.9ac3e313.js → planning.4876fd77.js} +26 -5
  73. package/static/v3/js/views/planning.js +26 -5
  74. package/static/v3/js/views/runs.b63b2afa.js +144 -0
  75. package/static/v3/js/views/runs.js +144 -0
  76. package/static/v3/js/views/{settings.8631fa5e.js → settings.b7140634.js} +7 -8
  77. package/static/v3/js/views/settings.js +7 -8
  78. package/static/v3/js/views/snapshots.6f5db095.js +135 -0
  79. package/static/v3/js/views/snapshots.js +135 -0
  80. package/static/v3/js/views/{workflows.26c57290.js → workflows.7752225a.js} +87 -2
  81. package/static/v3/js/views/workflows.js +87 -2
  82. package/static/v3/js/views/workspace-admin.c466029b.js +156 -0
  83. package/static/v3/js/views/workspace-admin.js +156 -0
  84. package/static/account.html +0 -113
  85. package/static/activity.html +0 -73
  86. package/static/admin.html +0 -486
  87. package/static/agents.html +0 -139
  88. package/static/chat.html +0 -841
  89. package/static/css/reference/account.css +0 -439
  90. package/static/css/reference/admin.css +0 -610
  91. package/static/css/reference/base.css +0 -1661
  92. package/static/css/reference/chat.css +0 -4623
  93. package/static/css/reference/graph.css +0 -1016
  94. package/static/css/responsive.css +0 -861
  95. package/static/graph.html +0 -122
  96. package/static/platform.css +0 -104
  97. package/static/plugins.html +0 -136
  98. package/static/scripts/account.js +0 -238
  99. package/static/scripts/admin.js +0 -1614
  100. package/static/scripts/chat.js +0 -5081
  101. package/static/scripts/graph.js +0 -1804
  102. package/static/scripts/platform.js +0 -64
  103. package/static/scripts/ux.js +0 -167
  104. package/static/scripts/workspace.js +0 -948
  105. package/static/v3/js/core/routes.7222343d.js +0 -93
  106. package/static/workflows.html +0 -146
  107. package/static/workspace.css +0 -1121
  108. 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 = "4.0.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:
@@ -903,7 +1046,6 @@ class WorkspaceOSStore:
903
1046
  **trace,
904
1047
  }
905
1048
  state.setdefault("traces", []).append(record)
906
- state["traces"] = state["traces"][-200:]
907
1049
  self.save_state(state)
908
1050
  self.record_timeline_event("graph", "answer_trace", {"trace_id": trace_id, "conversation_id": conversation_id})
909
1051
  return record
@@ -1045,7 +1187,6 @@ class WorkspaceOSStore:
1045
1187
  "indexed_folder_count": len(local_sources.get("sources") or []),
1046
1188
  }
1047
1189
  state.setdefault("snapshots", []).append(meta)
1048
- state["snapshots"] = state["snapshots"][-200:]
1049
1190
  self.save_state(state)
1050
1191
  self.record_timeline_event("snapshot", "snapshot_saved", {"snapshot_id": snapshot_id, "name": name})
1051
1192
  return {"snapshot": meta}
@@ -1089,6 +1230,52 @@ class WorkspaceOSStore:
1089
1230
  self.record_timeline_event("snapshot", "snapshot_exported", {"snapshot_id": snapshot_id, "path": str(export_path)})
1090
1231
  return {"snapshot_id": snapshot_id, "export_path": str(export_path), "bytes": export_path.stat().st_size}
1091
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
+
1092
1279
  def compare_snapshots(self, before_id: str, after_id: str) -> Dict[str, Any]:
1093
1280
  before = self.get_snapshot(before_id)
1094
1281
  after = self.get_snapshot(after_id)
@@ -1207,7 +1394,7 @@ class WorkspaceOSStore:
1207
1394
  record["graph_error"] = str(exc)
1208
1395
  if existing is None:
1209
1396
  memories.append(record)
1210
- state["memories"] = memories[-500:]
1397
+ state["memories"] = memories
1211
1398
  self.save_state(state)
1212
1399
  self.record_timeline_event("memory", "memory_upserted", {"memory_id": memory_id, "kind": kind}, workspace_id=record.get("workspace_id"))
1213
1400
  return record
@@ -1283,7 +1470,6 @@ class WorkspaceOSStore:
1283
1470
  "created_at": _now(),
1284
1471
  }
1285
1472
  state.setdefault("memory_snapshots", []).append(snapshot)
1286
- state["memory_snapshots"] = state["memory_snapshots"][-200:]
1287
1473
  self.save_state(state)
1288
1474
  self.record_timeline_event("memory", "memory_snapshot", {"snapshot_id": snapshot["id"], "memory_count": len(memories)}, workspace_id=scope)
1289
1475
  return snapshot
@@ -1371,9 +1557,8 @@ class WorkspaceOSStore:
1371
1557
  "workspace_id": resolved_workspace,
1372
1558
  }
1373
1559
  stored_handoffs.append(stored)
1374
- state["handoffs"] = stored_handoffs[-500:]
1560
+ state["handoffs"] = stored_handoffs
1375
1561
  state.setdefault("agent_runs", []).append(run)
1376
- state["agent_runs"] = state["agent_runs"][-300:]
1377
1562
  self.save_state(state)
1378
1563
  self._emit_replayable_timeline_events(area="agent", run_id=run["id"], timeline=run["timeline"], workspace_id=resolved_workspace)
1379
1564
  if status == "failed":
@@ -1381,6 +1566,93 @@ class WorkspaceOSStore:
1381
1566
  self.record_timeline_event("agent", "agent_run", {"run_id": run["id"], "agent_id": agent_id, "status": status}, workspace_id=resolved_workspace)
1382
1567
  return run
1383
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
+
1384
1656
  def get_agent_run(self, run_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
1385
1657
  run = next((item for item in _listify(self.load_state().get("agent_runs")) if item.get("id") == run_id), None)
1386
1658
  if not run or (workspace_id and self._record_workspace(run) != str(workspace_id)):
@@ -1433,7 +1705,6 @@ class WorkspaceOSStore:
1433
1705
  except Exception as exc:
1434
1706
  workflow["graph_error"] = str(exc)
1435
1707
  state.setdefault("workflows", []).append(workflow)
1436
- state["workflows"] = state["workflows"][-300:]
1437
1708
  self.save_state(state)
1438
1709
  self.record_timeline_event("workflow", "workflow_created", {"workflow_id": workflow["id"], "name": workflow["name"]})
1439
1710
  return workflow
@@ -1488,7 +1759,6 @@ class WorkspaceOSStore:
1488
1759
  except Exception as exc:
1489
1760
  run["graph_error"] = str(exc)
1490
1761
  state.setdefault("workflow_runs", []).append(run)
1491
- state["workflow_runs"] = state["workflow_runs"][-300:]
1492
1762
  # Attach the run id to the workflow's event log for cross-linking.
1493
1763
  for wf in _listify(state.get("workflows")):
1494
1764
  if wf.get("id") == workflow_id:
@@ -1505,6 +1775,85 @@ class WorkspaceOSStore:
1505
1775
  self.record_timeline_event("workflow", "workflow_run", {"run_id": run["id"], "workflow_id": workflow_id, "status": status}, workspace_id=resolved_workspace)
1506
1776
  return run
1507
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
+
1508
1857
  def list_workflow_runs(self, workflow_id: Optional[str] = None, limit: int = 50, workspace_id: Optional[str] = None) -> Dict[str, Any]:
1509
1858
  runs = self._scoped(_listify(self.load_state().get("workflow_runs")), workspace_id)
1510
1859
  if workflow_id:
@@ -1532,6 +1881,58 @@ class WorkspaceOSStore:
1532
1881
  raise FileNotFoundError(run_id)
1533
1882
  return run
1534
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
+
1535
1936
  @staticmethod
1536
1937
  def _replay_frames(run: Dict[str, Any], *, kind: str) -> List[Dict[str, Any]]:
1537
1938
  frames = []
@@ -1747,7 +2148,6 @@ class WorkspaceOSStore:
1747
2148
  **activity,
1748
2149
  }
1749
2150
  config.setdefault("activities", []).append(record)
1750
- config["activities"] = config["activities"][-500:]
1751
2151
  if graph is not None:
1752
2152
  try:
1753
2153
  graph.ingest_event(