ltcai 1.7.0 → 2.1.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.
@@ -0,0 +1,81 @@
1
+ """Marketplace foundation API (local templates only)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable, Dict, Optional
6
+
7
+ from fastapi import APIRouter, HTTPException, Request
8
+ from pydantic import BaseModel
9
+
10
+
11
+ class TemplateImportRequest(BaseModel):
12
+ data: Dict[str, Any] = {}
13
+
14
+
15
+ class TemplateInstallRequest(BaseModel):
16
+ data: Dict[str, Any] = {}
17
+
18
+
19
+ def create_marketplace_router(
20
+ *,
21
+ store,
22
+ catalog,
23
+ require_user: Callable[[Request], str],
24
+ gate_read: Callable[[Request], Optional[str]],
25
+ gate_write: Callable[[Request], Optional[str]],
26
+ workspace_graph: Callable[[], Any],
27
+ ) -> APIRouter:
28
+ from latticeai.core.marketplace import MarketplaceError
29
+
30
+ router = APIRouter()
31
+
32
+ @router.get("/marketplace/templates")
33
+ async def list_templates(request: Request, kind: Optional[str] = None):
34
+ require_user(request)
35
+ gate_read(request)
36
+ try:
37
+ return catalog.list_templates(kind=kind)
38
+ except MarketplaceError as exc:
39
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
40
+
41
+ @router.get("/marketplace/templates/{kind}/{template_id}/export")
42
+ async def export_template(kind: str, template_id: str, request: Request):
43
+ require_user(request)
44
+ gate_read(request)
45
+ try:
46
+ return catalog.export_template(kind, template_id)
47
+ except MarketplaceError as exc:
48
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
49
+
50
+ @router.post("/marketplace/templates/import")
51
+ async def import_template(req: TemplateImportRequest, request: Request):
52
+ require_user(request)
53
+ gate_read(request)
54
+ try:
55
+ return {"template": catalog.import_template(req.data)}
56
+ except MarketplaceError as exc:
57
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
58
+
59
+ @router.post("/marketplace/templates/install")
60
+ async def install_template(req: TemplateInstallRequest, request: Request):
61
+ user = require_user(request)
62
+ scope = gate_write(request)
63
+ try:
64
+ installed = catalog.install_template(
65
+ req.data,
66
+ store=store,
67
+ user_email=user or None,
68
+ workspace_id=scope,
69
+ graph=workspace_graph(),
70
+ )
71
+ except MarketplaceError as exc:
72
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
73
+ return {"installed": installed}
74
+
75
+ @router.get("/marketplace/templates/registry")
76
+ async def template_registry(request: Request):
77
+ require_user(request)
78
+ gate_read(request)
79
+ return {"registry": store.list_template_registry()}
80
+
81
+ return router
@@ -0,0 +1,115 @@
1
+ """Plugin SDK API router (v2).
2
+
3
+ Surfaces the :class:`latticeai.core.plugins.PluginRegistry` over HTTP using the
4
+ same router-factory convention as the rest of ``latticeai.api`` (server_app
5
+ constructs the dependencies and passes them in; this module never imports the
6
+ app). New paths are namespaced under ``/plugins/registry`` and friends so they
7
+ do not collide with the pre-existing ``/plugins/directory`` marketplace routes.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import Any, Callable, Dict, Optional
14
+
15
+ from fastapi import APIRouter, HTTPException, Request
16
+ from pydantic import BaseModel
17
+
18
+
19
+ class PluginActionRequest(BaseModel):
20
+ plugin_id: str
21
+ enabled: Optional[bool] = None
22
+ version: Optional[str] = None
23
+
24
+
25
+ class PluginValidateRequest(BaseModel):
26
+ manifest: Dict[str, Any] = {}
27
+
28
+
29
+ class PluginExecuteRequest(BaseModel):
30
+ plugin_id: str
31
+ action: str
32
+ args: Dict[str, Any] = {}
33
+
34
+
35
+ def create_plugins_router(
36
+ *,
37
+ registry,
38
+ require_user: Callable[[Request], str],
39
+ require_admin: Callable[[Request], Any],
40
+ append_audit_event: Callable[..., None],
41
+ register_skill: Optional[Callable[[str, str], Any]] = None,
42
+ plugin_runners_factory: Optional[Callable[[], Dict[str, Callable[..., Any]]]] = None,
43
+ ui_file_response: Optional[Callable[[Path], Any]] = None,
44
+ static_dir: Optional[Path] = None,
45
+ ) -> APIRouter:
46
+ from latticeai.core.plugins import validate_manifest
47
+
48
+ router = APIRouter()
49
+
50
+ @router.get("/plugins/sdk")
51
+ async def plugins_sdk_page(request: Request):
52
+ require_user(request)
53
+ if ui_file_response is None or static_dir is None:
54
+ raise HTTPException(status_code=404, detail="Plugin SDK UI not available.")
55
+ page = static_dir / "plugins.html"
56
+ if not page.exists():
57
+ raise HTTPException(status_code=404, detail="Plugin SDK UI not found.")
58
+ return ui_file_response(page)
59
+
60
+ @router.get("/plugins/registry")
61
+ async def plugins_registry(request: Request):
62
+ require_user(request)
63
+ return registry.catalog()
64
+
65
+ @router.get("/plugins/registry/{plugin_id}")
66
+ async def plugin_detail(plugin_id: str, request: Request):
67
+ require_user(request)
68
+ manifest = registry.get_manifest(plugin_id)
69
+ if manifest is None:
70
+ raise HTTPException(status_code=404, detail=f"Plugin not found: {plugin_id}")
71
+ state = registry.store.list_plugin_registry().get(plugin_id, {}) if registry.store else {}
72
+ return {"plugin": manifest.public(), "registry": state}
73
+
74
+ @router.post("/plugins/validate")
75
+ async def plugin_validate(req: PluginValidateRequest, request: Request):
76
+ require_user(request)
77
+ manifest, errors = validate_manifest(req.manifest)
78
+ return {"ok": not errors, "errors": errors, "manifest": manifest.public() if manifest else None}
79
+
80
+ @router.post("/plugins/install")
81
+ async def plugin_install(req: PluginActionRequest, request: Request):
82
+ admin_email, _ = require_admin(request)
83
+ try:
84
+ result = registry.install(req.plugin_id, register_skill=register_skill)
85
+ except Exception as exc:
86
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
87
+ append_audit_event("plugin_install", user_email=admin_email, plugin=req.plugin_id)
88
+ return result
89
+
90
+ @router.post("/plugins/uninstall")
91
+ async def plugin_uninstall(req: PluginActionRequest, request: Request):
92
+ admin_email, _ = require_admin(request)
93
+ result = registry.uninstall(req.plugin_id)
94
+ append_audit_event("plugin_uninstall", user_email=admin_email, plugin=req.plugin_id)
95
+ return result
96
+
97
+ @router.post("/plugins/enable")
98
+ async def plugin_enable(req: PluginActionRequest, request: Request):
99
+ require_user(request)
100
+ return {"plugin": registry.set_enabled(req.plugin_id, True)}
101
+
102
+ @router.post("/plugins/disable")
103
+ async def plugin_disable(req: PluginActionRequest, request: Request):
104
+ require_user(request)
105
+ return {"plugin": registry.set_enabled(req.plugin_id, False)}
106
+
107
+ @router.post("/plugins/execute")
108
+ async def plugin_execute(req: PluginExecuteRequest, request: Request):
109
+ current_user = require_user(request)
110
+ runners = plugin_runners_factory() if plugin_runners_factory else {}
111
+ result = registry.execute_action(req.plugin_id, req.action, req.args, runners=runners)
112
+ append_audit_event("plugin_execute", user_email=current_user, plugin=req.plugin_id, action=req.action, status=result.status)
113
+ return result.as_dict()
114
+
115
+ return router
@@ -0,0 +1,91 @@
1
+ """Realtime Collaboration API router (v2).
2
+
3
+ Server-Sent-Events stream + presence + activity feed over
4
+ :class:`latticeai.core.realtime.RealtimeBus`. Workspace isolation is enforced by
5
+ resolving each caller's allowed workspace scope before subscribing; single-user
6
+ local mode works with no scope restriction.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import secrets
12
+ from pathlib import Path
13
+ from typing import Any, Callable, Dict, Optional, Set
14
+
15
+ from fastapi import APIRouter, HTTPException, Request
16
+ from fastapi.responses import StreamingResponse
17
+ from pydantic import BaseModel
18
+
19
+
20
+ class PresenceRequest(BaseModel):
21
+ client_id: Optional[str] = None
22
+ workspace_id: Optional[str] = None
23
+
24
+
25
+ def create_realtime_router(
26
+ *,
27
+ bus,
28
+ require_user: Callable[[Request], str],
29
+ get_current_user: Callable[[Request], Optional[str]],
30
+ allowed_scopes: Callable[[Optional[str]], Optional[Set[str]]],
31
+ ui_file_response: Optional[Callable[[Path], Any]] = None,
32
+ static_dir: Optional[Path] = None,
33
+ ) -> APIRouter:
34
+ router = APIRouter()
35
+
36
+ @router.get("/activity")
37
+ async def activity_page(request: Request):
38
+ require_user(request)
39
+ if ui_file_response is None or static_dir is None:
40
+ raise HTTPException(status_code=404, detail="Activity UI not available.")
41
+ page = static_dir / "activity.html"
42
+ if not page.exists():
43
+ raise HTTPException(status_code=404, detail="Activity UI not found.")
44
+ return ui_file_response(page)
45
+
46
+ @router.get("/realtime/stream")
47
+ async def realtime_stream(request: Request):
48
+ user = require_user(request)
49
+ scope = allowed_scopes(user or None)
50
+ sub_id = secrets.token_urlsafe(12)
51
+ sub = bus.add_subscriber(sub_id, workspace_scope=scope, user=user or None)
52
+
53
+ async def event_gen():
54
+ async for frame in bus.stream(sub):
55
+ if await request.is_disconnected():
56
+ break
57
+ yield frame
58
+
59
+ return StreamingResponse(
60
+ event_gen(),
61
+ media_type="text/event-stream",
62
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"},
63
+ )
64
+
65
+ @router.get("/realtime/feed")
66
+ async def realtime_feed(request: Request, limit: int = 50):
67
+ user = require_user(request)
68
+ scope = allowed_scopes(user or None)
69
+ return {"events": bus.recent(limit=limit, workspace_scope=scope), "stats": bus.stats()}
70
+
71
+ @router.get("/realtime/presence")
72
+ async def realtime_presence(request: Request):
73
+ user = require_user(request)
74
+ scope = allowed_scopes(user or None)
75
+ return {"presence": bus.presence(workspace_scope=scope), "stats": bus.stats()}
76
+
77
+ @router.post("/realtime/presence/join")
78
+ async def realtime_join(req: PresenceRequest, request: Request):
79
+ user = require_user(request)
80
+ client_id = req.client_id or secrets.token_urlsafe(8)
81
+ record = bus.join(client_id, user=user or None, workspace_id=req.workspace_id)
82
+ return {"presence": record}
83
+
84
+ @router.post("/realtime/presence/leave")
85
+ async def realtime_leave(req: PresenceRequest, request: Request):
86
+ require_user(request)
87
+ if req.client_id:
88
+ bus.leave(req.client_id)
89
+ return {"status": "ok"}
90
+
91
+ return router
@@ -0,0 +1,216 @@
1
+ """Workflow Designer API router (v2).
2
+
3
+ Create / edit / validate / execute / inspect / export / import workflows plus
4
+ run history, layered on :mod:`latticeai.core.workflow_engine` and the existing
5
+ ``WorkspaceOSStore`` workflow persistence (so pre-2.0 workflow history is
6
+ preserved). Paths are namespaced under ``/workflows`` to avoid colliding with
7
+ ``/workspace/workflows``.
8
+
9
+ server_app injects a ``build_runners`` callable that returns the executable
10
+ runner map (tool / skill / plugin / agent), which is what lets a workflow
11
+ actually drive plugins, skills, and multi-agent runs.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+ from typing import Any, Callable, Dict, List, Optional
18
+
19
+ from fastapi import APIRouter, HTTPException, Request
20
+ from pydantic import BaseModel
21
+
22
+
23
+ class WorkflowDefinitionRequest(BaseModel):
24
+ name: str
25
+ nodes: List[Dict[str, Any]] = []
26
+ metadata: Dict[str, Any] = {}
27
+
28
+
29
+ class WorkflowUpdateRequest(BaseModel):
30
+ name: Optional[str] = None
31
+ nodes: Optional[List[Dict[str, Any]]] = None
32
+ metadata: Optional[Dict[str, Any]] = None
33
+
34
+
35
+ class WorkflowRunRequest(BaseModel):
36
+ inputs: Dict[str, Any] = {}
37
+
38
+
39
+ class WorkflowValidateRequest(BaseModel):
40
+ name: str = "Draft"
41
+ nodes: List[Dict[str, Any]] = []
42
+
43
+
44
+ class WorkflowImportRequest(BaseModel):
45
+ data: Dict[str, Any] = {}
46
+
47
+
48
+ def create_workflow_designer_router(
49
+ *,
50
+ store,
51
+ require_user: Callable[[Request], str],
52
+ get_current_user: Callable[[Request], Optional[str]],
53
+ gate_read: Callable[[Request], Optional[str]],
54
+ gate_write: Callable[[Request], Optional[str]],
55
+ workspace_graph: Callable[[], Any],
56
+ build_runners: Callable[[Optional[str], Optional[str]], Dict[str, Callable[..., Any]]],
57
+ append_audit_event: Callable[..., None],
58
+ ui_file_response: Optional[Callable[[Path], Any]] = None,
59
+ static_dir: Optional[Path] = None,
60
+ ) -> APIRouter:
61
+ from latticeai.core.workflow_engine import (
62
+ WorkflowEngine,
63
+ validate_definition,
64
+ export_workflow,
65
+ import_workflow,
66
+ WorkflowError,
67
+ )
68
+
69
+ router = APIRouter()
70
+
71
+ @router.get("/workflows")
72
+ async def workflows_page(request: Request):
73
+ require_user(request)
74
+ if ui_file_response is None or static_dir is None:
75
+ raise HTTPException(status_code=404, detail="Workflow Designer UI not available.")
76
+ page = static_dir / "workflows.html"
77
+ if not page.exists():
78
+ raise HTTPException(status_code=404, detail="Workflow Designer UI not found.")
79
+ return ui_file_response(page)
80
+
81
+ @router.get("/workflows/api/definitions")
82
+ async def list_definitions(request: Request, q: str = ""):
83
+ require_user(request)
84
+ scope = gate_read(request)
85
+ return store.list_workflows(query=q, workspace_id=scope)
86
+
87
+ @router.post("/workflows/api/definitions")
88
+ async def create_definition(req: WorkflowDefinitionRequest, request: Request):
89
+ current_user = require_user(request)
90
+ scope = gate_write(request)
91
+ errors = validate_definition({"name": req.name, "nodes": req.nodes})
92
+ if errors:
93
+ raise HTTPException(status_code=400, detail={"validation_errors": errors})
94
+ workflow = store.create_workflow(
95
+ name=req.name,
96
+ steps=[{"action": n.get("type"), "node": n.get("id")} for n in req.nodes],
97
+ nodes=req.nodes,
98
+ metadata=req.metadata,
99
+ user_email=current_user or None,
100
+ graph=workspace_graph(),
101
+ workspace_id=scope,
102
+ )
103
+ append_audit_event("workflow_created", user_email=current_user, workflow_id=workflow["id"])
104
+ return {"workflow": workflow}
105
+
106
+ @router.get("/workflows/api/definitions/{workflow_id}")
107
+ async def get_definition(workflow_id: str, request: Request):
108
+ require_user(request)
109
+ scope = gate_read(request)
110
+ try:
111
+ return {"workflow": store.get_workflow(workflow_id, workspace_id=scope)}
112
+ except FileNotFoundError as exc:
113
+ raise HTTPException(status_code=404, detail=f"Workflow not found: {exc}") from exc
114
+
115
+ @router.patch("/workflows/api/definitions/{workflow_id}")
116
+ async def update_definition(workflow_id: str, req: WorkflowUpdateRequest, request: Request):
117
+ require_user(request)
118
+ scope = gate_write(request)
119
+ if req.nodes is not None:
120
+ errors = validate_definition({"name": req.name or "wf", "nodes": req.nodes})
121
+ if errors:
122
+ raise HTTPException(status_code=400, detail={"validation_errors": errors})
123
+ try:
124
+ workflow = store.update_workflow_definition(
125
+ workflow_id,
126
+ name=req.name,
127
+ nodes=req.nodes,
128
+ metadata=req.metadata,
129
+ workspace_id=scope,
130
+ )
131
+ except FileNotFoundError as exc:
132
+ raise HTTPException(status_code=404, detail=f"Workflow not found: {exc}") from exc
133
+ return {"workflow": workflow}
134
+
135
+ @router.post("/workflows/api/validate")
136
+ async def validate_workflow(req: WorkflowValidateRequest, request: Request):
137
+ require_user(request)
138
+ errors = validate_definition({"name": req.name, "nodes": req.nodes})
139
+ return {"ok": not errors, "errors": errors}
140
+
141
+ @router.post("/workflows/api/definitions/{workflow_id}/run")
142
+ async def run_definition(workflow_id: str, req: WorkflowRunRequest, request: Request):
143
+ current_user = require_user(request)
144
+ scope = gate_write(request)
145
+ try:
146
+ workflow = store.get_workflow(workflow_id, workspace_id=scope)
147
+ except FileNotFoundError as exc:
148
+ raise HTTPException(status_code=404, detail=f"Workflow not found: {exc}") from exc
149
+ runners = build_runners(current_user or None, scope)
150
+ engine = WorkflowEngine(runners)
151
+ result = engine.run(workflow, inputs=req.inputs)
152
+ run = store.record_workflow_run(
153
+ workflow_id=workflow_id,
154
+ name=workflow.get("name") or "workflow",
155
+ status=result.status,
156
+ timeline=result.timeline,
157
+ outputs=result.outputs,
158
+ user_email=current_user or None,
159
+ graph=workspace_graph(),
160
+ workspace_id=scope,
161
+ )
162
+ append_audit_event("workflow_run", user_email=current_user, workflow_id=workflow_id, status=result.status)
163
+ return {"run": run, "result": result.as_dict()}
164
+
165
+ @router.get("/workflows/api/definitions/{workflow_id}/runs")
166
+ async def list_runs(workflow_id: str, request: Request, limit: int = 50):
167
+ require_user(request)
168
+ scope = gate_read(request)
169
+ return store.list_workflow_runs(workflow_id=workflow_id, limit=limit, workspace_id=scope)
170
+
171
+ @router.get("/workflows/api/runs")
172
+ async def list_all_runs(request: Request, limit: int = 50):
173
+ require_user(request)
174
+ scope = gate_read(request)
175
+ return store.list_workflow_runs(limit=limit, workspace_id=scope)
176
+
177
+ @router.get("/workflows/api/runs/{run_id}/replay")
178
+ async def workflow_run_replay(run_id: str, request: Request):
179
+ require_user(request)
180
+ scope = gate_read(request)
181
+ try:
182
+ return {"replay": store.replay_workflow_run(run_id, workspace_id=scope)}
183
+ except FileNotFoundError as exc:
184
+ raise HTTPException(status_code=404, detail=f"Workflow run not found: {run_id}") from exc
185
+
186
+ @router.get("/workflows/api/export/{workflow_id}")
187
+ async def export_definition(workflow_id: str, request: Request):
188
+ require_user(request)
189
+ scope = gate_read(request)
190
+ try:
191
+ workflow = store.get_workflow(workflow_id, workspace_id=scope)
192
+ except FileNotFoundError as exc:
193
+ raise HTTPException(status_code=404, detail=f"Workflow not found: {exc}") from exc
194
+ return export_workflow(workflow)
195
+
196
+ @router.post("/workflows/api/import")
197
+ async def import_definition(req: WorkflowImportRequest, request: Request):
198
+ current_user = require_user(request)
199
+ scope = gate_write(request)
200
+ try:
201
+ definition = import_workflow(req.data)
202
+ except WorkflowError as exc:
203
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
204
+ workflow = store.create_workflow(
205
+ name=definition["name"],
206
+ steps=[{"action": n.get("type"), "node": n.get("id")} for n in definition["nodes"]],
207
+ nodes=definition["nodes"],
208
+ metadata=definition.get("metadata") or {},
209
+ user_email=current_user or None,
210
+ graph=workspace_graph(),
211
+ workspace_id=scope,
212
+ )
213
+ append_audit_event("workflow_imported", user_email=current_user, workflow_id=workflow["id"])
214
+ return {"workflow": workflow}
215
+
216
+ return router
@@ -0,0 +1,178 @@
1
+ """Marketplace foundation for local templates.
2
+
3
+ v2.1 intentionally does not add a cloud marketplace. This module provides the
4
+ portable template shape, metadata, export/import validation, and install hooks
5
+ that a future service can reuse.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from copy import deepcopy
11
+ from typing import Any, Dict, List, Optional
12
+
13
+
14
+ MARKETPLACE_VERSION = "2.1.0"
15
+ TEMPLATE_KINDS = ("plugin", "workflow", "agent")
16
+
17
+
18
+ class MarketplaceError(Exception):
19
+ """Raised for invalid template operations."""
20
+
21
+
22
+ BUILTIN_TEMPLATES: Dict[str, List[Dict[str, Any]]] = {
23
+ "plugin": [
24
+ {
25
+ "id": "plugin-review-action",
26
+ "kind": "plugin",
27
+ "name": "Plugin Review Action",
28
+ "version": "1.0.0",
29
+ "description": "A permissioned plugin action template for review workflows.",
30
+ "metadata": {"category": "review", "installable": True},
31
+ "files": {
32
+ "plugin.json": {
33
+ "id": "plugin-review-action",
34
+ "name": "Plugin Review Action",
35
+ "version": "1.0.0",
36
+ "lattice_version": ">=2.1.0",
37
+ "permissions": ["read_workspace", "run_skills"],
38
+ "provides": {"skills": ["review_action"]},
39
+ }
40
+ },
41
+ }
42
+ ],
43
+ "workflow": [
44
+ {
45
+ "id": "workflow-agent-plugin-review",
46
+ "kind": "workflow",
47
+ "name": "Agent Plugin Review Workflow",
48
+ "version": "1.0.0",
49
+ "description": "Manual trigger into planner/executor/reviewer, plugin action, and output.",
50
+ "metadata": {"category": "agent-ops", "installable": True},
51
+ "definition": {
52
+ "name": "Agent Plugin Review Workflow",
53
+ "nodes": [
54
+ {"id": "trigger", "type": "trigger", "name": "Manual start", "config": {"trigger": "manual"}, "next": "agent"},
55
+ {"id": "agent", "type": "agent", "name": "Plan and execute", "config": {"goal": "Run agent platform review", "roles": ["planner", "executor", "reviewer"]}, "next": "plugin"},
56
+ {"id": "plugin", "type": "plugin", "name": "Plugin action", "config": {"plugin": "hello-world", "action": "run_skill", "args": {}}, "next": "output"},
57
+ {"id": "output", "type": "output", "name": "Output", "config": {}, "next": None},
58
+ ],
59
+ "metadata": {"template_id": "workflow-agent-plugin-review"},
60
+ },
61
+ }
62
+ ],
63
+ "agent": [
64
+ {
65
+ "id": "agent-planner-executor-reviewer",
66
+ "kind": "agent",
67
+ "name": "Planner Executor Reviewer",
68
+ "version": "1.0.0",
69
+ "description": "Default bounded planning, execution, review, and retry template.",
70
+ "metadata": {"category": "agent-ops", "installable": True},
71
+ "definition": {
72
+ "roles": ["planner", "executor", "reviewer"],
73
+ "max_retries": 2,
74
+ "constraints": ["workspace scoped", "no secret leakage", "replayable timeline"],
75
+ },
76
+ }
77
+ ],
78
+ }
79
+
80
+
81
+ def _normalize_kind(kind: str) -> str:
82
+ value = str(kind or "").strip().lower()
83
+ if value not in TEMPLATE_KINDS:
84
+ raise MarketplaceError(f"unknown template kind: {kind}")
85
+ return value
86
+
87
+
88
+ class TemplateCatalog:
89
+ """Local template catalog with export/import/install primitives."""
90
+
91
+ def __init__(self, templates: Optional[Dict[str, List[Dict[str, Any]]]] = None):
92
+ self.templates = templates or BUILTIN_TEMPLATES
93
+
94
+ def list_templates(self, kind: Optional[str] = None) -> Dict[str, Any]:
95
+ kinds = [_normalize_kind(kind)] if kind else list(TEMPLATE_KINDS)
96
+ templates = []
97
+ for item_kind in kinds:
98
+ templates.extend(deepcopy(self.templates.get(item_kind, [])))
99
+ return {
100
+ "marketplace_version": MARKETPLACE_VERSION,
101
+ "kinds": list(TEMPLATE_KINDS),
102
+ "templates": templates,
103
+ "total": len(templates),
104
+ }
105
+
106
+ def get_template(self, kind: str, template_id: str) -> Dict[str, Any]:
107
+ item_kind = _normalize_kind(kind)
108
+ for template in self.templates.get(item_kind, []):
109
+ if template.get("id") == template_id:
110
+ return deepcopy(template)
111
+ raise MarketplaceError(f"template not found: {item_kind}/{template_id}")
112
+
113
+ def export_template(self, kind: str, template_id: str) -> Dict[str, Any]:
114
+ template = self.get_template(kind, template_id)
115
+ return {
116
+ "lattice_template_export": MARKETPLACE_VERSION,
117
+ "kind": template["kind"],
118
+ "template": template,
119
+ "metadata": {
120
+ "exported_from": "local",
121
+ "template_id": template_id,
122
+ "template_version": template.get("version"),
123
+ },
124
+ }
125
+
126
+ def import_template(self, payload: Dict[str, Any]) -> Dict[str, Any]:
127
+ if not isinstance(payload, dict):
128
+ raise MarketplaceError("template import payload must be an object")
129
+ template = deepcopy(payload.get("template") or payload)
130
+ kind = _normalize_kind(template.get("kind") or payload.get("kind"))
131
+ if not template.get("id"):
132
+ raise MarketplaceError("template missing id")
133
+ if not template.get("name"):
134
+ raise MarketplaceError("template missing name")
135
+ template["kind"] = kind
136
+ template.setdefault("version", "1.0.0")
137
+ template.setdefault("metadata", {})
138
+ template["metadata"] = {**template["metadata"], "imported": True}
139
+ return template
140
+
141
+ def install_template(
142
+ self,
143
+ template: Dict[str, Any],
144
+ *,
145
+ store: Any,
146
+ user_email: Optional[str] = None,
147
+ workspace_id: Optional[str] = None,
148
+ graph: Any = None,
149
+ ) -> Dict[str, Any]:
150
+ imported = self.import_template(template)
151
+ kind = imported["kind"]
152
+ installed: Dict[str, Any] = {
153
+ "kind": kind,
154
+ "template_id": imported["id"],
155
+ "name": imported["name"],
156
+ "version": imported.get("version", "1.0.0"),
157
+ }
158
+ if kind == "workflow":
159
+ definition = imported.get("definition") or {}
160
+ workflow = store.create_workflow(
161
+ name=definition.get("name") or imported["name"],
162
+ steps=[{"action": node.get("type"), "node": node.get("id")} for node in definition.get("nodes", [])],
163
+ nodes=definition.get("nodes", []),
164
+ metadata={**(definition.get("metadata") or {}), "template_id": imported["id"]},
165
+ user_email=user_email,
166
+ graph=graph,
167
+ workspace_id=workspace_id,
168
+ )
169
+ installed["workflow_id"] = workflow["id"]
170
+ registry = store.mark_template_installed(
171
+ kind=kind,
172
+ template_id=imported["id"],
173
+ version=imported.get("version", "1.0.0"),
174
+ metadata=imported.get("metadata") or {},
175
+ workspace_id=workspace_id,
176
+ )
177
+ installed["registry"] = registry
178
+ return installed