ltcai 8.1.0 → 8.3.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 +29 -48
- package/docs/CHANGELOG.md +70 -874
- package/docs/COMMUNITY_AND_PLUGINS.md +43 -0
- package/docs/DEVELOPMENT.md +26 -3
- package/docs/LEGACY_COMPATIBILITY.md +23 -1
- package/docs/ONBOARDING.md +38 -0
- package/docs/TRUST_MODEL.md +26 -26
- package/docs/WHY_LATTICE.md +29 -25
- package/docs/kg-schema.md +7 -5
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/ingest.py +40 -8
- package/lattice_brain/graph/schema.py +6 -0
- package/lattice_brain/runtime/agent_runtime.py +22 -37
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/workflow.py +26 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/computer_use.py +2 -0
- package/latticeai/api/knowledge_graph.py +33 -0
- package/latticeai/api/local_files.py +2 -0
- package/latticeai/api/memory.py +13 -0
- package/latticeai/api/tools.py +1 -0
- package/latticeai/api/workflow_designer.py +5 -4
- package/latticeai/app_factory.py +30 -28
- package/latticeai/brain/__init__.py +1 -1
- package/latticeai/core/config.py +10 -0
- package/latticeai/core/legacy_compatibility.py +174 -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 +37 -3
- package/latticeai/services/memory_service.py +215 -7
- package/latticeai/services/model_capability_registry.py +68 -0
- package/latticeai/services/model_engines.py +594 -0
- package/latticeai/services/model_loading.py +482 -0
- package/latticeai/services/model_recommendation.py +5 -0
- package/latticeai/services/model_runtime.py +131 -875
- package/latticeai/services/product_readiness.py +41 -18
- package/llm_router.py +7 -28
- package/mcp_registry.py +7 -24
- package/package.json +1 -1
- package/scripts/pts-claudecode-discord-bridge.mjs +2 -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-D5mo4tE4.js} +1 -1
- package/static/app/assets/{Brain-C6lEYiD7.js → Brain-BVWyQw8A.js} +1 -1
- package/static/app/assets/{Capture-TATXBRDw.js → Capture-C1R6GT0t.js} +1 -1
- package/static/app/assets/{Library-DK4FIp8a.js → Library-C2wIxpTs.js} +1 -1
- package/static/app/assets/{System-Bgs6Ql7x.js → System-DE5GRyQR.js} +1 -1
- package/static/app/assets/index-CoiuIFFP.js +16 -0
- package/static/app/assets/index-ty1iGgZu.css +2 -0
- package/static/app/assets/{primitives-B70WOra0.js → primitives-BdsUNXa6.js} +1 -1
- package/static/app/assets/{textarea-Czrd9gwM.js → textarea-e7qaj6Hm.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
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Helper utilities extracted from the monolithic WorkspaceOSStore module.
|
|
2
|
+
|
|
3
|
+
These are internal (_-prefixed) helpers for JSON handling, slugging, timeline,
|
|
4
|
+
and snapshot conversion. Moving them keeps workspace_os.py focused on the
|
|
5
|
+
store class and public constants while preserving exact behavior.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _now() -> str:
|
|
18
|
+
return datetime.now().isoformat(timespec="seconds")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _safe_slug(raw: str) -> str:
|
|
22
|
+
value = "".join(ch if ch.isalnum() or ch in "-_." else "-" for ch in str(raw or "").strip())
|
|
23
|
+
value = "-".join(part for part in value.split("-") if part)
|
|
24
|
+
return (value or "item")[:96]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _json_hash(value: Any) -> str:
|
|
28
|
+
import hashlib
|
|
29
|
+
|
|
30
|
+
payload = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
|
|
31
|
+
return hashlib.sha256(payload.encode("utf-8", errors="replace")).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _deep_merge(default: Any, loaded: Any) -> Any:
|
|
35
|
+
if isinstance(default, dict) and isinstance(loaded, dict):
|
|
36
|
+
merged = {key: _deep_merge(value, loaded.get(key)) for key, value in default.items()}
|
|
37
|
+
for key, value in loaded.items():
|
|
38
|
+
if key not in merged:
|
|
39
|
+
merged[key] = value
|
|
40
|
+
return merged
|
|
41
|
+
if loaded is None:
|
|
42
|
+
return default
|
|
43
|
+
return loaded
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _atomic_write_json(path: Path, payload: Dict[str, Any]) -> None:
|
|
47
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
|
49
|
+
tmp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
50
|
+
os.replace(tmp_path, path)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _listify(value: Any) -> List[Any]:
|
|
54
|
+
return value if isinstance(value, list) else []
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _snapshot_graph_import_payload(graph_payload: Dict[str, Any], *, workspace_id: Optional[str]) -> Dict[str, Any]:
|
|
58
|
+
"""Convert the UI graph snapshot shape into the logical import artifact."""
|
|
59
|
+
|
|
60
|
+
nodes = []
|
|
61
|
+
for node in _listify((graph_payload or {}).get("nodes")):
|
|
62
|
+
node_id = node.get("id")
|
|
63
|
+
if not node_id:
|
|
64
|
+
continue
|
|
65
|
+
metadata = node.get("metadata") if isinstance(node.get("metadata"), dict) else {}
|
|
66
|
+
raw = node.get("raw") if isinstance(node.get("raw"), dict) else {}
|
|
67
|
+
if workspace_id and not metadata.get("workspace_id"):
|
|
68
|
+
metadata = {**metadata, "workspace_id": workspace_id}
|
|
69
|
+
nodes.append({
|
|
70
|
+
"id": node_id,
|
|
71
|
+
"type": node.get("type") or "Concept",
|
|
72
|
+
"title": node.get("title") or node.get("label") or node_id,
|
|
73
|
+
"summary": node.get("summary") or "",
|
|
74
|
+
"metadata_json": json.dumps(metadata, ensure_ascii=False),
|
|
75
|
+
"raw_json": json.dumps(raw, ensure_ascii=False),
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
edges = []
|
|
79
|
+
for edge in _listify((graph_payload or {}).get("edges")):
|
|
80
|
+
source = edge.get("from_node") or edge.get("from") or edge.get("source")
|
|
81
|
+
target = edge.get("to_node") or edge.get("to") or edge.get("target")
|
|
82
|
+
if not source or not target:
|
|
83
|
+
continue
|
|
84
|
+
edges.append({
|
|
85
|
+
"from_node": source,
|
|
86
|
+
"to_node": target,
|
|
87
|
+
"type": edge.get("type") or "related_to",
|
|
88
|
+
"weight": edge.get("weight") or 1.0,
|
|
89
|
+
"metadata_json": json.dumps(edge.get("metadata") or {}, ensure_ascii=False),
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
"header": {"graph_schema_version": 1, "workspace_id": workspace_id, "source": "workspace_snapshot"},
|
|
94
|
+
"nodes": nodes,
|
|
95
|
+
"edges": edges,
|
|
96
|
+
"chunks": [],
|
|
97
|
+
"knowledge_sources": [],
|
|
98
|
+
"provenance": [],
|
|
99
|
+
"counts": {"nodes": len(nodes), "edges": len(edges)},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _parse_iso(value: Optional[str]) -> Optional[datetime]:
|
|
104
|
+
if not value:
|
|
105
|
+
return None
|
|
106
|
+
try:
|
|
107
|
+
return datetime.fromisoformat(str(value))
|
|
108
|
+
except (TypeError, ValueError):
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _file_size(path: Path) -> int:
|
|
113
|
+
try:
|
|
114
|
+
return path.stat().st_size
|
|
115
|
+
except Exception:
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def remove_skill_directory(skills_dir: Path, skill: str) -> Dict[str, Any]:
|
|
120
|
+
"""Remove an installed skill directory after caller has performed auth checks.
|
|
121
|
+
|
|
122
|
+
Moved here from workspace_os.py for smaller focused surface.
|
|
123
|
+
"""
|
|
124
|
+
safe_name = _safe_slug(skill)
|
|
125
|
+
target = (skills_dir / safe_name).resolve()
|
|
126
|
+
root = skills_dir.resolve()
|
|
127
|
+
if not str(target).startswith(str(root)):
|
|
128
|
+
raise ValueError("invalid skill path")
|
|
129
|
+
if not target.exists() or not target.is_dir():
|
|
130
|
+
raise FileNotFoundError(skill)
|
|
131
|
+
shutil.rmtree(target)
|
|
132
|
+
return {"status": "ok", "skill": safe_name, "removed_path": str(target)}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Workspace permission and role logic extracted from WorkspaceOSStore.
|
|
2
|
+
|
|
3
|
+
Provides pure functions + a small PermissionManager for composition.
|
|
4
|
+
All behavior preserved; main store continues to expose the same public methods.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
|
|
10
|
+
from .workspace_os_utils import _listify, _now
|
|
11
|
+
|
|
12
|
+
# Avoid circular at import time: pull constants lazily from parent module.
|
|
13
|
+
def _get_role_permissions():
|
|
14
|
+
from .workspace_os import ROLE_PERMISSIONS
|
|
15
|
+
return ROLE_PERMISSIONS
|
|
16
|
+
|
|
17
|
+
def _get_workspace_roles():
|
|
18
|
+
from .workspace_os import WORKSPACE_ROLES
|
|
19
|
+
return WORKSPACE_ROLES
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _member_role(ws: Dict[str, Any], user_id: Optional[str]) -> Optional[str]:
|
|
23
|
+
if ws.get("type") == "personal":
|
|
24
|
+
return "owner"
|
|
25
|
+
owner = ws.get("owner_user_id")
|
|
26
|
+
if not owner and not user_id:
|
|
27
|
+
return "owner"
|
|
28
|
+
if user_id and user_id == owner:
|
|
29
|
+
return "owner"
|
|
30
|
+
for member in _listify(ws.get("members")):
|
|
31
|
+
if member.get("user_id") == user_id:
|
|
32
|
+
return member.get("role")
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def has_permission(ws_or_store: Any, workspace_id: str, user_id: Optional[str], permission: str) -> bool:
|
|
37
|
+
"""Works both as standalone or delegated."""
|
|
38
|
+
if hasattr(ws_or_store, "load_state"):
|
|
39
|
+
# called as method on store
|
|
40
|
+
ws = (ws_or_store.load_state().get("workspaces") or {}).get(workspace_id)
|
|
41
|
+
else:
|
|
42
|
+
ws = ws_or_store
|
|
43
|
+
if not ws:
|
|
44
|
+
return False
|
|
45
|
+
role = _member_role(ws, user_id)
|
|
46
|
+
if role is None:
|
|
47
|
+
return False
|
|
48
|
+
return permission in _get_role_permissions().get(role, set())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class WorkspacePermissionManager:
|
|
52
|
+
"""Composable permission manager."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, store: Any):
|
|
55
|
+
self._store = store
|
|
56
|
+
|
|
57
|
+
def get_member_role(self, workspace_id: str, user_id: Optional[str]) -> Optional[str]:
|
|
58
|
+
ws = (self._store.load_state().get("workspaces") or {}).get(workspace_id)
|
|
59
|
+
if not ws:
|
|
60
|
+
raise FileNotFoundError(workspace_id)
|
|
61
|
+
return _member_role(ws, user_id)
|
|
62
|
+
|
|
63
|
+
def has_permission(self, workspace_id: str, user_id: Optional[str], permission: str) -> bool:
|
|
64
|
+
try:
|
|
65
|
+
role = self.get_member_role(workspace_id, user_id)
|
|
66
|
+
except FileNotFoundError:
|
|
67
|
+
return False
|
|
68
|
+
if role is None:
|
|
69
|
+
return False
|
|
70
|
+
return permission in _get_role_permissions().get(role, set())
|
|
71
|
+
|
|
72
|
+
def require_permission(self, ws: Dict[str, Any], actor: Optional[str], permission: str) -> None:
|
|
73
|
+
role = _member_role(ws, actor)
|
|
74
|
+
if role is None or permission not in _get_role_permissions().get(role, set()):
|
|
75
|
+
raise PermissionError(
|
|
76
|
+
f"'{actor or 'anonymous'}' lacks '{permission}' on workspace '{ws.get('workspace_id')}'"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def add_member(self, workspace_id: str, *, user_id: str, role: str = "member", actor: Optional[str] = None) -> Dict[str, Any]:
|
|
80
|
+
if role not in _get_workspace_roles():
|
|
81
|
+
raise ValueError(f"unknown role: {role}")
|
|
82
|
+
if not str(user_id or "").strip():
|
|
83
|
+
raise ValueError("user_id is required")
|
|
84
|
+
state = self._store.load_state()
|
|
85
|
+
ws = self._store._load_org(state, workspace_id)
|
|
86
|
+
self.require_permission(ws, actor, "manage_members")
|
|
87
|
+
members = ws.setdefault("members", [])
|
|
88
|
+
existing = next((m for m in members if m.get("user_id") == user_id), None)
|
|
89
|
+
if existing:
|
|
90
|
+
existing["role"] = role
|
|
91
|
+
existing["updated_at"] = _now()
|
|
92
|
+
else:
|
|
93
|
+
members.append({"user_id": user_id, "role": role, "added_at": _now()})
|
|
94
|
+
ws["updated_at"] = _now()
|
|
95
|
+
self._store.save_state(state)
|
|
96
|
+
self._store.record_timeline_event("workspace", "member_added", {"workspace_id": workspace_id, "user_id": user_id, "role": role})
|
|
97
|
+
return self._store._workspace_public(ws, actor)
|
|
98
|
+
|
|
99
|
+
# add thin wrappers for update/remove if needed by store
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Plugins, marketplace templates, and skill management extracted from WorkspaceOSStore.
|
|
2
|
+
|
|
3
|
+
Provides PluginManager for composition.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
from .workspace_os_utils import _now
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WorkspacePluginManager:
|
|
13
|
+
"""Composable plugin + marketplace manager."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, store: Any):
|
|
16
|
+
self._store = store
|
|
17
|
+
|
|
18
|
+
def list_plugin_registry(self) -> Dict[str, Any]:
|
|
19
|
+
return dict(self._store.load_state().get("plugin_registry") or {})
|
|
20
|
+
|
|
21
|
+
def set_plugin_enabled(self, plugin_id: str, enabled: bool) -> Dict[str, Any]:
|
|
22
|
+
state = self._store.load_state()
|
|
23
|
+
entry = state.setdefault("plugin_registry", {}).setdefault(plugin_id, {"id": plugin_id})
|
|
24
|
+
entry["enabled"] = bool(enabled)
|
|
25
|
+
entry["updated_at"] = _now()
|
|
26
|
+
self._store.save_state(state)
|
|
27
|
+
self._store.record_timeline_event("plugins", "plugin_toggled", {"plugin": plugin_id, "enabled": enabled})
|
|
28
|
+
return entry
|
|
29
|
+
|
|
30
|
+
def mark_plugin_installed(self, plugin_id: str, *, version: str = "0.0.0", metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
31
|
+
state = self._store.load_state()
|
|
32
|
+
entry = state.setdefault("plugin_registry", {}).setdefault(plugin_id, {"id": plugin_id})
|
|
33
|
+
entry.update({
|
|
34
|
+
"id": plugin_id,
|
|
35
|
+
"installed": True,
|
|
36
|
+
"enabled": entry.get("enabled", True),
|
|
37
|
+
"version": version,
|
|
38
|
+
"install_status": "ready",
|
|
39
|
+
"validation_status": "valid",
|
|
40
|
+
"metadata": metadata or entry.get("metadata") or {},
|
|
41
|
+
"updated_at": _now(),
|
|
42
|
+
})
|
|
43
|
+
self._store.save_state(state)
|
|
44
|
+
self._store.record_timeline_event("plugins", "plugin_installed", {"plugin": plugin_id, "version": version})
|
|
45
|
+
return entry
|
|
46
|
+
|
|
47
|
+
def mark_plugin_uninstalled(self, plugin_id: str) -> Dict[str, Any]:
|
|
48
|
+
state = self._store.load_state()
|
|
49
|
+
entry = state.setdefault("plugin_registry", {}).setdefault(plugin_id, {"id": plugin_id})
|
|
50
|
+
entry.update({"installed": False, "enabled": False, "updated_at": _now()})
|
|
51
|
+
self._store.save_state(state)
|
|
52
|
+
self._store.record_timeline_event("plugins", "plugin_uninstalled", {"plugin": plugin_id})
|
|
53
|
+
return {"status": "ok", "plugin_id": plugin_id, "registry": entry}
|
|
54
|
+
|
|
55
|
+
# Marketplace templates
|
|
56
|
+
@staticmethod
|
|
57
|
+
def _template_registry_key(kind: str, template_id: str, workspace_id: str) -> str:
|
|
58
|
+
base = f"{kind}:{template_id}"
|
|
59
|
+
return base if workspace_id == "personal" else f"{workspace_id}:{base}"
|
|
60
|
+
|
|
61
|
+
def list_template_registry(self, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
62
|
+
state = self._store.load_state()
|
|
63
|
+
registry = dict(state.get("template_registry") or {})
|
|
64
|
+
if workspace_id is None:
|
|
65
|
+
return registry
|
|
66
|
+
scope = self._store._resolve_scope(workspace_id, state)
|
|
67
|
+
return {
|
|
68
|
+
key: value
|
|
69
|
+
for key, value in registry.items()
|
|
70
|
+
if self._store._record_workspace(value) == scope
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
def mark_template_installed(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
kind: str,
|
|
77
|
+
template_id: str,
|
|
78
|
+
version: str = "1.0.0",
|
|
79
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
80
|
+
workspace_id: Optional[str] = None,
|
|
81
|
+
) -> Dict[str, Any]:
|
|
82
|
+
state = self._store.load_state()
|
|
83
|
+
scope = self._store._resolve_scope(workspace_id, state)
|
|
84
|
+
key = self._template_registry_key(kind, template_id, scope)
|
|
85
|
+
entry = state.setdefault("template_registry", {}).setdefault(key, {"id": template_id, "kind": kind})
|
|
86
|
+
entry.update({
|
|
87
|
+
"id": template_id,
|
|
88
|
+
"kind": kind,
|
|
89
|
+
"version": version,
|
|
90
|
+
"installed": True,
|
|
91
|
+
"workspace_id": scope,
|
|
92
|
+
"metadata": metadata or entry.get("metadata") or {},
|
|
93
|
+
"updated_at": _now(),
|
|
94
|
+
})
|
|
95
|
+
self._store.save_state(state)
|
|
96
|
+
self._store.record_timeline_event("marketplace", "template_installed", {"kind": kind, "template_id": template_id}, workspace_id=scope)
|
|
97
|
+
return entry
|