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
@@ -9,19 +9,47 @@ exported or compared without mutating the live knowledge graph.
9
9
  from __future__ import annotations
10
10
 
11
11
  import json
12
- import os
13
12
  import shutil
14
13
  import sqlite3
15
- import zipfile
16
14
  from collections import deque
17
- from datetime import datetime
18
15
  from pathlib import Path
19
16
  from typing import Any, Callable, Dict, Iterable, List, Optional
20
17
 
21
- from lattice_brain.runtime.contracts import realtime_event_contract, run_record_contract, workflow_run_contract
22
-
18
+ # Extracted pure helpers (keeps this module smaller and focused on the store).
19
+ from .workspace_os_utils import (
20
+ _atomic_write_json,
21
+ _deep_merge,
22
+ _json_hash,
23
+ _listify,
24
+ _now,
25
+ _safe_slug,
26
+ remove_skill_directory,
27
+ )
28
+ from .workspace_permissions import WorkspacePermissionManager, _member_role # type: ignore
29
+ from .workspace_timeline import WorkspaceTimeline
30
+ from .workspace_plugins import WorkspacePluginManager
31
+ from .workspace_memory import WorkspaceMemory
32
+ from .workspace_snapshots import WorkspaceSnapshots
33
+ from .workspace_graph_trace import WorkspaceGraphTrace
34
+ from .workspace_runs import WorkspaceRuns
35
+ from .workspace_skills import WorkspaceSkills
36
+
37
+ __all__ = [
38
+ "WORKSPACE_OS_VERSION",
39
+ "WORKSPACE_TYPES",
40
+ "DEFAULT_WORKSPACE_ID",
41
+ "WORKSPACE_ROLES",
42
+ "WORKSPACE_PERMISSIONS",
43
+ "ROLE_PERMISSIONS",
44
+ "WORKSPACE_AREAS",
45
+ "ONBOARDING_STEPS",
46
+ "MEMORY_KINDS",
47
+ "EXECUTION_EVENT_TYPES",
48
+ "WorkspaceOSStore",
49
+ "remove_skill_directory",
50
+ ]
23
51
 
24
- WORKSPACE_OS_VERSION = "8.0.0"
52
+ WORKSPACE_OS_VERSION = "8.2.0"
25
53
 
26
54
  # Workspace types separate single-user Personal workspaces from shared
27
55
  # Organization workspaces. Both keep the same local-first JSON store; the type
@@ -140,101 +168,6 @@ DEFAULT_AGENTS = [
140
168
  ]
141
169
 
142
170
 
143
- def _now() -> str:
144
- return datetime.now().isoformat(timespec="seconds")
145
-
146
-
147
- def _safe_slug(raw: str) -> str:
148
- value = "".join(ch if ch.isalnum() or ch in "-_." else "-" for ch in str(raw or "").strip())
149
- value = "-".join(part for part in value.split("-") if part)
150
- return (value or "item")[:96]
151
-
152
-
153
- def _json_hash(value: Any) -> str:
154
- import hashlib
155
-
156
- payload = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
157
- return hashlib.sha256(payload.encode("utf-8", errors="replace")).hexdigest()
158
-
159
-
160
- def _deep_merge(default: Any, loaded: Any) -> Any:
161
- if isinstance(default, dict) and isinstance(loaded, dict):
162
- merged = {key: _deep_merge(value, loaded.get(key)) for key, value in default.items()}
163
- for key, value in loaded.items():
164
- if key not in merged:
165
- merged[key] = value
166
- return merged
167
- if loaded is None:
168
- return default
169
- return loaded
170
-
171
-
172
- def _atomic_write_json(path: Path, payload: Dict[str, Any]) -> None:
173
- path.parent.mkdir(parents=True, exist_ok=True)
174
- tmp_path = path.with_suffix(path.suffix + ".tmp")
175
- tmp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
176
- os.replace(tmp_path, path)
177
-
178
-
179
- def _listify(value: Any) -> List[Any]:
180
- return value if isinstance(value, list) else []
181
-
182
-
183
- def _snapshot_graph_import_payload(graph_payload: Dict[str, Any], *, workspace_id: Optional[str]) -> Dict[str, Any]:
184
- """Convert the UI graph snapshot shape into the logical import artifact."""
185
-
186
- nodes = []
187
- for node in _listify((graph_payload or {}).get("nodes")):
188
- node_id = node.get("id")
189
- if not node_id:
190
- continue
191
- metadata = node.get("metadata") if isinstance(node.get("metadata"), dict) else {}
192
- raw = node.get("raw") if isinstance(node.get("raw"), dict) else {}
193
- if workspace_id and not metadata.get("workspace_id"):
194
- metadata = {**metadata, "workspace_id": workspace_id}
195
- nodes.append({
196
- "id": node_id,
197
- "type": node.get("type") or "Concept",
198
- "title": node.get("title") or node.get("label") or node_id,
199
- "summary": node.get("summary") or "",
200
- "metadata_json": json.dumps(metadata, ensure_ascii=False),
201
- "raw_json": json.dumps(raw, ensure_ascii=False),
202
- })
203
-
204
- edges = []
205
- for edge in _listify((graph_payload or {}).get("edges")):
206
- source = edge.get("from_node") or edge.get("from") or edge.get("source")
207
- target = edge.get("to_node") or edge.get("to") or edge.get("target")
208
- if not source or not target:
209
- continue
210
- edges.append({
211
- "from_node": source,
212
- "to_node": target,
213
- "type": edge.get("type") or "related_to",
214
- "weight": edge.get("weight") or 1.0,
215
- "metadata_json": json.dumps(edge.get("metadata") or {}, ensure_ascii=False),
216
- })
217
-
218
- return {
219
- "header": {"graph_schema_version": 1, "workspace_id": workspace_id, "source": "workspace_snapshot"},
220
- "nodes": nodes,
221
- "edges": edges,
222
- "chunks": [],
223
- "knowledge_sources": [],
224
- "provenance": [],
225
- "counts": {"nodes": len(nodes), "edges": len(edges)},
226
- }
227
-
228
-
229
- def _parse_iso(value: Optional[str]) -> Optional[datetime]:
230
- if not value:
231
- return None
232
- try:
233
- return datetime.fromisoformat(str(value))
234
- except (TypeError, ValueError):
235
- return None
236
-
237
-
238
171
  class WorkspaceOSStore:
239
172
  """Local-first state store for Workspace OS APIs."""
240
173
 
@@ -251,6 +184,15 @@ class WorkspaceOSStore:
251
184
  # bus receives all workspace activity without per-call wiring.
252
185
  # Defaults to None → zero behavior change for existing callers/tests.
253
186
  self.event_sink = event_sink
187
+ # Composed managers (extracted to shrink god class per architecture rules).
188
+ self.permissions = WorkspacePermissionManager(self)
189
+ self._timeline = WorkspaceTimeline(self)
190
+ self.plugins = WorkspacePluginManager(self)
191
+ self.memory = WorkspaceMemory(self)
192
+ self.snapshots = WorkspaceSnapshots(self)
193
+ self.graph_trace = WorkspaceGraphTrace(self)
194
+ self.runs = WorkspaceRuns(self)
195
+ self.skills = WorkspaceSkills(self)
254
196
 
255
197
  def _connect_state_db(self) -> sqlite3.Connection:
256
198
  conn = sqlite3.connect(self.sqlite_path)
@@ -519,25 +461,7 @@ class WorkspaceOSStore:
519
461
  return state
520
462
 
521
463
  def record_timeline_event(self, area: str, event_type: str, payload: Dict[str, Any], workspace_id: Optional[str] = None) -> Dict[str, Any]:
522
- state = self.load_state()
523
- event = {
524
- "id": f"timeline-{_json_hash([area, event_type, payload, _now()])[:16]}",
525
- "area": area,
526
- "event_type": event_type,
527
- "timestamp": _now(),
528
- "workspace_id": self._resolve_scope(workspace_id, state),
529
- "payload": payload,
530
- }
531
- event["contract"] = realtime_event_contract({"seq": event["id"], "received_at": event["timestamp"], **event})
532
- state.setdefault("timeline", []).append(event)
533
- self.save_state(state)
534
- if self.event_sink is not None:
535
- try:
536
- self.event_sink(event)
537
- except Exception:
538
- # Realtime delivery is best-effort and must never break a write.
539
- pass
540
- return event
464
+ return self._timeline.record_timeline_event(area, event_type, payload, workspace_id=workspace_id)
541
465
 
542
466
  def _emit_execution_event(
543
467
  self,
@@ -710,41 +634,17 @@ class WorkspaceOSStore:
710
634
 
711
635
  @staticmethod
712
636
  def _member_role(ws: Dict[str, Any], user_id: Optional[str]) -> Optional[str]:
713
- if ws.get("type") == "personal":
714
- return "owner"
715
- owner = ws.get("owner_user_id")
716
- # Local single-user / no-auth mode: an ownerless org is owned by the
717
- # local user (who has no identity), so they can manage what they create.
718
- if not owner and not user_id:
719
- return "owner"
720
- if user_id and user_id == owner:
721
- return "owner"
722
- for member in _listify(ws.get("members")):
723
- if member.get("user_id") == user_id:
724
- return member.get("role")
725
- return None
637
+ # Delegated to extracted module (kept for any internal direct calls).
638
+ return _member_role(ws, user_id)
726
639
 
727
640
  def get_member_role(self, workspace_id: str, user_id: Optional[str]) -> Optional[str]:
728
- ws = (self.load_state().get("workspaces") or {}).get(workspace_id)
729
- if not ws:
730
- raise FileNotFoundError(workspace_id)
731
- return self._member_role(ws, user_id)
641
+ return self.permissions.get_member_role(workspace_id, user_id)
732
642
 
733
643
  def has_permission(self, workspace_id: str, user_id: Optional[str], permission: str) -> bool:
734
- try:
735
- role = self.get_member_role(workspace_id, user_id)
736
- except FileNotFoundError:
737
- return False
738
- if role is None:
739
- return False
740
- return permission in ROLE_PERMISSIONS.get(role, set())
644
+ return self.permissions.has_permission(workspace_id, user_id, permission)
741
645
 
742
646
  def _require_permission(self, ws: Dict[str, Any], actor: Optional[str], permission: str) -> None:
743
- role = self._member_role(ws, actor)
744
- if role is None or permission not in ROLE_PERMISSIONS.get(role, set()):
745
- raise PermissionError(
746
- f"'{actor or 'anonymous'}' lacks '{permission}' on workspace '{ws.get('workspace_id')}'"
747
- )
647
+ self.permissions.require_permission(ws, actor, permission)
748
648
 
749
649
  def _load_org(self, state: Dict[str, Any], workspace_id: str) -> Dict[str, Any]:
750
650
  ws = (state.get("workspaces") or {}).get(workspace_id)
@@ -788,26 +688,10 @@ class WorkspaceOSStore:
788
688
  return self._workspace_public(ws, actor)
789
689
 
790
690
  def add_member(self, workspace_id: str, *, user_id: str, role: str = "member", actor: Optional[str] = None) -> Dict[str, Any]:
791
- if role not in WORKSPACE_ROLES:
792
- raise ValueError(f"unknown role: {role}")
793
- if not str(user_id or "").strip():
794
- raise ValueError("user_id is required")
795
- state = self.load_state()
796
- ws = self._load_org(state, workspace_id)
797
- self._require_permission(ws, actor, "manage_members")
798
- members = ws.setdefault("members", [])
799
- existing = next((m for m in members if m.get("user_id") == user_id), None)
800
- if existing:
801
- existing["role"] = role
802
- existing["updated_at"] = _now()
803
- else:
804
- members.append({"user_id": user_id, "role": role, "added_at": _now()})
805
- ws["updated_at"] = _now()
806
- self.save_state(state)
807
- self.record_timeline_event("workspace", "member_added", {"workspace_id": workspace_id, "user_id": user_id, "role": role})
808
- return self._workspace_public(ws, actor)
691
+ return self.permissions.add_member(workspace_id, user_id=user_id, role=role, actor=actor)
809
692
 
810
693
  def update_member_role(self, workspace_id: str, *, user_id: str, role: str, actor: Optional[str] = None) -> Dict[str, Any]:
694
+ # delegate or keep simple for now (full impl in permissions if expanded)
811
695
  if role not in WORKSPACE_ROLES:
812
696
  raise ValueError(f"unknown role: {role}")
813
697
  state = self.load_state()
@@ -840,6 +724,10 @@ class WorkspaceOSStore:
840
724
  self.save_state(state)
841
725
  self.record_timeline_event("workspace", "member_removed", {"workspace_id": workspace_id, "user_id": user_id})
842
726
  return self._workspace_public(ws, actor)
727
+ ws["updated_at"] = _now()
728
+ self.save_state(state)
729
+ self.record_timeline_event("workspace", "member_removed", {"workspace_id": workspace_id, "user_id": user_id})
730
+ return self._workspace_public(ws, actor)
843
731
 
844
732
  def set_active_workspace(self, workspace_id: str, user_id: Optional[str] = None) -> Dict[str, Any]:
845
733
  state = self.load_state()
@@ -943,122 +831,13 @@ class WorkspaceOSStore:
943
831
  # ------------------------------------------------------------------
944
832
 
945
833
  def build_graph_trace(self, question: str, graph: Any, context: str = "", *, limit: int = 8) -> Dict[str, Any]:
946
- if graph is None:
947
- return {
948
- "source_files": [],
949
- "graph_nodes": [],
950
- "graph_edges": [],
951
- "confidence": 0.0,
952
- "retrieval_metadata": {
953
- "query": question,
954
- "matched_nodes": 0,
955
- "graph_enabled": False,
956
- "context_chars": len(context or ""),
957
- },
958
- }
959
-
960
- matches: List[Dict[str, Any]] = []
961
- search_error = ""
962
- try:
963
- matches = graph.search(question, limit=limit).get("matches", [])
964
- except Exception as exc:
965
- search_error = str(exc)
966
- matches = []
967
-
968
- source_files: List[Dict[str, Any]] = []
969
- seen_sources = set()
970
- for match in matches:
971
- meta = match.get("metadata") or {}
972
- source = (
973
- meta.get("relative_path")
974
- or meta.get("file_path")
975
- or meta.get("filename")
976
- or meta.get("blob_path")
977
- or meta.get("source")
978
- )
979
- if source and source not in seen_sources:
980
- seen_sources.add(source)
981
- source_files.append({
982
- "source": source,
983
- "node_id": match.get("id"),
984
- "node_title": match.get("title"),
985
- "node_type": match.get("type"),
986
- "jump": {
987
- "graph": f"/graph?node={match.get('id')}",
988
- "source": source,
989
- },
990
- })
991
-
992
- edges: List[Dict[str, Any]] = []
993
- edge_seen = set()
994
- for match in matches[:5]:
995
- node_id = match.get("id")
996
- if not node_id:
997
- continue
998
- try:
999
- for edge in graph.neighbors(node_id).get("edges", []):
1000
- key = (edge.get("from"), edge.get("to"), edge.get("type"))
1001
- if key in edge_seen:
1002
- continue
1003
- edge_seen.add(key)
1004
- edges.append(edge)
1005
- if len(edges) >= 24:
1006
- break
1007
- except Exception:
1008
- continue
834
+ return self.graph_trace.build_graph_trace(question, graph, context, limit=limit)
1009
835
 
1010
- if matches:
1011
- confidence = min(0.95, 0.35 + min(len(matches), limit) / max(limit, 1) * 0.45 + (0.10 if edges else 0.0))
1012
- else:
1013
- confidence = 0.05 if context else 0.0
1014
-
1015
- return {
1016
- "source_files": source_files,
1017
- "graph_nodes": matches,
1018
- "graph_edges": edges,
1019
- "confidence": round(confidence, 4),
1020
- "retrieval_metadata": {
1021
- "query": question,
1022
- "matched_nodes": len(matches),
1023
- "matched_edges": len(edges),
1024
- "graph_enabled": True,
1025
- "context_chars": len(context or ""),
1026
- "search_error": search_error,
1027
- },
1028
- }
1029
-
1030
- def record_trace(
1031
- self,
1032
- *,
1033
- question: str,
1034
- response: str,
1035
- conversation_id: Optional[str],
1036
- user_email: Optional[str],
1037
- trace: Dict[str, Any],
1038
- workspace_id: Optional[str] = None,
1039
- ) -> Dict[str, Any]:
1040
- state = self.load_state()
1041
- trace_id = f"trace-{_json_hash([question, response, conversation_id, _now()])[:16]}"
1042
- record = {
1043
- "id": trace_id,
1044
- "question": question,
1045
- "response_preview": str(response or "")[:700],
1046
- "conversation_id": conversation_id,
1047
- "user_email": user_email,
1048
- "workspace_id": self._resolve_scope(workspace_id, state),
1049
- "created_at": _now(),
1050
- **trace,
1051
- }
1052
- state.setdefault("traces", []).append(record)
1053
- self.save_state(state)
1054
- self.record_timeline_event("graph", "answer_trace", {"trace_id": trace_id, "conversation_id": conversation_id})
1055
- return record
836
+ def record_trace(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
837
+ return self.graph_trace.record_trace(*args, **kwargs)
1056
838
 
1057
839
  def list_traces(self, conversation_id: Optional[str] = None, limit: int = 50, workspace_id: Optional[str] = None) -> Dict[str, Any]:
1058
- traces = self._scoped(_listify(self.load_state().get("traces")), workspace_id)
1059
- if conversation_id:
1060
- traces = [trace for trace in traces if trace.get("conversation_id") == conversation_id]
1061
- return {"traces": list(reversed(traces[-max(1, min(limit, 200)):]))}
840
+ return self.graph_trace.list_traces(conversation_id=conversation_id, limit=limit, workspace_id=workspace_id)
1062
841
 
1063
842
  # ------------------------------------------------------------------
1064
843
  # Indexing dashboard
@@ -1150,89 +929,19 @@ class WorkspaceOSStore:
1150
929
  models: Dict[str, Any],
1151
930
  workspace_id: Optional[str] = None,
1152
931
  ) -> Dict[str, Any]:
1153
- scope = self._resolve_scope(workspace_id)
1154
- graph_payload = {"nodes": [], "edges": []}
1155
- graph_stats = {}
1156
- local_sources = {"sources": []}
1157
- if graph is not None:
1158
- graph_payload = graph.graph(limit=2000)
1159
- graph_stats = graph.stats()
1160
- local_sources = graph.local_sources()
1161
- chat = list(history or [])
1162
- snapshot_body = {
1163
- "version": WORKSPACE_OS_VERSION,
1164
- "name": name or "Workspace snapshot",
1165
- "created_at": _now(),
1166
- "workspace": scope,
1167
- "workspace_id": scope,
1168
- "graph": graph_payload,
1169
- "graph_stats": graph_stats,
1170
- "chat": chat,
1171
- "settings": settings,
1172
- "indexed_folders": local_sources.get("sources", []),
1173
- "models": models,
1174
- }
1175
- snapshot_id = f"snapshot-{datetime.now().strftime('%Y%m%d%H%M%S')}-{_json_hash(snapshot_body)[:10]}"
1176
- snapshot_body["id"] = snapshot_id
1177
- path = self.snapshots_dir / f"{snapshot_id}.json"
1178
- _atomic_write_json(path, snapshot_body)
1179
-
1180
- state = self.load_state()
1181
- meta = {
1182
- "id": snapshot_id,
1183
- "name": snapshot_body["name"],
1184
- "created_at": snapshot_body["created_at"],
1185
- "workspace_id": scope,
1186
- "path": str(path),
1187
- "node_count": len(graph_payload.get("nodes") or []),
1188
- "edge_count": len(graph_payload.get("edges") or []),
1189
- "chat_count": len(chat),
1190
- "model_count": len(models.get("loaded_models") or []),
1191
- "indexed_folder_count": len(local_sources.get("sources") or []),
1192
- }
1193
- state.setdefault("snapshots", []).append(meta)
1194
- self.save_state(state)
1195
- self.record_timeline_event("snapshot", "snapshot_saved", {"snapshot_id": snapshot_id, "name": name})
1196
- return {"snapshot": meta}
932
+ return self.snapshots.create_snapshot(name=name, graph=graph, history=history, settings=settings, models=models, workspace_id=workspace_id)
1197
933
 
1198
934
  def list_snapshots(self, workspace_id: Optional[str] = None) -> Dict[str, Any]:
1199
- snapshots = self._scoped(_listify(self.load_state().get("snapshots")), workspace_id)
1200
- return {"snapshots": list(reversed(snapshots))}
935
+ return self.snapshots.list_snapshots(workspace_id=workspace_id)
1201
936
 
1202
937
  def get_snapshot(self, snapshot_id: str) -> Dict[str, Any]:
1203
- path = self.snapshots_dir / f"{_safe_slug(snapshot_id)}.json"
1204
- if not path.exists():
1205
- state = self.load_state()
1206
- meta = next((item for item in _listify(state.get("snapshots")) if item.get("id") == snapshot_id), None)
1207
- if meta:
1208
- path = Path(meta.get("path") or path)
1209
- if not path.exists():
1210
- raise FileNotFoundError(snapshot_id)
1211
- return json.loads(path.read_text(encoding="utf-8"))
938
+ return self.snapshots.get_snapshot(snapshot_id)
1212
939
 
1213
940
  def snapshot_view(self, snapshot_id: str, area: str) -> Dict[str, Any]:
1214
- snapshot = self.get_snapshot(snapshot_id)
1215
- if area == "graph":
1216
- return {"snapshot_id": snapshot_id, "graph": snapshot.get("graph") or {}, "graph_stats": snapshot.get("graph_stats") or {}}
1217
- if area == "chat":
1218
- return {"snapshot_id": snapshot_id, "chat": snapshot.get("chat") or []}
1219
- if area == "decision":
1220
- nodes = (snapshot.get("graph") or {}).get("nodes") or []
1221
- return {"snapshot_id": snapshot_id, "decisions": [node for node in nodes if node.get("type") == "Decision"]}
1222
- return {"snapshot_id": snapshot_id, "snapshot": snapshot}
941
+ return self.snapshots.snapshot_view(snapshot_id, area)
1223
942
 
1224
943
  def export_snapshot(self, snapshot_id: str) -> Dict[str, Any]:
1225
- snapshot = self.get_snapshot(snapshot_id)
1226
- export_path = self.exports_dir / f"{_safe_slug(snapshot_id)}.zip"
1227
- with zipfile.ZipFile(export_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
1228
- zf.writestr("snapshot.json", json.dumps(snapshot, ensure_ascii=False, indent=2))
1229
- zf.writestr("graph.json", json.dumps(snapshot.get("graph") or {}, ensure_ascii=False, indent=2))
1230
- zf.writestr("chat.json", json.dumps(snapshot.get("chat") or [], ensure_ascii=False, indent=2))
1231
- zf.writestr("settings.json", json.dumps(snapshot.get("settings") or {}, ensure_ascii=False, indent=2))
1232
- zf.writestr("indexed_folders.json", json.dumps(snapshot.get("indexed_folders") or [], ensure_ascii=False, indent=2))
1233
- zf.writestr("models.json", json.dumps(snapshot.get("models") or {}, ensure_ascii=False, indent=2))
1234
- self.record_timeline_event("snapshot", "snapshot_exported", {"snapshot_id": snapshot_id, "path": str(export_path)})
1235
- return {"snapshot_id": snapshot_id, "export_path": str(export_path), "bytes": export_path.stat().st_size}
944
+ return self.snapshots.export_snapshot(snapshot_id)
1236
945
 
1237
946
  def restore_snapshot(
1238
947
  self,
@@ -1242,109 +951,10 @@ class WorkspaceOSStore:
1242
951
  workspace_id: Optional[str] = None,
1243
952
  user_email: Optional[str] = None,
1244
953
  ) -> Dict[str, Any]:
1245
- """Restore a snapshot additively, preserving all current user data.
1246
-
1247
- v4 snapshots are immutable checkpoints. Restoring one must not delete
1248
- newer graph nodes, chat history, memories, workspaces, or settings, so
1249
- this operation imports the snapshot graph in ``merge`` mode and records a
1250
- durable restore event. It is a real restore path for lost/missing graph
1251
- data, with rollback safety because current state remains intact.
1252
- """
1253
-
1254
- snapshot = self.get_snapshot(snapshot_id)
1255
- scope = self._resolve_scope(workspace_id or snapshot.get("workspace_id"))
1256
- if graph is None or not hasattr(graph, "import_graph_data"):
1257
- raise ValueError("knowledge graph import is required for snapshot restore")
1258
- artifact = _snapshot_graph_import_payload(snapshot.get("graph") or {}, workspace_id=scope)
1259
- import_result = graph.import_graph_data(artifact, mode="merge", dry_run=False)
1260
- restore_id = f"restore-{datetime.now().strftime('%Y%m%d%H%M%S')}-{_json_hash([snapshot_id, scope, user_email, _now()])[:10]}"
1261
- record = {
1262
- "id": restore_id,
1263
- "snapshot_id": snapshot_id,
1264
- "workspace_id": scope,
1265
- "restored_at": _now(),
1266
- "restored_by": user_email,
1267
- "mode": "merge",
1268
- "graph": import_result,
1269
- "settings_preserved": True,
1270
- "chat_preserved": True,
1271
- }
1272
- state = self.load_state()
1273
- state.setdefault("snapshot_restores", []).append(record)
1274
- self.save_state(state)
1275
- self.record_timeline_event(
1276
- "snapshot",
1277
- "snapshot_restored",
1278
- {"snapshot_id": snapshot_id, "restore_id": restore_id, "mode": "merge", "graph": import_result},
1279
- workspace_id=scope,
1280
- )
1281
- return {"restored": True, "restore": record}
954
+ return self.snapshots.restore_snapshot(snapshot_id, graph=graph, workspace_id=workspace_id, user_email=user_email)
1282
955
 
1283
956
  def compare_snapshots(self, before_id: str, after_id: str) -> Dict[str, Any]:
1284
- before = self.get_snapshot(before_id)
1285
- after = self.get_snapshot(after_id)
1286
- before_nodes = {node.get("id"): node for node in (before.get("graph") or {}).get("nodes") or [] if node.get("id")}
1287
- after_nodes = {node.get("id"): node for node in (after.get("graph") or {}).get("nodes") or [] if node.get("id")}
1288
-
1289
- def edge_key(edge: Dict[str, Any]) -> str:
1290
- return "|".join(str(edge.get(key) or "") for key in ("from", "to", "type"))
1291
-
1292
- before_edges = {edge_key(edge): edge for edge in (before.get("graph") or {}).get("edges") or []}
1293
- after_edges = {edge_key(edge): edge for edge in (after.get("graph") or {}).get("edges") or []}
1294
-
1295
- added_nodes = [after_nodes[key] for key in sorted(set(after_nodes) - set(before_nodes))]
1296
- removed_nodes = [before_nodes[key] for key in sorted(set(before_nodes) - set(after_nodes))]
1297
- changed_nodes = [
1298
- {"before": before_nodes[key], "after": after_nodes[key]}
1299
- for key in sorted(set(before_nodes) & set(after_nodes))
1300
- if _json_hash(before_nodes[key]) != _json_hash(after_nodes[key])
1301
- ]
1302
- added_edges = [after_edges[key] for key in sorted(set(after_edges) - set(before_edges))]
1303
- removed_edges = [before_edges[key] for key in sorted(set(before_edges) - set(after_edges))]
1304
-
1305
- before_decisions = {key: value for key, value in before_nodes.items() if value.get("type") == "Decision"}
1306
- after_decisions = {key: value for key, value in after_nodes.items() if value.get("type") == "Decision"}
1307
- decisions_changed = [
1308
- {"before": before_decisions.get(key), "after": after_decisions.get(key)}
1309
- for key in sorted(set(before_decisions) | set(after_decisions))
1310
- if _json_hash(before_decisions.get(key)) != _json_hash(after_decisions.get(key))
1311
- ]
1312
-
1313
- return {
1314
- "before": before_id,
1315
- "after": after_id,
1316
- "nodes_added": added_nodes,
1317
- "nodes_removed": removed_nodes,
1318
- "nodes_changed": changed_nodes,
1319
- "edges_added": added_edges,
1320
- "edges_removed": removed_edges,
1321
- "decisions_changed": decisions_changed,
1322
- "summary": {
1323
- "nodes_added": len(added_nodes),
1324
- "nodes_removed": len(removed_nodes),
1325
- "nodes_changed": len(changed_nodes),
1326
- "edges_added": len(added_edges),
1327
- "edges_removed": len(removed_edges),
1328
- "decisions_changed": len(decisions_changed),
1329
- },
1330
- }
1331
-
1332
- def timeline(self, audit_events: Optional[Iterable[Dict[str, Any]]] = None, limit: int = 100, workspace_id: Optional[str] = None) -> Dict[str, Any]:
1333
- state = self.load_state()
1334
- events: List[Dict[str, Any]] = []
1335
- events.extend(self._scoped(_listify(state.get("timeline")), workspace_id))
1336
- for snapshot in self._scoped(_listify(state.get("snapshots")), workspace_id):
1337
- events.append({"area": "snapshot", "event_type": "snapshot", "timestamp": snapshot.get("created_at"), "workspace_id": self._record_workspace(snapshot), "payload": snapshot})
1338
- for trace in self._scoped(_listify(state.get("traces")), workspace_id):
1339
- events.append({"area": "graph", "event_type": "answer_trace", "timestamp": trace.get("created_at"), "workspace_id": self._record_workspace(trace), "payload": trace})
1340
- for run in self._scoped(_listify(state.get("agent_runs")), workspace_id):
1341
- events.append({"area": "agent", "event_type": "agent_run", "timestamp": run.get("created_at"), "workspace_id": self._record_workspace(run), "payload": run})
1342
- for workflow in self._scoped(_listify(state.get("workflows")), workspace_id):
1343
- events.append({"area": "workflow", "event_type": "workflow", "timestamp": workflow.get("created_at"), "workspace_id": self._record_workspace(workflow), "payload": workflow})
1344
- for audit in audit_events or []:
1345
- events.append({"area": "audit", "event_type": audit.get("event_type") or "audit", "timestamp": audit.get("timestamp"), "payload": audit})
1346
- events.sort(key=lambda item: item.get("timestamp") or "", reverse=True)
1347
- return {"events": events[: max(1, min(limit, 500))]}
957
+ return self.snapshots.compare_snapshots(before_id, after_id)
1348
958
 
1349
959
  # ------------------------------------------------------------------
1350
960
  # Personal memory
@@ -1362,54 +972,13 @@ class WorkspaceOSStore:
1362
972
  graph: Any = None,
1363
973
  workspace_id: Optional[str] = None,
1364
974
  ) -> Dict[str, Any]:
1365
- if kind not in MEMORY_KINDS:
1366
- raise ValueError(f"unknown memory kind: {kind}")
1367
- if not str(content or "").strip():
1368
- raise ValueError("content is required")
1369
- state = self.load_state()
1370
- memories = _listify(state.get("memories"))
1371
- now = _now()
1372
- memory_id = memory_id or f"memory-{_json_hash([kind, content, user_email, now])[:16]}"
1373
- existing = next((item for item in memories if item.get("id") == memory_id), None)
1374
- record = existing or {
1375
- "id": memory_id,
1376
- "created_at": now,
1377
- }
1378
- record.update({
1379
- "kind": kind,
1380
- "content": content,
1381
- "user_email": user_email,
1382
- "tags": tags or [],
1383
- "metadata": {**(metadata or {}), "memory_scope": kind},
1384
- "workspace_id": self._resolve_scope(workspace_id, state) if existing is None else self._record_workspace(record),
1385
- "updated_at": now,
1386
- })
1387
- if graph is not None:
1388
- try:
1389
- ingested = graph.ingest_event(
1390
- "Memory",
1391
- f"{kind}: {content[:80]}",
1392
- user_email=user_email,
1393
- source="workspace_os",
1394
- metadata={"memory_id": memory_id, "kind": kind, "tags": tags or []},
1395
- )
1396
- record["graph_node_id"] = ingested.get("node_id")
1397
- except Exception as exc:
1398
- record["graph_error"] = str(exc)
1399
- if existing is None:
1400
- memories.append(record)
1401
- state["memories"] = memories
1402
- self.save_state(state)
1403
- self.record_timeline_event("memory", "memory_upserted", {"memory_id": memory_id, "kind": kind}, workspace_id=record.get("workspace_id"))
1404
- return record
975
+ return self.memory.upsert_memory(
976
+ kind=kind, content=content, user_email=user_email, tags=tags,
977
+ memory_id=memory_id, metadata=metadata, graph=graph, workspace_id=workspace_id
978
+ )
1405
979
 
1406
980
  def list_memories(self, user_email: Optional[str] = None, kind: Optional[str] = None, workspace_id: Optional[str] = None) -> Dict[str, Any]:
1407
- memories = self._scoped(_listify(self.load_state().get("memories")), workspace_id)
1408
- if user_email:
1409
- memories = [item for item in memories if item.get("user_email") in {None, user_email}]
1410
- if kind:
1411
- memories = [item for item in memories if item.get("kind") == kind]
1412
- return {"memories": list(reversed(memories))}
981
+ return self.memory.list_memories(user_email=user_email, kind=kind, workspace_id=workspace_id)
1413
982
 
1414
983
  def search_memories(self, query: str, user_email: Optional[str] = None, limit: int = 20, workspace_id: Optional[str] = None) -> Dict[str, Any]:
1415
984
  q = str(query or "").lower().strip()
@@ -1486,432 +1055,63 @@ class WorkspaceOSStore:
1486
1055
  # Agent and workflow graph
1487
1056
  # ------------------------------------------------------------------
1488
1057
 
1489
- def list_agents(self, workspace_id: Optional[str] = None) -> Dict[str, Any]:
1490
- state = self.load_state()
1491
- runs = self._scoped(_listify(state.get("agent_runs")), workspace_id)
1492
- return {"agents": _listify(state.get("agents")), "runs": list(reversed(runs[-100:]))}
1058
+ def list_agents(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
1059
+ return self.runs.list_agents(*args, **kwargs)
1493
1060
 
1494
- def record_agent_run(
1495
- self,
1496
- *,
1497
- agent_id: str,
1498
- status: str,
1499
- input_text: str,
1500
- output_text: str,
1501
- user_email: Optional[str],
1502
- timeline: Optional[List[Dict[str, Any]]] = None,
1503
- relationships: Optional[List[str]] = None,
1504
- handoffs: Optional[List[Dict[str, Any]]] = None,
1505
- context_packets: Optional[List[Dict[str, Any]]] = None,
1506
- plan: Optional[List[Dict[str, Any]]] = None,
1507
- plan_review: Optional[Dict[str, Any]] = None,
1508
- review_history: Optional[List[Dict[str, Any]]] = None,
1509
- retry_history: Optional[List[Dict[str, Any]]] = None,
1510
- memory_snapshots: Optional[List[Dict[str, Any]]] = None,
1511
- graph: Any = None,
1512
- workspace_id: Optional[str] = None,
1513
- mode: str = "simulation",
1514
- ) -> Dict[str, Any]:
1515
- state = self.load_state()
1516
- resolved_workspace = self._resolve_scope(workspace_id, state)
1517
- run = {
1518
- "id": f"agent-run-{_json_hash([agent_id, input_text, output_text, _now()])[:16]}",
1519
- "record_schema_version": 2,
1520
- "agent_id": agent_id,
1521
- "mode": mode,
1522
- "status": status,
1523
- "input": input_text,
1524
- "output_preview": output_text[:1000],
1525
- "user_email": user_email,
1526
- "workspace_id": resolved_workspace,
1527
- "relationships": relationships or [],
1528
- "timeline": timeline or [],
1529
- "handoffs": handoffs or [],
1530
- "context_packets": context_packets or [],
1531
- "plan": plan or [],
1532
- "plan_review": plan_review or {},
1533
- "review_history": review_history or [],
1534
- "retry_history": retry_history or [],
1535
- "memory_snapshots": memory_snapshots or [],
1536
- "created_at": _now(),
1537
- }
1538
- if mode == "simulation":
1539
- # Simulated runs are replay scaffolding, not experiences — they must
1540
- # never enter the knowledge graph as real provenance.
1541
- run["graph_node_id"] = None
1542
- run["graph_skipped"] = "simulation runs are not recorded in the knowledge graph"
1543
- elif graph is not None:
1544
- try:
1545
- ingested = graph.ingest_event(
1546
- "AgentRun",
1547
- f"{agent_id} {status}",
1548
- user_email=user_email,
1549
- source="workspace_os",
1550
- metadata={"run_id": run["id"], "agent_id": agent_id, "status": status, "mode": mode},
1551
- )
1552
- run["graph_node_id"] = ingested.get("node_id")
1553
- except Exception as exc:
1554
- run["graph_error"] = str(exc)
1555
- if handoffs:
1556
- stored_handoffs = state.setdefault("handoffs", [])
1557
- for handoff in handoffs:
1558
- stored = {
1559
- **handoff,
1560
- "run_id": run["id"],
1561
- "workspace_id": resolved_workspace,
1562
- }
1563
- stored_handoffs.append(stored)
1564
- state["handoffs"] = stored_handoffs
1565
- state.setdefault("agent_runs", []).append(run)
1566
- self.save_state(state)
1567
- self._emit_replayable_timeline_events(area="agent", run_id=run["id"], timeline=run["timeline"], workspace_id=resolved_workspace)
1568
- if status == "failed":
1569
- 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)
1570
- self.record_timeline_event("agent", "agent_run", {"run_id": run["id"], "agent_id": agent_id, "status": status}, workspace_id=resolved_workspace)
1571
- run["contract"] = run_record_contract(run)
1572
- state = self.load_state()
1573
- for item in _listify(state.get("agent_runs")):
1574
- if item.get("id") == run["id"]:
1575
- item["contract"] = run["contract"]
1576
- break
1577
- self.save_state(state)
1578
- return run
1061
+ def record_agent_run(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
1062
+ return self.runs.record_agent_run(*args, **kwargs)
1579
1063
 
1580
- def update_agent_run(
1581
- self,
1582
- run_id: str,
1583
- *,
1584
- workspace_id: Optional[str] = None,
1585
- graph: Any = None,
1586
- patch: Optional[Dict[str, Any]] = None,
1587
- **fields: Any,
1588
- ) -> Dict[str, Any]:
1589
- """Patch a persisted agent run without changing its id.
1064
+ def update_agent_run(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
1065
+ return self.runs.update_agent_run(*args, **kwargs)
1590
1066
 
1591
- Async execution creates a durable queued/running row before work starts,
1592
- then updates that same row as progress, cancellation, or a terminal
1593
- result arrives. This keeps old run lists/read APIs compatible while
1594
- avoiding duplicate "placeholder + final" records.
1595
- """
1596
- updates = {**(patch or {}), **fields}
1597
- state = self.load_state()
1598
- run = next((item for item in _listify(state.get("agent_runs")) if item.get("id") == run_id), None)
1599
- if run is None or (workspace_id and self._record_workspace(run) != str(workspace_id)):
1600
- raise FileNotFoundError(run_id)
1601
- resolved_workspace = self._record_workspace(run)
1602
- old_timeline_len = len(run.get("timeline") or [])
1603
-
1604
- output_text = updates.pop("output_text", None)
1605
- if output_text is not None:
1606
- run["output_preview"] = str(output_text)[:1000]
1607
- for key, value in updates.items():
1608
- run[key] = value
1609
- status = str(run.get("status") or "")
1610
- run["updated_at"] = _now()
1611
- if status in RUN_TERMINAL_STATUSES:
1612
- run.setdefault("completed_at", _now())
1613
-
1614
- handoffs = updates.get("handoffs")
1615
- if isinstance(handoffs, list):
1616
- stored_handoffs = [
1617
- item for item in _listify(state.get("handoffs"))
1618
- if item.get("run_id") != run_id
1619
- ]
1620
- for handoff in handoffs:
1621
- if isinstance(handoff, dict):
1622
- stored_handoffs.append({**handoff, "run_id": run_id, "workspace_id": resolved_workspace})
1623
- state["handoffs"] = stored_handoffs
1624
-
1625
- if (
1626
- status in RUN_TERMINAL_STATUSES
1627
- and run.get("mode") != "simulation"
1628
- and graph is not None
1629
- and not run.get("graph_node_id")
1630
- ):
1631
- try:
1632
- ingested = graph.ingest_event(
1633
- "AgentRun",
1634
- f"{run.get('agent_id')} {status}",
1635
- user_email=run.get("user_email"),
1636
- source="workspace_os",
1637
- metadata={
1638
- "run_id": run_id,
1639
- "agent_id": run.get("agent_id"),
1640
- "status": status,
1641
- "mode": run.get("mode"),
1642
- },
1643
- )
1644
- run["graph_node_id"] = ingested.get("node_id")
1645
- except Exception as exc:
1646
- run["graph_error"] = str(exc)
1067
+ def get_agent_run(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
1068
+ return self.runs.get_agent_run(*args, **kwargs)
1647
1069
 
1648
- self.save_state(state)
1649
- run["contract"] = run_record_contract(run)
1650
- state = self.load_state()
1651
- for item in _listify(state.get("agent_runs")):
1652
- if item.get("id") == run_id:
1653
- item["contract"] = run["contract"]
1654
- break
1655
- self.save_state(state)
1070
+ def list_handoffs(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
1071
+ return self.runs.list_handoffs(*args, **kwargs)
1656
1072
 
1657
- timeline = run.get("timeline") or []
1658
- if len(timeline) > old_timeline_len:
1659
- self._emit_replayable_timeline_events(
1660
- area="agent",
1661
- run_id=run_id,
1662
- timeline=timeline[old_timeline_len:],
1663
- workspace_id=resolved_workspace,
1664
- )
1665
- if status == "failed":
1666
- 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)
1667
- elif status == "cancelled":
1668
- 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)
1669
- elif status == "interrupted":
1670
- 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)
1671
- self.record_timeline_event("agent", "agent_run_update", {"run_id": run_id, "agent_id": run.get("agent_id"), "status": status}, workspace_id=resolved_workspace)
1672
- return run
1673
-
1674
- def get_agent_run(self, run_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
1675
- run = next((item for item in _listify(self.load_state().get("agent_runs")) if item.get("id") == run_id), None)
1676
- if not run or (workspace_id and self._record_workspace(run) != str(workspace_id)):
1677
- raise FileNotFoundError(run_id)
1678
- return run
1679
-
1680
- def list_handoffs(self, workspace_id: Optional[str] = None, run_id: Optional[str] = None) -> Dict[str, Any]:
1681
- handoffs = self._scoped(_listify(self.load_state().get("handoffs")), workspace_id)
1682
- if run_id:
1683
- handoffs = [item for item in handoffs if item.get("run_id") == run_id]
1684
- return {"handoffs": list(reversed(handoffs[-200:]))}
1685
-
1686
- def create_workflow(
1687
- self,
1688
- *,
1689
- name: str,
1690
- steps: List[Dict[str, Any]],
1691
- user_email: Optional[str],
1692
- metadata: Optional[Dict[str, Any]] = None,
1693
- graph: Any = None,
1694
- workspace_id: Optional[str] = None,
1695
- nodes: Optional[List[Dict[str, Any]]] = None,
1696
- ) -> Dict[str, Any]:
1697
- state = self.load_state()
1698
- workflow = {
1699
- "id": f"workflow-{_json_hash([name, steps, user_email, _now()])[:16]}",
1700
- "name": name or "Untitled workflow",
1701
- "steps": steps,
1702
- "user_email": user_email,
1703
- "workspace_id": self._resolve_scope(workspace_id, state),
1704
- "metadata": metadata or {},
1705
- "events": [{"type": "created", "timestamp": _now()}],
1706
- "created_at": _now(),
1707
- "updated_at": _now(),
1708
- }
1709
- # Workflow Designer stores a typed-node graph alongside the legacy
1710
- # ``steps`` list so older history keeps working and new editors get nodes.
1711
- if nodes is not None:
1712
- workflow["nodes"] = nodes
1713
- if graph is not None:
1714
- try:
1715
- ingested = graph.ingest_event(
1716
- "Workflow",
1717
- workflow["name"],
1718
- user_email=user_email,
1719
- source="workspace_os",
1720
- metadata={"workflow_id": workflow["id"], "steps": steps},
1721
- )
1722
- workflow["graph_node_id"] = ingested.get("node_id")
1723
- except Exception as exc:
1724
- workflow["graph_error"] = str(exc)
1725
- state.setdefault("workflows", []).append(workflow)
1726
- self.save_state(state)
1727
- self.record_timeline_event("workflow", "workflow_created", {"workflow_id": workflow["id"], "name": workflow["name"]})
1728
- return workflow
1073
+ def create_workflow(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
1074
+ return self.runs.create_workflow(*args, **kwargs)
1729
1075
 
1730
- def record_workflow_run(
1731
- self,
1732
- *,
1733
- workflow_id: Optional[str],
1734
- name: str,
1735
- status: str,
1736
- timeline: List[Dict[str, Any]],
1737
- outputs: Optional[Dict[str, Any]] = None,
1738
- user_email: Optional[str] = None,
1739
- graph: Any = None,
1740
- workspace_id: Optional[str] = None,
1741
- mode: str = "simulation",
1742
- pause: Optional[Dict[str, Any]] = None,
1743
- ) -> Dict[str, Any]:
1744
- """Persist a Workflow Designer execution into local-first run history."""
1745
- state = self.load_state()
1746
- resolved_workspace = self._resolve_scope(workspace_id, state)
1747
- run = {
1748
- "id": f"workflow-run-{_json_hash([workflow_id, name, status, _now()])[:16]}",
1749
- "record_schema_version": 2,
1750
- "workflow_id": workflow_id,
1751
- "name": name or "workflow",
1752
- "mode": mode,
1753
- "status": status,
1754
- "timeline": timeline or [],
1755
- "outputs": outputs or {},
1756
- "user_email": user_email,
1757
- "workspace_id": resolved_workspace,
1758
- "created_at": _now(),
1759
- }
1760
- if pause:
1761
- run["pause"] = pause
1762
- if mode == "simulation":
1763
- # Record-only node runners do no real work; their runs must not be
1764
- # written into the knowledge graph as if they were real executions.
1765
- run["graph_node_id"] = None
1766
- run["graph_skipped"] = "simulation runs are not recorded in the knowledge graph"
1767
- elif graph is not None:
1768
- try:
1769
- ingested = graph.ingest_event(
1770
- "WorkflowRun",
1771
- f"{run['name']} {status}",
1772
- user_email=user_email,
1773
- source="workspace_os",
1774
- metadata={"run_id": run["id"], "workflow_id": workflow_id, "status": status, "mode": mode},
1775
- )
1776
- run["graph_node_id"] = ingested.get("node_id")
1777
- except Exception as exc:
1778
- run["graph_error"] = str(exc)
1779
- state.setdefault("workflow_runs", []).append(run)
1780
- # Attach the run id to the workflow's event log for cross-linking.
1781
- for wf in _listify(state.get("workflows")):
1782
- if wf.get("id") == workflow_id:
1783
- wf.setdefault("events", []).append({"type": "run", "timestamp": _now(), "payload": {"run_id": run["id"], "status": status}})
1784
- wf["updated_at"] = _now()
1785
- break
1786
- self.save_state(state)
1787
- 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)
1788
- self._emit_replayable_timeline_events(area="workflow", run_id=run["id"], timeline=run["timeline"], workspace_id=resolved_workspace)
1789
- if status == "failed":
1790
- 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)
1791
- elif status in {"ok", "partial"}:
1792
- 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)
1793
- self.record_timeline_event("workflow", "workflow_run", {"run_id": run["id"], "workflow_id": workflow_id, "status": status}, workspace_id=resolved_workspace)
1794
- run["contract"] = workflow_run_contract(run)
1795
- state = self.load_state()
1796
- for item in _listify(state.get("workflow_runs")):
1797
- if item.get("id") == run["id"]:
1798
- item["contract"] = run["contract"]
1799
- break
1800
- self.save_state(state)
1801
- return run
1076
+ def record_workflow_run(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
1077
+ return self.runs.record_workflow_run(*args, **kwargs)
1802
1078
 
1803
- def update_workflow_run(
1804
- self,
1805
- run_id: str,
1806
- *,
1807
- workspace_id: Optional[str] = None,
1808
- graph: Any = None,
1809
- patch: Optional[Dict[str, Any]] = None,
1810
- **fields: Any,
1811
- ) -> Dict[str, Any]:
1812
- """Patch a persisted workflow run in place for async execution."""
1813
- updates = {**(patch or {}), **fields}
1814
- state = self.load_state()
1815
- run = next((item for item in _listify(state.get("workflow_runs")) if item.get("id") == run_id), None)
1816
- if run is None or (workspace_id and self._record_workspace(run) != str(workspace_id)):
1817
- raise FileNotFoundError(run_id)
1818
- resolved_workspace = self._record_workspace(run)
1819
- old_timeline_len = len(run.get("timeline") or [])
1820
-
1821
- for key, value in updates.items():
1822
- if value is None and key == "pause":
1823
- run.pop("pause", None)
1824
- else:
1825
- run[key] = value
1826
- status = str(run.get("status") or "")
1827
- run["updated_at"] = _now()
1828
- if status in RUN_TERMINAL_STATUSES:
1829
- run.setdefault("completed_at", _now())
1830
-
1831
- workflow_id = run.get("workflow_id")
1832
- for wf in _listify(state.get("workflows")):
1833
- if wf.get("id") == workflow_id:
1834
- wf.setdefault("events", []).append({"type": "run_update", "timestamp": _now(), "payload": {"run_id": run_id, "status": status}})
1835
- wf["updated_at"] = _now()
1836
- break
1837
-
1838
- if (
1839
- status in RUN_TERMINAL_STATUSES
1840
- and run.get("mode") != "simulation"
1841
- and graph is not None
1842
- and not run.get("graph_node_id")
1843
- ):
1844
- try:
1845
- ingested = graph.ingest_event(
1846
- "WorkflowRun",
1847
- f"{run.get('name')} {status}",
1848
- user_email=run.get("user_email"),
1849
- source="workspace_os",
1850
- metadata={
1851
- "run_id": run_id,
1852
- "workflow_id": workflow_id,
1853
- "status": status,
1854
- "mode": run.get("mode"),
1855
- },
1856
- )
1857
- run["graph_node_id"] = ingested.get("node_id")
1858
- except Exception as exc:
1859
- run["graph_error"] = str(exc)
1079
+ def update_workflow_run(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
1080
+ return self.runs.update_workflow_run(*args, **kwargs)
1860
1081
 
1861
- self.save_state(state)
1862
- run["contract"] = workflow_run_contract(run)
1863
- state = self.load_state()
1864
- for item in _listify(state.get("workflow_runs")):
1865
- if item.get("id") == run_id:
1866
- item["contract"] = run["contract"]
1867
- break
1868
- self.save_state(state)
1082
+ def list_workflow_runs(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
1083
+ return self.runs.list_workflow_runs(*args, **kwargs)
1869
1084
 
1870
- timeline = run.get("timeline") or []
1871
- if len(timeline) > old_timeline_len:
1872
- self._emit_replayable_timeline_events(
1873
- area="workflow",
1874
- run_id=run_id,
1875
- timeline=timeline[old_timeline_len:],
1876
- workspace_id=resolved_workspace,
1877
- )
1878
- if status == "failed":
1879
- 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)
1880
- elif status in {"ok", "partial"}:
1881
- 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)
1882
- elif status == "cancelled":
1883
- 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)
1884
- elif status == "interrupted":
1885
- 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)
1886
- self.record_timeline_event("workflow", "workflow_run_update", {"run_id": run_id, "workflow_id": workflow_id, "status": status}, workspace_id=resolved_workspace)
1887
- return run
1888
-
1889
- def list_workflow_runs(self, workflow_id: Optional[str] = None, limit: int = 50, workspace_id: Optional[str] = None) -> Dict[str, Any]:
1890
- runs = self._scoped(_listify(self.load_state().get("workflow_runs")), workspace_id)
1891
- if workflow_id:
1892
- runs = [run for run in runs if run.get("workflow_id") == workflow_id]
1893
- return {"runs": list(reversed(runs[-max(1, min(limit, 300)):]))}
1894
-
1895
- def mark_workflow_run_resolved(
1896
- self, run_id: str, *, resumed_run_id: str, approved: bool,
1897
- workspace_id: Optional[str] = None,
1898
- ) -> Dict[str, Any]:
1899
- """Close out a paused run after its approval decision (one decision only)."""
1900
- state = self.load_state()
1901
- run = next((item for item in _listify(state.get("workflow_runs")) if item.get("id") == run_id), None)
1902
- if run is None or (workspace_id and self._record_workspace(run) != str(workspace_id)):
1903
- raise FileNotFoundError(run_id)
1904
- run["status"] = "resumed" if approved else "denied"
1905
- run["resolved_at"] = _now()
1906
- run["resumed_run_id"] = resumed_run_id
1907
- self.save_state(state)
1908
- return run
1085
+ def mark_workflow_run_resolved(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
1086
+ return self.runs.mark_workflow_run_resolved(*args, **kwargs)
1087
+
1088
+ def get_workflow_run(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
1089
+ return self.runs.get_workflow_run(*args, **kwargs)
1090
+
1091
+ def reconcile_interrupted_runs(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
1092
+ return self.runs.reconcile_interrupted_runs(*args, **kwargs)
1909
1093
 
1910
- def get_workflow_run(self, run_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
1911
- run = next((item for item in _listify(self.load_state().get("workflow_runs")) if item.get("id") == run_id), None)
1912
- if not run or (workspace_id and self._record_workspace(run) != str(workspace_id)):
1913
- raise FileNotFoundError(run_id)
1914
- return run
1094
+ @staticmethod
1095
+ def _replay_frames(run: Dict[str, Any], *, kind: str) -> List[Dict[str, Any]]:
1096
+ return WorkspaceRuns._replay_frames(run, kind=kind)
1097
+
1098
+ def replay_agent_run(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
1099
+ return self.runs.replay_agent_run(*args, **kwargs)
1100
+
1101
+ def replay_workflow_run(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
1102
+ return self.runs.replay_workflow_run(*args, **kwargs)
1103
+
1104
+ def get_workflow(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
1105
+ return self.runs.get_workflow(*args, **kwargs)
1106
+
1107
+ def update_workflow_definition(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
1108
+ return self.runs.update_workflow_definition(*args, **kwargs)
1109
+
1110
+ def list_workflows(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
1111
+ return self.runs.list_workflows(*args, **kwargs)
1112
+
1113
+ def record_workflow_event(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
1114
+ return self.runs.record_workflow_event(*args, **kwargs)
1915
1115
 
1916
1116
  # ── review queue (5.6.0) ─────────────────────────────────────────────
1917
1117
  # Workspace-scoped suggestion inbox. Automation/trigger runs write drafts
@@ -1997,171 +1197,6 @@ class WorkspaceOSStore:
1997
1197
  )
1998
1198
  return item
1999
1199
 
2000
- def reconcile_interrupted_runs(self, *, reason: str = "server_startup") -> Dict[str, Any]:
2001
- """Mark durable active runs as interrupted after a process restart.
2002
-
2003
- Queued/running/cancelling rows cannot have an owning asyncio task after
2004
- startup. Paused approval runs are intentionally left untouched so their
2005
- durable human decision cursor remains resumable.
2006
- """
2007
- state = self.load_state()
2008
- interrupted: List[Dict[str, Any]] = []
2009
- now = _now()
2010
- collections = (("agent_runs", "agent"), ("workflow_runs", "workflow"))
2011
- for key, area in collections:
2012
- for run in _listify(state.get(key)):
2013
- status = str(run.get("status") or "")
2014
- if status not in RUN_ACTIVE_STATUSES:
2015
- continue
2016
- run["status"] = "interrupted"
2017
- run["interrupted_at"] = now
2018
- run["interrupt_reason"] = reason
2019
- run["updated_at"] = now
2020
- run.setdefault("timeline", []).append({
2021
- "event": "execution_interrupted",
2022
- "status": "interrupted",
2023
- "reason": reason,
2024
- "timestamp": now,
2025
- })
2026
- interrupted.append({
2027
- "kind": area,
2028
- "run_id": run.get("id"),
2029
- "workspace_id": self._record_workspace(run),
2030
- "previous_status": status,
2031
- })
2032
- if not interrupted:
2033
- return {"count": 0, "interrupted": []}
2034
- self.save_state(state)
2035
- for item in interrupted:
2036
- area = item["kind"]
2037
- run_id = item["run_id"]
2038
- workspace = item.get("workspace_id")
2039
- self._emit_execution_event(
2040
- area=area,
2041
- event_type="execution_interrupted",
2042
- payload={"run_id": run_id, "reason": reason, "previous_status": item.get("previous_status")},
2043
- workspace_id=workspace,
2044
- )
2045
- self.record_timeline_event(
2046
- "system",
2047
- "startup_reconciliation",
2048
- {"interrupted_runs": len(interrupted), "reason": reason},
2049
- )
2050
- return {"count": len(interrupted), "interrupted": interrupted}
2051
-
2052
- @staticmethod
2053
- def _replay_frames(run: Dict[str, Any], *, kind: str) -> List[Dict[str, Any]]:
2054
- frames = []
2055
- for index, item in enumerate(run.get("timeline") or []):
2056
- event = item.get("event") or item.get("event_type") or item.get("type") or "event"
2057
- actor = (
2058
- item.get("agent_id")
2059
- or item.get("role")
2060
- or item.get("source_agent")
2061
- or item.get("target_agent")
2062
- or item.get("node")
2063
- or kind
2064
- )
2065
- result = item.get("result") if "result" in item else item.get("output")
2066
- decision = item.get("outcome") or item.get("verdict") or item.get("status")
2067
- frames.append({
2068
- "index": index,
2069
- "event": event,
2070
- "actor": actor,
2071
- "when": item.get("timestamp") or item.get("started_at") or run.get("created_at"),
2072
- "why": item.get("reason") or item.get("note") or item.get("name") or "",
2073
- "input": item.get("context_packet") or item.get("trigger") or run.get("input"),
2074
- "output": result,
2075
- "decision": decision,
2076
- "raw": item,
2077
- })
2078
- return frames
2079
-
2080
- def replay_agent_run(self, run_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
2081
- run = self.get_agent_run(run_id, workspace_id=workspace_id)
2082
- return {
2083
- "kind": "agent",
2084
- "run_id": run_id,
2085
- "status": run.get("status"),
2086
- "workspace_id": self._record_workspace(run),
2087
- "contract": run.get("contract") or run_record_contract(run),
2088
- "replayable": True,
2089
- "frames": self._replay_frames(run, kind="agent"),
2090
- "handoffs": run.get("handoffs") or [],
2091
- "context_packets": run.get("context_packets") or [],
2092
- "review_history": run.get("review_history") or [],
2093
- "retry_history": run.get("retry_history") or [],
2094
- }
2095
-
2096
- def replay_workflow_run(self, run_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
2097
- run = self.get_workflow_run(run_id, workspace_id=workspace_id)
2098
- return {
2099
- "kind": "workflow",
2100
- "run_id": run_id,
2101
- "status": run.get("status"),
2102
- "workspace_id": self._record_workspace(run),
2103
- "contract": run.get("contract") or workflow_run_contract(run),
2104
- "replayable": True,
2105
- "frames": self._replay_frames(run, kind="workflow"),
2106
- "outputs": run.get("outputs") or {},
2107
- }
2108
-
2109
- def get_workflow(self, workflow_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
2110
- workflow = next((wf for wf in _listify(self.load_state().get("workflows")) if wf.get("id") == workflow_id), None)
2111
- if not workflow or (workspace_id and self._record_workspace(workflow) != str(workspace_id)):
2112
- raise FileNotFoundError(workflow_id)
2113
- return workflow
2114
-
2115
- def update_workflow_definition(
2116
- self,
2117
- workflow_id: str,
2118
- *,
2119
- name: Optional[str] = None,
2120
- nodes: Optional[List[Dict[str, Any]]] = None,
2121
- metadata: Optional[Dict[str, Any]] = None,
2122
- workspace_id: Optional[str] = None,
2123
- ) -> Dict[str, Any]:
2124
- """Edit a stored workflow's node graph / name without losing its history."""
2125
- state = self.load_state()
2126
- workflow = next((wf for wf in _listify(state.get("workflows")) if wf.get("id") == workflow_id), None)
2127
- if not workflow or (workspace_id and self._record_workspace(workflow) != str(workspace_id)):
2128
- raise FileNotFoundError(workflow_id)
2129
- if name is not None and str(name).strip():
2130
- workflow["name"] = str(name).strip()
2131
- if nodes is not None:
2132
- workflow["nodes"] = nodes
2133
- if metadata is not None:
2134
- workflow["metadata"] = {**(workflow.get("metadata") or {}), **metadata}
2135
- workflow.setdefault("events", []).append({"type": "edited", "timestamp": _now()})
2136
- workflow["updated_at"] = _now()
2137
- self.save_state(state)
2138
- self.record_timeline_event("workflow", "workflow_edited", {"workflow_id": workflow_id})
2139
- return workflow
2140
-
2141
- def list_workflows(self, query: str = "", workspace_id: Optional[str] = None) -> Dict[str, Any]:
2142
- workflows = list(reversed(self._scoped(_listify(self.load_state().get("workflows")), workspace_id)))
2143
- q = str(query or "").lower().strip()
2144
- if q:
2145
- workflows = [
2146
- wf for wf in workflows
2147
- if q in str(wf.get("name") or "").lower()
2148
- or q in json.dumps(wf.get("steps") or [], ensure_ascii=False).lower()
2149
- ]
2150
- return {"workflows": workflows}
2151
-
2152
- def record_workflow_event(self, workflow_id: str, event_type: str, payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
2153
- state = self.load_state()
2154
- workflows = _listify(state.get("workflows"))
2155
- workflow = next((item for item in workflows if item.get("id") == workflow_id), None)
2156
- if not workflow:
2157
- raise FileNotFoundError(workflow_id)
2158
- event = {"type": event_type, "timestamp": _now(), "payload": payload or {}}
2159
- workflow.setdefault("events", []).append(event)
2160
- workflow["updated_at"] = _now()
2161
- self.save_state(state)
2162
- self.record_timeline_event("workflow", "workflow_event", {"workflow_id": workflow_id, "event_type": event_type})
2163
- return workflow
2164
-
2165
1200
  # ------------------------------------------------------------------
2166
1201
  # Relationship explorer
2167
1202
  # ------------------------------------------------------------------
@@ -2284,144 +1319,33 @@ class WorkspaceOSStore:
2284
1319
  # Skills
2285
1320
  # ------------------------------------------------------------------
2286
1321
 
2287
- def list_skill_registry(self, skills_dir: Path, marketplace: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
2288
- state = self.load_state()
2289
- registry = state.setdefault("skill_registry", {})
2290
- installed = []
2291
- if skills_dir.exists():
2292
- for skill_dir in sorted(skills_dir.iterdir()):
2293
- if not skill_dir.is_dir():
2294
- continue
2295
- skill_md = skill_dir / "SKILL.md"
2296
- schema = skill_dir / "schema.json"
2297
- if not skill_md.exists():
2298
- continue
2299
- desc = ""
2300
- try:
2301
- for line in skill_md.read_text(encoding="utf-8").splitlines():
2302
- if line.startswith("description:"):
2303
- desc = line.split(":", 1)[1].strip()
2304
- break
2305
- except Exception:
2306
- desc = ""
2307
- version = "local"
2308
- if schema.exists():
2309
- try:
2310
- version = str((json.loads(schema.read_text(encoding="utf-8")) or {}).get("version") or "local")
2311
- except Exception:
2312
- version = "local"
2313
- entry = registry.setdefault(skill_dir.name, {})
2314
- entry.setdefault("enabled", True)
2315
- entry.update({
2316
- "name": skill_dir.name,
2317
- "description": desc,
2318
- "version": version,
2319
- "installed": True,
2320
- "install_status": entry.get("install_status") or "ready",
2321
- "validation_status": "ready" if skill_md.exists() else "missing_manifest",
2322
- "source": entry.get("source") or "local",
2323
- "path": str(skill_dir),
2324
- "updated_at": entry.get("updated_at") or _now(),
2325
- })
2326
- installed.append(entry)
2327
- available = []
2328
- for item in marketplace or []:
2329
- name = item.get("skill") or item.get("name")
2330
- if not name:
2331
- continue
2332
- state_entry = registry.get(name, {})
2333
- available.append({
2334
- **item,
2335
- "enabled": bool(state_entry.get("enabled", True)),
2336
- "installed": bool(state_entry.get("installed")),
2337
- "install_status": state_entry.get("install_status") or ("ready" if state_entry.get("installed") else "available"),
2338
- "validation_status": state_entry.get("validation_status") or item.get("validation_status") or ("ready" if state_entry.get("installed") else "not_installed"),
2339
- "source": state_entry.get("source") or item.get("source") or item.get("plugin") or "marketplace",
2340
- "version": state_entry.get("version") or item.get("version") or "remote",
2341
- })
2342
- self.save_state(state)
2343
- return {
2344
- "installed": installed,
2345
- "available": available,
2346
- "registry": registry,
2347
- "total_installed": len(installed),
2348
- "total_available": len(available),
2349
- }
1322
+ def list_skill_registry(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
1323
+ return self.skills.list_skill_registry(*args, **kwargs)
2350
1324
 
2351
- def set_skill_enabled(self, skill: str, enabled: bool) -> Dict[str, Any]:
2352
- state = self.load_state()
2353
- entry = state.setdefault("skill_registry", {}).setdefault(skill, {"name": skill})
2354
- entry["enabled"] = bool(enabled)
2355
- entry["updated_at"] = _now()
2356
- self.save_state(state)
2357
- self.record_timeline_event("skills", "skill_enabled" if enabled else "skill_disabled", {"skill": skill})
2358
- return entry
1325
+ def set_skill_enabled(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
1326
+ return self.skills.set_skill_enabled(*args, **kwargs)
2359
1327
 
2360
- def mark_skill_installed(self, skill: str, *, version: str = "local", metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
2361
- state = self.load_state()
2362
- entry = state.setdefault("skill_registry", {}).setdefault(skill, {"name": skill})
2363
- entry.update({
2364
- "installed": True,
2365
- "enabled": entry.get("enabled", True),
2366
- "version": version,
2367
- "install_status": "ready",
2368
- "validation_status": "ready",
2369
- "source": (metadata or {}).get("source") or entry.get("source") or "marketplace",
2370
- "metadata": metadata or entry.get("metadata") or {},
2371
- "updated_at": _now(),
2372
- })
2373
- self.save_state(state)
2374
- self.record_timeline_event("skills", "skill_installed", {"skill": skill, "version": version})
2375
- return entry
1328
+ def mark_skill_installed(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
1329
+ return self.skills.mark_skill_installed(*args, **kwargs)
2376
1330
 
2377
- def mark_skill_uninstalled(self, skill: str) -> Dict[str, Any]:
2378
- state = self.load_state()
2379
- entry = state.setdefault("skill_registry", {}).setdefault(skill, {"name": skill})
2380
- entry.update({"installed": False, "enabled": False, "updated_at": _now()})
2381
- self.save_state(state)
2382
- self.record_timeline_event("skills", "skill_uninstalled", {"skill": skill})
2383
- return entry
1331
+ def mark_skill_uninstalled(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
1332
+ return self.skills.mark_skill_uninstalled(*args, **kwargs)
2384
1333
 
2385
1334
  # ------------------------------------------------------------------
2386
1335
  # Plugin SDK registry — mirrors the skill registry contract.
2387
1336
  # ------------------------------------------------------------------
2388
1337
 
2389
1338
  def list_plugin_registry(self) -> Dict[str, Any]:
2390
- return dict(self.load_state().get("plugin_registry") or {})
1339
+ return self.plugins.list_plugin_registry()
2391
1340
 
2392
1341
  def set_plugin_enabled(self, plugin_id: str, enabled: bool) -> Dict[str, Any]:
2393
- state = self.load_state()
2394
- entry = state.setdefault("plugin_registry", {}).setdefault(plugin_id, {"id": plugin_id})
2395
- entry["enabled"] = bool(enabled)
2396
- entry["updated_at"] = _now()
2397
- self.save_state(state)
2398
- self.record_timeline_event("plugins", "plugin_enabled" if enabled else "plugin_disabled", {"plugin": plugin_id})
2399
- return entry
1342
+ return self.plugins.set_plugin_enabled(plugin_id, enabled)
2400
1343
 
2401
1344
  def mark_plugin_installed(self, plugin_id: str, *, version: str = "0.0.0", metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
2402
- state = self.load_state()
2403
- entry = state.setdefault("plugin_registry", {}).setdefault(plugin_id, {"id": plugin_id})
2404
- entry.update({
2405
- "id": plugin_id,
2406
- "installed": True,
2407
- "enabled": entry.get("enabled", True),
2408
- "version": version,
2409
- "install_status": "ready",
2410
- "validation_status": "valid",
2411
- "metadata": metadata or entry.get("metadata") or {},
2412
- "updated_at": _now(),
2413
- })
2414
- self.save_state(state)
2415
- self.record_timeline_event("plugins", "plugin_installed", {"plugin": plugin_id, "version": version})
2416
- return entry
1345
+ return self.plugins.mark_plugin_installed(plugin_id, version=version, metadata=metadata)
2417
1346
 
2418
1347
  def mark_plugin_uninstalled(self, plugin_id: str) -> Dict[str, Any]:
2419
- state = self.load_state()
2420
- entry = state.setdefault("plugin_registry", {}).setdefault(plugin_id, {"id": plugin_id})
2421
- entry.update({"installed": False, "enabled": False, "updated_at": _now()})
2422
- self.save_state(state)
2423
- self.record_timeline_event("plugins", "plugin_uninstalled", {"plugin": plugin_id})
2424
- return {"status": "ok", "plugin_id": plugin_id, "registry": entry}
1348
+ return self.plugins.mark_plugin_uninstalled(plugin_id)
2425
1349
 
2426
1350
  # ------------------------------------------------------------------
2427
1351
  # Marketplace template registry (v2.1 foundation)
@@ -2433,16 +1357,7 @@ class WorkspaceOSStore:
2433
1357
  return base if workspace_id == DEFAULT_WORKSPACE_ID else f"{workspace_id}:{base}"
2434
1358
 
2435
1359
  def list_template_registry(self, workspace_id: Optional[str] = None) -> Dict[str, Any]:
2436
- state = self.load_state()
2437
- registry = dict(state.get("template_registry") or {})
2438
- if workspace_id is None:
2439
- return registry
2440
- scope = self._resolve_scope(workspace_id, state)
2441
- return {
2442
- key: value
2443
- for key, value in registry.items()
2444
- if self._record_workspace(value) == scope
2445
- }
1360
+ return self.plugins.list_template_registry(workspace_id)
2446
1361
 
2447
1362
  def mark_template_installed(
2448
1363
  self,
@@ -2453,22 +1368,7 @@ class WorkspaceOSStore:
2453
1368
  metadata: Optional[Dict[str, Any]] = None,
2454
1369
  workspace_id: Optional[str] = None,
2455
1370
  ) -> Dict[str, Any]:
2456
- state = self.load_state()
2457
- scope = self._resolve_scope(workspace_id, state)
2458
- key = self._template_registry_key(kind, template_id, scope)
2459
- entry = state.setdefault("template_registry", {}).setdefault(key, {"id": template_id, "kind": kind})
2460
- entry.update({
2461
- "id": template_id,
2462
- "kind": kind,
2463
- "version": version,
2464
- "installed": True,
2465
- "workspace_id": scope,
2466
- "metadata": metadata or entry.get("metadata") or {},
2467
- "updated_at": _now(),
2468
- })
2469
- self.save_state(state)
2470
- self.record_timeline_event("marketplace", "template_installed", {"kind": kind, "template_id": template_id}, workspace_id=scope)
2471
- return entry
1371
+ return self.plugins.mark_template_installed(kind=kind, template_id=template_id, version=version, metadata=metadata, workspace_id=workspace_id)
2472
1372
 
2473
1373
  # ------------------------------------------------------------------
2474
1374
  # Audit timeline
@@ -2485,55 +1385,7 @@ class WorkspaceOSStore:
2485
1385
  until: Optional[str] = None,
2486
1386
  limit: int = 100,
2487
1387
  ) -> Dict[str, Any]:
2488
- since_dt = _parse_iso(since)
2489
- until_dt = _parse_iso(until)
2490
- filtered = []
2491
- for event in audit_events:
2492
- stamp = _parse_iso(event.get("timestamp"))
2493
- if user and user.lower() not in str(event.get("user_email") or event.get("user") or "").lower():
2494
- continue
2495
- if event_type and event_type.lower() not in str(event.get("event_type") or "").lower():
2496
- continue
2497
- if model and model.lower() not in json.dumps(event, ensure_ascii=False).lower():
2498
- continue
2499
- if since_dt and stamp and stamp < since_dt:
2500
- continue
2501
- if until_dt and stamp and stamp > until_dt:
2502
- continue
2503
- filtered.append({
2504
- **event,
2505
- "category": self._audit_category(event),
2506
- })
2507
- filtered.sort(key=lambda item: item.get("timestamp") or "", reverse=True)
2508
- return {"events": filtered[: max(1, min(limit, 1000))], "total": len(filtered)}
1388
+ return self._timeline.filter_audit_timeline(audit_events, user=user, event_type=event_type, model=model, since=since, until=until, limit=limit)
2509
1389
 
2510
- @staticmethod
2511
- def _audit_category(event: Dict[str, Any]) -> str:
2512
- raw = str(event.get("event_type") or "").lower()
2513
- if "model" in raw or "chat" in raw:
2514
- return "model_usage"
2515
- if "file" in raw or "document" in raw or "local" in raw:
2516
- return "file_access"
2517
- if "folder" in raw or "permission" in raw:
2518
- return "folder_approval"
2519
- if "sensitive" in raw or "secret" in raw:
2520
- return "sensitive_data"
2521
- if "admin" in raw or "user" in raw or "sso" in raw:
2522
- return "admin_action"
2523
- if "security" in raw or "auth" in raw or "login" in raw:
2524
- return "security_event"
2525
- return "workspace_event"
2526
-
2527
-
2528
- def remove_skill_directory(skills_dir: Path, skill: str) -> Dict[str, Any]:
2529
- """Remove an installed skill directory after caller has performed auth checks."""
2530
-
2531
- safe_name = _safe_slug(skill)
2532
- target = (skills_dir / safe_name).resolve()
2533
- root = skills_dir.resolve()
2534
- if not str(target).startswith(str(root)):
2535
- raise ValueError("invalid skill path")
2536
- if not target.exists() or not target.is_dir():
2537
- raise FileNotFoundError(skill)
2538
- shutil.rmtree(target)
2539
- return {"status": "ok", "skill": safe_name, "removed_path": str(target)}
1390
+ def timeline(self, audit_events: Optional[Iterable[Dict[str, Any]]] = None, limit: int = 100, workspace_id: Optional[str] = None) -> Dict[str, Any]:
1391
+ return self._timeline.timeline(audit_events=audit_events, limit=limit, workspace_id=workspace_id)