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.
- package/README.md +32 -21
- package/docs/CHANGELOG.md +119 -0
- package/docs/EDITION_STRATEGY.md +10 -4
- package/docs/ENTERPRISE.md +3 -1
- package/docs/MULTI_AGENT_RUNTIME.md +428 -0
- package/docs/PLUGIN_SDK.md +664 -0
- package/docs/REALTIME_COLLABORATION.md +423 -0
- package/docs/V2_ARCHITECTURE.md +540 -0
- package/docs/WORKFLOW_DESIGNER.md +485 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/agents.py +154 -0
- package/latticeai/api/marketplace.py +81 -0
- package/latticeai/api/plugins.py +115 -0
- package/latticeai/api/realtime.py +91 -0
- package/latticeai/api/workflow_designer.py +216 -0
- package/latticeai/core/marketplace.py +178 -0
- package/latticeai/core/multi_agent.py +561 -0
- package/latticeai/core/plugins.py +416 -0
- package/latticeai/core/realtime.py +190 -0
- package/latticeai/core/workflow_engine.py +329 -0
- package/latticeai/core/workspace_os.py +406 -6
- package/latticeai/server_app.py +88 -2
- package/latticeai/services/platform_runtime.py +204 -0
- package/package.json +8 -2
- package/plugins/README.md +35 -0
- package/plugins/git-insights/plugin.json +15 -0
- package/plugins/hello-world/plugin.json +16 -0
- package/plugins/hello-world/skills/hello_skill/SKILL.md +15 -0
- package/static/activity.html +70 -0
- package/static/agents.html +136 -0
- package/static/platform.css +75 -0
- package/static/plugins.html +133 -0
- package/static/scripts/platform.js +64 -0
- package/static/workflows.html +143 -0
- package/static/workspace.html +5 -1
|
@@ -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
|