ltcai 8.1.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.
- package/README.md +20 -41
- package/docs/CHANGELOG.md +38 -874
- package/docs/DEVELOPMENT.md +26 -3
- package/docs/LEGACY_COMPATIBILITY.md +3 -1
- package/docs/TRUST_MODEL.md +26 -26
- package/docs/WHY_LATTICE.md +28 -25
- package/docs/kg-schema.md +6 -4
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/schema.py +6 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/computer_use.py +2 -0
- package/latticeai/api/memory.py +13 -0
- package/latticeai/app_factory.py +30 -28
- package/latticeai/brain/__init__.py +1 -1
- package/latticeai/core/config.py +10 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/tool_registry.py +10 -0
- package/latticeai/core/workspace_graph_trace.py +132 -0
- package/latticeai/core/workspace_memory.py +75 -0
- package/latticeai/core/workspace_os.py +135 -1283
- package/latticeai/core/workspace_os_utils.py +132 -0
- package/latticeai/core/workspace_permissions.py +99 -0
- package/latticeai/core/workspace_plugins.py +97 -0
- package/latticeai/core/workspace_runs.py +612 -0
- package/latticeai/core/workspace_skills.py +114 -0
- package/latticeai/core/workspace_snapshots.py +195 -0
- package/latticeai/core/workspace_timeline.py +107 -0
- package/latticeai/runtime/audit_runtime.py +64 -0
- package/latticeai/runtime/config_runtime.py +4 -0
- package/latticeai/runtime/sso_runtime.py +52 -0
- package/latticeai/services/architecture_readiness.py +3 -3
- package/latticeai/services/memory_service.py +215 -7
- package/latticeai/services/model_capability_registry.py +68 -0
- package/latticeai/services/model_engines.py +603 -0
- package/latticeai/services/model_loading.py +482 -0
- package/latticeai/services/model_recommendation.py +5 -0
- package/latticeai/services/model_runtime.py +141 -866
- package/latticeai/services/product_readiness.py +19 -18
- package/package.json +1 -1
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +11 -11
- package/static/app/assets/{Act-BOO66G-c.js → Act-D9jIknFd.js} +1 -1
- package/static/app/assets/{Brain-C6lEYiD7.js → Brain-CFOtWbPN.js} +1 -1
- package/static/app/assets/{Capture-TATXBRDw.js → Capture-Q4WYzwr5.js} +1 -1
- package/static/app/assets/{Library-DK4FIp8a.js → Library-C5Q2yWee.js} +1 -1
- package/static/app/assets/{System-Bgs6Ql7x.js → System-BLbjdr1_.js} +1 -1
- package/static/app/assets/index-BqammyNu.js +16 -0
- package/static/app/assets/index-ty1iGgZu.css +2 -0
- package/static/app/assets/{primitives-B70WOra0.js → primitives-Br8uSfZ4.js} +1 -1
- package/static/app/assets/{textarea-Czrd9gwM.js → textarea-BnhNs1_X.js} +1 -1
- package/static/app/index.html +2 -2
- package/static/sw.js +1 -1
- package/tools/__init__.py +2 -1
- package/tools/computer.py +21 -0
- package/docs/CARRYOVER_AUDIT_v3.6.0.md +0 -61
- package/docs/HANDOVER_v3.6.0.md +0 -46
- package/docs/RUNTIME_HOOK_COVERAGE_v3.5.0.md +0 -56
- package/docs/RUNTIME_HOOK_COVERAGE_v3.6.0.md +0 -49
- package/docs/V2_ARCHITECTURE.md +0 -561
- package/docs/V3_2_AUDIT.md +0 -82
- package/docs/V3_BACKEND_ARCHITECTURE.md +0 -138
- package/docs/V3_FRONTEND.md +0 -146
- package/docs/V4_1_FRONTEND_ARCHITECTURE_REVIEW.md +0 -65
- package/docs/V4_1_FRONTEND_MIGRATION_REPORT.md +0 -70
- package/docs/V4_1_VALIDATION_REPORT.md +0 -47
- package/docs/V4_2_BRAIN_CORE_ARCHITECTURE.md +0 -97
- package/docs/V4_2_STORAGE_MIGRATION_REPORT.md +0 -91
- package/docs/V4_2_VALIDATION_REPORT.md +0 -89
- package/docs/V4_3_2_DEADCODE_AUDIT_REPORT.md +0 -174
- package/docs/V4_3_2_DOCUMENTATION_CLEANUP_REPORT.md +0 -81
- package/docs/V4_3_2_GITHUB_VERCEL_CHECK_REPORT.md +0 -75
- package/docs/V4_3_2_GRAPH_UX_REPORT.md +0 -48
- package/docs/V4_3_2_INDEPENDENT_AUDIT_PACKAGE.md +0 -209
- package/docs/V4_3_2_PRODUCT_POLISH_REPORT.md +0 -57
- package/docs/V4_3_2_SELF_AUDIT_REPORT.md +0 -63
- package/docs/V4_3_2_VALIDATION_REPORT.md +0 -97
- package/docs/V4_3_3_VALIDATION_REPORT.md +0 -46
- package/docs/V4_3_PORTABILITY_ARCHITECTURE.md +0 -69
- package/docs/V4_3_PRIVACY_AUDIT.md +0 -60
- package/docs/V4_3_PRODUCT_HARDENING_REPORT.md +0 -53
- package/docs/V4_3_VALIDATION_REPORT.md +0 -58
- package/docs/V4_4_0_EXTRACTION_REPORT.md +0 -239
- package/docs/V4_5_0_GEMMA_RUNTIME_COMPATIBILITY_REPORT.md +0 -49
- package/docs/V4_5_0_GRAPH_UX_REPORT.md +0 -34
- package/docs/V4_5_0_MODEL_RUNTIME_UX_REPORT.md +0 -40
- package/docs/V4_5_0_ONBOARDING_REPORT.md +0 -31
- package/docs/V4_5_0_PRODUCT_EXPERIENCE_RECOVERY_REPORT.md +0 -49
- package/docs/V4_5_0_VALIDATION_REPORT.md +0 -60
- package/docs/V4_5_1_GRAPH_EXPERIENCE_REPORT.md +0 -33
- package/docs/V4_5_1_MODEL_EXPERIENCE_REPORT.md +0 -37
- package/docs/V4_5_1_NAVIGATION_REPORT.md +0 -37
- package/docs/V4_5_1_ONBOARDING_REPORT.md +0 -29
- package/docs/V4_5_1_PRODUCT_REIMAGINING_REPORT.md +0 -61
- package/docs/V4_5_1_RC_ARTIFACTS.md +0 -44
- package/docs/V4_5_1_UX_REPORT.md +0 -45
- package/docs/V4_5_1_VALIDATION_REPORT.md +0 -55
- package/docs/V4_5_1_VISUAL_DESIGN_REPORT.md +0 -30
- package/docs/V4_6_0_LIVING_BRAIN_EXPERIENCE_REPORT.md +0 -72
- package/docs/V4_6_1_RELEASE_REFRESH_REPORT.md +0 -42
- package/docs/V4_7_0_ADMIN_SEPARATION_REPORT.md +0 -42
- package/docs/V4_7_1_ADMIN_OPERATIONS_REPORT.md +0 -49
- package/docs/V4_7_2_INTUITIVE_BRAIN_UX_REPORT.md +0 -62
- package/docs/V4_BRAIN_ARCHITECTURE.md +0 -322
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +0 -555
- package/docs/V4_IMPLEMENTATION_PLAN.md +0 -470
- package/static/app/assets/index-Bvv79nre.js +0 -16
- package/static/app/assets/index-Dslqglia.css +0 -2
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
714
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1011
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
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
|
-
|
|
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,
|
|
1490
|
-
|
|
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
|
-
|
|
1592
|
-
|
|
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
|
-
|
|
1649
|
-
|
|
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
|
-
|
|
1658
|
-
|
|
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
|
-
|
|
1862
|
-
|
|
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
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
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
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
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,
|
|
2288
|
-
|
|
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,
|
|
2352
|
-
|
|
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,
|
|
2361
|
-
|
|
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,
|
|
2378
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2511
|
-
|
|
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)
|