mas-lab-component-controller 0.2.0__py3-none-any.whl
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.
- mas/lab/controller/__init__.py +7 -0
- mas/lab/controller/__main__.py +5 -0
- mas/lab/controller/agent_chat.py +226 -0
- mas/lab/controller/api.py +364 -0
- mas/lab/controller/artifact_discovery.py +107 -0
- mas/lab/controller/artifacts.py +112 -0
- mas/lab/controller/cli.py +493 -0
- mas/lab/controller/client.py +271 -0
- mas/lab/controller/config.py +75 -0
- mas/lab/controller/constants.py +30 -0
- mas/lab/controller/daemon.py +190 -0
- mas/lab/controller/deps.py +172 -0
- mas/lab/controller/fastapi_app.py +166 -0
- mas/lab/controller/http_server.py +243 -0
- mas/lab/controller/io_capture.py +55 -0
- mas/lab/controller/item_service.py +625 -0
- mas/lab/controller/job_store.py +209 -0
- mas/lab/controller/jobs.py +375 -0
- mas/lab/controller/kinds.py +149 -0
- mas/lab/controller/lab_registry.py +1076 -0
- mas/lab/controller/manifest_layout.py +832 -0
- mas/lab/controller/manifest_store.py +214 -0
- mas/lab/controller/manifest_validation.py +90 -0
- mas/lab/controller/models.py +215 -0
- mas/lab/controller/path_safety.py +68 -0
- mas/lab/controller/pipeline_validation.py +79 -0
- mas/lab/controller/registry.py +83 -0
- mas/lab/controller/routes/__init__.py +82 -0
- mas/lab/controller/routes/_api.py +15 -0
- mas/lab/controller/routes/apps.py +190 -0
- mas/lab/controller/routes/artifacts.py +37 -0
- mas/lab/controller/routes/benchmark.py +307 -0
- mas/lab/controller/routes/choices.py +141 -0
- mas/lab/controller/routes/datasets.py +91 -0
- mas/lab/controller/routes/design_patterns.py +121 -0
- mas/lab/controller/routes/eval_output.py +31 -0
- mas/lab/controller/routes/experiments.py +194 -0
- mas/lab/controller/routes/health.py +178 -0
- mas/lab/controller/routes/items.py +187 -0
- mas/lab/controller/routes/jobs.py +107 -0
- mas/lab/controller/routes/libraries.py +144 -0
- mas/lab/controller/routes/overlays.py +164 -0
- mas/lab/controller/routes/pipelines.py +139 -0
- mas/lab/controller/routes/registry.py +40 -0
- mas/lab/controller/routes/run.py +215 -0
- mas/lab/controller/routes/runs.py +392 -0
- mas/lab/controller/routes/scopes.py +139 -0
- mas/lab/controller/routes/uploads.py +24 -0
- mas/lab/controller/routes/validate.py +36 -0
- mas/lab/controller/run_bridge.py +276 -0
- mas/lab/controller/run_store.py +190 -0
- mas/lab/controller/schema_registry.py +109 -0
- mas/lab/controller/schemas/pipeline-step-types-post.json +764 -0
- mas/lab/controller/schemas/pipeline-step-types-pre.json +35 -0
- mas/lab/controller/scope_catalog.py +266 -0
- mas/lab/controller/scope_service.py +134 -0
- mas/lab/controller/sessions.py +63 -0
- mas/lab/controller/settings.py +65 -0
- mas/lab/controller/ui_stream/__init__.py +13 -0
- mas/lab/controller/ui_stream/app.py +2147 -0
- mas/lab/controller/ui_stream/artefact_bind.py +308 -0
- mas/lab/controller/ui_stream/auth.py +136 -0
- mas/lab/controller/ui_stream/bus.py +156 -0
- mas/lab/controller/ui_stream/export.py +400 -0
- mas/lab/controller/ui_stream/governance.py +141 -0
- mas/lab/controller/ui_stream/ingest.py +300 -0
- mas/lab/controller/ui_stream/inspect.py +55 -0
- mas/lab/controller/ui_stream/layouts.py +161 -0
- mas/lab/controller/ui_stream/metrics.py +274 -0
- mas/lab/controller/ui_stream/plot_folds.py +609 -0
- mas/lab/controller/ui_stream/plugin_registry.py +139 -0
- mas/lab/controller/ui_stream/refs.py +259 -0
- mas/lab/controller/ui_stream/replay.py +236 -0
- mas/lab/controller/ui_stream/replay_cli.py +707 -0
- mas/lab/controller/ui_stream/run_index.py +337 -0
- mas/lab/controller/ui_stream/spec_load.py +48 -0
- mas/lab/controller/ui_stream/spec_snapshot.py +313 -0
- mas/lab/controller/ui_stream/stream.py +95 -0
- mas/lab/controller/ui_stream/streams.py +42 -0
- mas/lab/controller/ui_stream/timeline.py +183 -0
- mas/lab/controller/ui_stream/ui_trace.py +258 -0
- mas/lab/controller/uploads.py +61 -0
- mas/lab/controller/worker_model.py +137 -0
- mas/lab/controller/workers.py +296 -0
- mas/lab/controller/workspace_library.py +70 -0
- mas_lab_component_controller-0.2.0.dist-info/METADATA +21 -0
- mas_lab_component_controller-0.2.0.dist-info/RECORD +89 -0
- mas_lab_component_controller-0.2.0.dist-info/WHEEL +4 -0
- mas_lab_component_controller-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Job management endpoints."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, HTTPException
|
|
10
|
+
from mas.lab.controller.api import peek_controller_api
|
|
11
|
+
from mas.lab.controller.jobs import JobStatus, _jobs
|
|
12
|
+
|
|
13
|
+
router = APIRouter()
|
|
14
|
+
|
|
15
|
+
_TERMINAL = {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED, JobStatus.TIMEOUT}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _origin_of(job) -> str | None:
|
|
19
|
+
from mas.lab.controller.jobs import _job_origin
|
|
20
|
+
|
|
21
|
+
return _job_origin(job.request_body)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@router.get("/api/jobs", tags=["Jobs"])
|
|
25
|
+
async def list_jobs(status: Optional[str] = None, origin: Optional[str] = None):
|
|
26
|
+
"""List FastAPI jobs plus in-process controller workers."""
|
|
27
|
+
jobs = _jobs.values()
|
|
28
|
+
if status:
|
|
29
|
+
jobs = [j for j in jobs if j.status.value == status]
|
|
30
|
+
else:
|
|
31
|
+
jobs = list(jobs)
|
|
32
|
+
jobs.sort(key=lambda j: j.created_at, reverse=True)
|
|
33
|
+
summaries = [j.to_summary() for j in jobs]
|
|
34
|
+
if origin:
|
|
35
|
+
summaries = [item for item in summaries if item.get("origin") == origin]
|
|
36
|
+
seen = {item["id"] for item in summaries}
|
|
37
|
+
api = peek_controller_api()
|
|
38
|
+
if api is not None:
|
|
39
|
+
for worker in api.list_jobs(status=status):
|
|
40
|
+
if worker.get("id") in seen:
|
|
41
|
+
continue
|
|
42
|
+
if origin and worker.get("origin") != origin:
|
|
43
|
+
continue
|
|
44
|
+
summaries.append(worker)
|
|
45
|
+
return {"jobs": summaries}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@router.get("/api/jobs/{job_id}", tags=["Jobs"])
|
|
49
|
+
async def get_job(job_id: str):
|
|
50
|
+
"""Get full details of a specific job (including stdout/stderr)."""
|
|
51
|
+
job = _jobs.get(job_id)
|
|
52
|
+
if job:
|
|
53
|
+
return job.to_dict()
|
|
54
|
+
api = peek_controller_api()
|
|
55
|
+
detail = api.get_job(job_id) if api is not None else None
|
|
56
|
+
if detail:
|
|
57
|
+
return detail
|
|
58
|
+
from mas.lab.controller.ui_stream.app import state as ui_state
|
|
59
|
+
|
|
60
|
+
replay = ui_state.lookup_job(job_id)
|
|
61
|
+
if replay:
|
|
62
|
+
return replay
|
|
63
|
+
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@router.delete("/api/jobs/{job_id}", tags=["Jobs"])
|
|
67
|
+
async def cancel_job(job_id: str):
|
|
68
|
+
"""Cancel a running job and drop it from the in-memory list."""
|
|
69
|
+
job = _jobs.get(job_id)
|
|
70
|
+
if job:
|
|
71
|
+
if job.status in _TERMINAL:
|
|
72
|
+
# Nothing to cancel -- the job already reached a terminal state
|
|
73
|
+
# (completed/failed/cancelled/timeout) on its own; say so
|
|
74
|
+
# distinctly rather than implying this call actually stopped
|
|
75
|
+
# something, so a caller can tell the two cases apart.
|
|
76
|
+
del _jobs[job_id]
|
|
77
|
+
return {"message": "already terminal, removed", "job_id": job_id}
|
|
78
|
+
if job._task and not job._task.done():
|
|
79
|
+
job._task.cancel()
|
|
80
|
+
del _jobs[job_id]
|
|
81
|
+
return {"message": "removed", "job_id": job_id}
|
|
82
|
+
api = peek_controller_api()
|
|
83
|
+
if api is not None and api.drop_worker(job_id):
|
|
84
|
+
return {"message": "removed", "job_id": job_id}
|
|
85
|
+
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@router.delete("/api/jobs", tags=["Jobs"])
|
|
89
|
+
async def clear_finished_jobs(origin: Optional[str] = None):
|
|
90
|
+
"""Remove jobs. With origin, UI-owned runs are dropped even if still running."""
|
|
91
|
+
purge_running = origin is not None
|
|
92
|
+
to_remove = [
|
|
93
|
+
jid
|
|
94
|
+
for jid, job in _jobs.items()
|
|
95
|
+
if (purge_running or job.status in _TERMINAL)
|
|
96
|
+
and (origin is None or _origin_of(job) == origin)
|
|
97
|
+
]
|
|
98
|
+
for jid in to_remove:
|
|
99
|
+
job = _jobs[jid]
|
|
100
|
+
if job.status in (JobStatus.PENDING, JobStatus.RUNNING) and job._task and not job._task.done():
|
|
101
|
+
job._task.cancel()
|
|
102
|
+
del _jobs[jid]
|
|
103
|
+
removed = len(to_remove)
|
|
104
|
+
api = peek_controller_api()
|
|
105
|
+
if api is not None and origin:
|
|
106
|
+
removed += api.drop_workers(origin=origin, terminal_only=False)
|
|
107
|
+
return {"removed": removed}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Library listing and resource discovery endpoints."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter
|
|
10
|
+
|
|
11
|
+
from mas.lab.controller.routes._api import deps, jobs, LIBRARIES_DIR, validate_pipeline_yaml
|
|
12
|
+
from mas.runtime.constants import LIBRARY_MANIFEST_FILENAME
|
|
13
|
+
|
|
14
|
+
router = APIRouter()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@router.get("/api/libraries", tags=["Libraries"])
|
|
18
|
+
async def list_libraries():
|
|
19
|
+
"""List available MAS libraries."""
|
|
20
|
+
import yaml as _yaml
|
|
21
|
+
|
|
22
|
+
discovered = deps.get_manifest_store().libraries()
|
|
23
|
+
if discovered:
|
|
24
|
+
return {"libraries": discovered}
|
|
25
|
+
|
|
26
|
+
libraries_dir = LIBRARIES_DIR
|
|
27
|
+
libraries = []
|
|
28
|
+
if not libraries_dir.exists():
|
|
29
|
+
return {"libraries": libraries}
|
|
30
|
+
for lib_dir in sorted(libraries_dir.iterdir()):
|
|
31
|
+
if not lib_dir.is_dir() or not lib_dir.name.startswith("library-"):
|
|
32
|
+
continue
|
|
33
|
+
lib_yaml = lib_dir / LIBRARY_MANIFEST_FILENAME
|
|
34
|
+
name = lib_dir.name
|
|
35
|
+
description = ""
|
|
36
|
+
if lib_yaml.exists():
|
|
37
|
+
with open(lib_yaml, encoding="utf-8") as f:
|
|
38
|
+
data = _yaml.safe_load(f)
|
|
39
|
+
if data:
|
|
40
|
+
name = data.get("name", lib_dir.name)
|
|
41
|
+
description = data.get("description", "").strip()
|
|
42
|
+
libraries.append({
|
|
43
|
+
"dir": lib_dir.name,
|
|
44
|
+
"name": name,
|
|
45
|
+
"description": description,
|
|
46
|
+
})
|
|
47
|
+
return {"libraries": libraries}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@router.get("/api/runtime-runners", tags=["Benchmark"])
|
|
51
|
+
async def list_runtime_runners():
|
|
52
|
+
"""List registered lab runtime plugins (mas, langgraph, autogen, …)."""
|
|
53
|
+
from mas.lab.controller.api import ControllerAPI
|
|
54
|
+
|
|
55
|
+
return {"runners": ControllerAPI().list_runtime_runners()}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@router.get("/api/libraries/{library_name}/tools", tags=["Libraries"])
|
|
59
|
+
async def list_library_tools(library_name: str, namespaces: str = "global"):
|
|
60
|
+
"""List available tools for a specific library.
|
|
61
|
+
|
|
62
|
+
The *namespaces* query parameter is a comma-separated list of scopes.
|
|
63
|
+
``"global"`` returns tools under the library root ``tools/`` folder
|
|
64
|
+
(prefixed with ``global/``). Any other value is treated as an app
|
|
65
|
+
name and returns tools under ``apps/{name}/tools/``.
|
|
66
|
+
"""
|
|
67
|
+
lib_dir = deps.get_library_path(library_name)
|
|
68
|
+
ns_list = [n.strip() for n in namespaces.split(",") if n.strip()]
|
|
69
|
+
return {"tools": deps.discover_tools(lib_dir, ns_list)}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@router.get("/api/libraries/{library_name}/skills", tags=["Libraries"])
|
|
73
|
+
async def list_library_skills(library_name: str, namespaces: str = "global"):
|
|
74
|
+
"""List available skills for a specific library.
|
|
75
|
+
|
|
76
|
+
``namespaces`` is a comma-separated list of scopes: ``global`` for
|
|
77
|
+
library-root skills (prefixed ``global/``), or an app name for
|
|
78
|
+
app-scoped skills (no prefix). Defaults to ``global``."""
|
|
79
|
+
lib_dir = deps.get_library_path(library_name)
|
|
80
|
+
ns_list = [n.strip() for n in namespaces.split(",") if n.strip()]
|
|
81
|
+
return {"skills": deps.discover_skills(lib_dir, ns_list)}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@router.get("/api/libraries/{library_name}/documents", tags=["Libraries"])
|
|
85
|
+
async def read_library_document(library_name: str, path: str):
|
|
86
|
+
"""Read a file under the library root (MAS YAML, agent YAML, overlays)."""
|
|
87
|
+
from fastapi import HTTPException
|
|
88
|
+
|
|
89
|
+
rel = str(path or "").strip().lstrip("/")
|
|
90
|
+
if not rel or ".." in Path(rel).parts:
|
|
91
|
+
raise HTTPException(status_code=400, detail="invalid path")
|
|
92
|
+
lib_dir = deps.get_library_path(library_name).resolve()
|
|
93
|
+
target = (lib_dir / rel).resolve()
|
|
94
|
+
if not str(target).startswith(str(lib_dir)):
|
|
95
|
+
raise HTTPException(status_code=400, detail="path escapes library")
|
|
96
|
+
if not target.is_file():
|
|
97
|
+
raise HTTPException(status_code=404, detail=f"file not found: {rel}")
|
|
98
|
+
return {"path": rel, "content": target.read_text(encoding="utf-8")}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@router.get("/api/libraries/{library_name}/topologies", tags=["Libraries"])
|
|
102
|
+
async def list_library_topologies(library_name: str):
|
|
103
|
+
"""List available topology manifests in a library."""
|
|
104
|
+
lib_dir = deps.get_library_path(library_name)
|
|
105
|
+
topo_dir = lib_dir / "topologies"
|
|
106
|
+
if not topo_dir.exists():
|
|
107
|
+
return {"topologies": []}
|
|
108
|
+
files = sorted(f.name for f in topo_dir.glob("*.yaml"))
|
|
109
|
+
return {"topologies": files}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@router.get("/api/libraries/{library_name}/config-files", tags=["Libraries"])
|
|
113
|
+
async def get_library_config_files(library_name: str):
|
|
114
|
+
"""Return infra, flavour, and workspace config files for the control panel."""
|
|
115
|
+
return deps.get_manifest_store().config_files(library_name)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@router.get("/api/libraries/{library_name}/pipelines", tags=["Libraries"])
|
|
119
|
+
async def list_library_pipelines(library_name: str):
|
|
120
|
+
"""List available pipeline definitions with metadata."""
|
|
121
|
+
pipelines = deps.get_manifest_store().list_pipelines(library_name)
|
|
122
|
+
return {"pipelines": pipelines}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@router.get("/api/libraries/{library_name}/scenarios", tags=["Libraries"])
|
|
126
|
+
async def list_scenarios(library_name: str):
|
|
127
|
+
"""List available MAS scenario files (mas.yaml in apps/<name>/)."""
|
|
128
|
+
lib_dir = deps.get_library_path(library_name)
|
|
129
|
+
apps_dir = lib_dir / "apps"
|
|
130
|
+
|
|
131
|
+
if not apps_dir.exists():
|
|
132
|
+
return {"scenarios": []}
|
|
133
|
+
|
|
134
|
+
scenarios = []
|
|
135
|
+
for app_folder in sorted(apps_dir.iterdir()):
|
|
136
|
+
if not app_folder.is_dir():
|
|
137
|
+
continue
|
|
138
|
+
mas_file = app_folder / "mas.yaml"
|
|
139
|
+
if mas_file.exists():
|
|
140
|
+
scenarios.append({
|
|
141
|
+
"name": app_folder.name,
|
|
142
|
+
"path": f"apps/{app_folder.name}/mas.yaml",
|
|
143
|
+
})
|
|
144
|
+
return {"scenarios": scenarios}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Overlay CRUD and validation endpoints."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, HTTPException
|
|
10
|
+
|
|
11
|
+
from mas.lab.controller.models import SaveOverlayRequest, ValidateRequest
|
|
12
|
+
from mas.lab.controller.routes._api import deps, jobs, LIBRARIES_DIR, validate_pipeline_yaml
|
|
13
|
+
|
|
14
|
+
router = APIRouter()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _overlay_dir_for_namespace(lib_dir: Path, namespace: str | None) -> Path:
|
|
18
|
+
"""Return the overlays directory for the given namespace.
|
|
19
|
+
|
|
20
|
+
``"global"`` or ``None`` → ``{lib_dir}/overlays/``
|
|
21
|
+
anything else → ``{lib_dir}/apps/{namespace}/overlays/``
|
|
22
|
+
"""
|
|
23
|
+
if not namespace or namespace == "global":
|
|
24
|
+
return lib_dir / "overlays"
|
|
25
|
+
return lib_dir / "apps" / namespace / "overlays"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _extract_namespace_from_yaml(content: str) -> str:
|
|
29
|
+
"""Extract the namespace from overlay YAML content.
|
|
30
|
+
|
|
31
|
+
Checks root-level ``x-namespace`` first (UI-generated overlays), then
|
|
32
|
+
falls back to ``metadata.namespace``, defaulting to ``"global"``.
|
|
33
|
+
"""
|
|
34
|
+
import yaml as _yaml
|
|
35
|
+
try:
|
|
36
|
+
doc = _yaml.safe_load(content) or {}
|
|
37
|
+
return str(
|
|
38
|
+
doc.get("x-namespace")
|
|
39
|
+
or (doc.get("metadata") or {}).get("namespace")
|
|
40
|
+
or "global"
|
|
41
|
+
)
|
|
42
|
+
except Exception:
|
|
43
|
+
return "global"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _find_overlay_file(lib_dir: Path, overlay_name: str) -> Path | None:
|
|
47
|
+
"""Search for an overlay file across global, app-scoped, and nested app dirs."""
|
|
48
|
+
filename = f"{overlay_name}.yaml" if not overlay_name.endswith(".yaml") else overlay_name
|
|
49
|
+
global_path = lib_dir / "overlays" / filename
|
|
50
|
+
if global_path.exists():
|
|
51
|
+
return global_path
|
|
52
|
+
apps_dir = lib_dir / "apps"
|
|
53
|
+
if apps_dir.exists():
|
|
54
|
+
for pattern in (f"*/overlays/{filename}", f"*/*/overlays/{filename}"):
|
|
55
|
+
matches = sorted(apps_dir.glob(pattern))
|
|
56
|
+
if matches:
|
|
57
|
+
return matches[0]
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@router.post("/api/libraries/{library_name}/overlays/validate", tags=["Libraries"])
|
|
62
|
+
async def validate_overlay(library_name: str, req: ValidateRequest):
|
|
63
|
+
"""Validate a kind: Overlay overlay manifest.
|
|
64
|
+
|
|
65
|
+
Uses the same schema (overlay.schema.yaml) regardless of whether the overlay
|
|
66
|
+
is intended for mas-ctl run-mas or mas-lab benchmark run — the schema does
|
|
67
|
+
not differentiate between the two usage contexts.
|
|
68
|
+
"""
|
|
69
|
+
lib_dir = deps.get_library_path(library_name)
|
|
70
|
+
errors = await deps.validate_overlay_content(req.manifest_yaml, lib_dir)
|
|
71
|
+
if errors is None:
|
|
72
|
+
return {"status": "OK", "errors": []}
|
|
73
|
+
raise HTTPException(status_code=422, detail=errors)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@router.get("/api/libraries/{library_name}/overlays", tags=["Libraries"])
|
|
77
|
+
async def list_library_overlays(library_name: str):
|
|
78
|
+
"""List available overlay files with name and description (includes nested lab modules)."""
|
|
79
|
+
return {"overlays": deps.get_manifest_store().list_overlays(library_name)}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@router.post("/api/libraries/{library_name}/overlays", tags=["Libraries"], status_code=201)
|
|
83
|
+
async def create_overlay(library_name: str, req: SaveOverlayRequest):
|
|
84
|
+
"""Create a new overlay definition.
|
|
85
|
+
|
|
86
|
+
Storage location is determined by ``metadata.namespace`` in the YAML content:
|
|
87
|
+
``global`` → ``{lib}/overlays/``, otherwise → ``{lib}/apps/{ns}/overlays/``.
|
|
88
|
+
"""
|
|
89
|
+
lib_dir = deps.get_library_path(library_name)
|
|
90
|
+
namespace = _extract_namespace_from_yaml(req.content)
|
|
91
|
+
overlay_dir = _overlay_dir_for_namespace(lib_dir, namespace)
|
|
92
|
+
overlay_dir.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
|
|
94
|
+
filename = f"{req.name}.yaml" if not req.name.endswith(".yaml") else req.name
|
|
95
|
+
file_path = overlay_dir / filename
|
|
96
|
+
|
|
97
|
+
if file_path.exists():
|
|
98
|
+
raise HTTPException(status_code=409, detail=f"Overlay '{req.name}' already exists")
|
|
99
|
+
|
|
100
|
+
if req.run_validation:
|
|
101
|
+
result = await deps.validate_overlay_content(req.content, lib_dir)
|
|
102
|
+
if result is not None:
|
|
103
|
+
raise HTTPException(status_code=422, detail=result)
|
|
104
|
+
|
|
105
|
+
file_path.write_text(req.content, encoding="utf-8")
|
|
106
|
+
deps.get_manifest_store().invalidate()
|
|
107
|
+
return {"name": req.name, "filename": filename}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@router.get("/api/libraries/{library_name}/overlays/{overlay_name}", tags=["Libraries"])
|
|
111
|
+
async def get_overlay(library_name: str, overlay_name: str):
|
|
112
|
+
"""Return the content of a specific overlay definition."""
|
|
113
|
+
lib_dir = deps.get_library_path(library_name)
|
|
114
|
+
file_path = _find_overlay_file(lib_dir, overlay_name)
|
|
115
|
+
|
|
116
|
+
if file_path is None:
|
|
117
|
+
raise HTTPException(status_code=404, detail=f"Overlay '{overlay_name}' not found")
|
|
118
|
+
|
|
119
|
+
return {"name": overlay_name, "filename": file_path.name, "content": file_path.read_text(encoding="utf-8")}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@router.put("/api/libraries/{library_name}/overlays/{overlay_name}", tags=["Libraries"])
|
|
123
|
+
async def update_overlay(library_name: str, overlay_name: str, req: SaveOverlayRequest):
|
|
124
|
+
"""Update an existing overlay definition. Supports rename and namespace change."""
|
|
125
|
+
lib_dir = deps.get_library_path(library_name)
|
|
126
|
+
old_path = _find_overlay_file(lib_dir, overlay_name)
|
|
127
|
+
|
|
128
|
+
if old_path is None:
|
|
129
|
+
raise HTTPException(status_code=404, detail=f"Overlay '{overlay_name}' not found")
|
|
130
|
+
|
|
131
|
+
namespace = _extract_namespace_from_yaml(req.content)
|
|
132
|
+
new_overlay_dir = _overlay_dir_for_namespace(lib_dir, namespace)
|
|
133
|
+
new_overlay_dir.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
|
|
135
|
+
new_filename = f"{req.name}.yaml" if not req.name.endswith(".yaml") else req.name
|
|
136
|
+
new_path = new_overlay_dir / new_filename
|
|
137
|
+
|
|
138
|
+
if new_path != old_path and new_path.exists():
|
|
139
|
+
raise HTTPException(status_code=409, detail=f"Overlay '{req.name}' already exists")
|
|
140
|
+
|
|
141
|
+
if req.run_validation:
|
|
142
|
+
result = await deps.validate_overlay_content(req.content, lib_dir)
|
|
143
|
+
if result is not None:
|
|
144
|
+
raise HTTPException(status_code=422, detail=result)
|
|
145
|
+
|
|
146
|
+
if old_path != new_path:
|
|
147
|
+
old_path.unlink()
|
|
148
|
+
new_path.write_text(req.content, encoding="utf-8")
|
|
149
|
+
deps.get_manifest_store().invalidate()
|
|
150
|
+
return {"name": req.name, "filename": new_filename}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@router.delete("/api/libraries/{library_name}/overlays/{overlay_name}", tags=["Libraries"])
|
|
154
|
+
async def delete_overlay(library_name: str, overlay_name: str):
|
|
155
|
+
"""Delete an overlay definition."""
|
|
156
|
+
lib_dir = deps.get_library_path(library_name)
|
|
157
|
+
file_path = _find_overlay_file(lib_dir, overlay_name)
|
|
158
|
+
|
|
159
|
+
if file_path is None:
|
|
160
|
+
raise HTTPException(status_code=404, detail=f"Overlay '{overlay_name}' not found")
|
|
161
|
+
|
|
162
|
+
file_path.unlink()
|
|
163
|
+
deps.get_manifest_store().invalidate()
|
|
164
|
+
return {"deleted": overlay_name}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Pipeline run, validation, and CRUD endpoints."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter, HTTPException
|
|
11
|
+
|
|
12
|
+
from mas.lab.controller.models import PipelineRunRequest, SavePipelineRequest, ValidateRequest
|
|
13
|
+
from mas.lab.controller.routes._api import deps, jobs, LIBRARIES_DIR, validate_pipeline_yaml
|
|
14
|
+
|
|
15
|
+
router = APIRouter()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.post("/api/libraries/{library_name}/pipeline/run", tags=["Libraries"], status_code=202)
|
|
19
|
+
async def pipeline_run(library_name: str, req: PipelineRunRequest):
|
|
20
|
+
"""Run an analysis pipeline. Returns a job_id for polling."""
|
|
21
|
+
lib_dir = deps.get_library_path(library_name)
|
|
22
|
+
|
|
23
|
+
cleanup_paths: list[Path] = []
|
|
24
|
+
|
|
25
|
+
# Support inline YAML content (has newlines) or a filename
|
|
26
|
+
if "\n" in req.pipeline_yaml:
|
|
27
|
+
tmp = tempfile.NamedTemporaryFile(
|
|
28
|
+
mode="w", suffix=".yaml", prefix="pipeline-", delete=False, dir=str(lib_dir)
|
|
29
|
+
)
|
|
30
|
+
tmp.write(req.pipeline_yaml)
|
|
31
|
+
tmp.close()
|
|
32
|
+
pipeline_path = Path(tmp.name).name
|
|
33
|
+
cleanup_paths.append(Path(tmp.name))
|
|
34
|
+
else:
|
|
35
|
+
pipeline_path = req.pipeline_yaml
|
|
36
|
+
|
|
37
|
+
cmd = ["mas-lab", "benchmark", "pipeline", "run", pipeline_path]
|
|
38
|
+
if req.only:
|
|
39
|
+
cmd += ["--only"] + req.only
|
|
40
|
+
job = jobs.submit_job(
|
|
41
|
+
endpoint=f"/api/libraries/{library_name}/pipeline/run",
|
|
42
|
+
cmd=cmd,
|
|
43
|
+
cwd=lib_dir,
|
|
44
|
+
timeout=req.timeout,
|
|
45
|
+
request_body=req.model_dump(),
|
|
46
|
+
cleanup_paths=cleanup_paths,
|
|
47
|
+
)
|
|
48
|
+
return {"job_id": job.id, "status": job.status.value, "command": job.command}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@router.post("/api/libraries/{library_name}/pipelines/validate", tags=["Libraries"])
|
|
52
|
+
async def validate_pipeline(library_name: str, req: ValidateRequest):
|
|
53
|
+
"""Validate a pipeline manifest against the schema and check DAG integrity."""
|
|
54
|
+
deps.get_library_path(library_name)
|
|
55
|
+
result = validate_pipeline_yaml(req.manifest_yaml)
|
|
56
|
+
if result["valid"]:
|
|
57
|
+
return {"status": "OK Pipeline", "errors": []}
|
|
58
|
+
raise HTTPException(status_code=422, detail=result["errors"])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@router.post("/api/libraries/{library_name}/pipelines", tags=["Libraries"], status_code=201)
|
|
62
|
+
async def create_pipeline(library_name: str, req: SavePipelineRequest):
|
|
63
|
+
"""Create a new pipeline definition in the library's pipelines/ directory."""
|
|
64
|
+
lib_dir = deps.get_library_path(library_name)
|
|
65
|
+
pipe_dir = lib_dir / "pipelines"
|
|
66
|
+
pipe_dir.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
|
|
68
|
+
filename = f"{req.name}.yaml" if not req.name.endswith(".yaml") else req.name
|
|
69
|
+
file_path = pipe_dir / filename
|
|
70
|
+
|
|
71
|
+
if file_path.exists():
|
|
72
|
+
raise HTTPException(status_code=409, detail=f"Pipeline '{req.name}' already exists")
|
|
73
|
+
|
|
74
|
+
if req.run_validation:
|
|
75
|
+
result = validate_pipeline_yaml(req.content)
|
|
76
|
+
if not result["valid"]:
|
|
77
|
+
raise HTTPException(status_code=422, detail=result["errors"])
|
|
78
|
+
|
|
79
|
+
file_path.write_text(req.content, encoding="utf-8")
|
|
80
|
+
deps.get_manifest_store().invalidate()
|
|
81
|
+
return {"name": req.name, "filename": filename}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@router.get("/api/libraries/{library_name}/pipelines/{pipeline_name}", tags=["Libraries"])
|
|
85
|
+
async def get_pipeline(library_name: str, pipeline_name: str):
|
|
86
|
+
"""Return the content of a specific pipeline definition."""
|
|
87
|
+
lib_dir = deps.get_library_path(library_name)
|
|
88
|
+
filename = f"{pipeline_name}.yaml" if not pipeline_name.endswith(".yaml") else pipeline_name
|
|
89
|
+
file_path = lib_dir / "pipelines" / filename
|
|
90
|
+
|
|
91
|
+
if not file_path.exists():
|
|
92
|
+
raise HTTPException(status_code=404, detail=f"Pipeline '{pipeline_name}' not found")
|
|
93
|
+
|
|
94
|
+
return {"name": pipeline_name, "filename": filename, "content": file_path.read_text(encoding="utf-8")}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@router.put("/api/libraries/{library_name}/pipelines/{pipeline_name}", tags=["Libraries"])
|
|
98
|
+
async def update_pipeline(library_name: str, pipeline_name: str, req: SavePipelineRequest):
|
|
99
|
+
"""Update an existing pipeline definition. Supports rename via req.name."""
|
|
100
|
+
lib_dir = deps.get_library_path(library_name)
|
|
101
|
+
pipe_dir = lib_dir / "pipelines"
|
|
102
|
+
|
|
103
|
+
old_filename = f"{pipeline_name}.yaml" if not pipeline_name.endswith(".yaml") else pipeline_name
|
|
104
|
+
old_path = pipe_dir / old_filename
|
|
105
|
+
|
|
106
|
+
if not old_path.exists():
|
|
107
|
+
raise HTTPException(status_code=404, detail=f"Pipeline '{pipeline_name}' not found")
|
|
108
|
+
|
|
109
|
+
new_filename = f"{req.name}.yaml" if not req.name.endswith(".yaml") else req.name
|
|
110
|
+
new_path = pipe_dir / new_filename
|
|
111
|
+
|
|
112
|
+
if req.name != pipeline_name and new_path.exists():
|
|
113
|
+
raise HTTPException(status_code=409, detail=f"Pipeline '{req.name}' already exists")
|
|
114
|
+
|
|
115
|
+
if req.run_validation:
|
|
116
|
+
result = validate_pipeline_yaml(req.content)
|
|
117
|
+
if not result["valid"]:
|
|
118
|
+
raise HTTPException(status_code=422, detail=result["errors"])
|
|
119
|
+
|
|
120
|
+
if old_path != new_path:
|
|
121
|
+
old_path.unlink()
|
|
122
|
+
new_path.write_text(req.content, encoding="utf-8")
|
|
123
|
+
deps.get_manifest_store().invalidate()
|
|
124
|
+
return {"name": req.name, "filename": new_filename}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@router.delete("/api/libraries/{library_name}/pipelines/{pipeline_name}", tags=["Libraries"])
|
|
128
|
+
async def delete_pipeline(library_name: str, pipeline_name: str):
|
|
129
|
+
"""Delete a pipeline definition."""
|
|
130
|
+
lib_dir = deps.get_library_path(library_name)
|
|
131
|
+
filename = f"{pipeline_name}.yaml" if not pipeline_name.endswith(".yaml") else pipeline_name
|
|
132
|
+
file_path = lib_dir / "pipelines" / filename
|
|
133
|
+
|
|
134
|
+
if not file_path.exists():
|
|
135
|
+
raise HTTPException(status_code=404, detail=f"Pipeline '{pipeline_name}' not found")
|
|
136
|
+
|
|
137
|
+
file_path.unlink()
|
|
138
|
+
deps.get_manifest_store().invalidate()
|
|
139
|
+
return {"deleted": pipeline_name}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Registry and discovery endpoints."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter
|
|
8
|
+
|
|
9
|
+
from mas.lab.controller.routes._api import deps, jobs, LIBRARIES_DIR, validate_pipeline_yaml
|
|
10
|
+
|
|
11
|
+
router = APIRouter()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@router.get("/api/registry", tags=["Registry"])
|
|
15
|
+
async def list_registry():
|
|
16
|
+
"""Unified lab + runtime registry catalog."""
|
|
17
|
+
from mas.lab.controller.lab_registry import get_lab_registry
|
|
18
|
+
|
|
19
|
+
ws = getattr(deps.get_manifest_store(), "_workspace", None)
|
|
20
|
+
return {"registry": get_lab_registry(ws).catalog()}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@router.get("/api/defaults", tags=["Registry"])
|
|
24
|
+
async def get_defaults():
|
|
25
|
+
"""Agent and infra defaults (design pattern, model) for UI and playground."""
|
|
26
|
+
from mas.lab.controller.lab_registry import get_lab_registry
|
|
27
|
+
|
|
28
|
+
ws = getattr(deps.get_manifest_store(), "_workspace", None)
|
|
29
|
+
reg = get_lab_registry(ws)
|
|
30
|
+
return {
|
|
31
|
+
"agent": reg.agent_defaults(),
|
|
32
|
+
"default_model": reg.default_model(),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@router.get("/api/discovery", tags=["Registry"])
|
|
37
|
+
async def get_discovery_report():
|
|
38
|
+
"""Paths scanned, libraries resolved, and runtime entry points (debug)."""
|
|
39
|
+
store = deps.get_manifest_store()
|
|
40
|
+
return store._registry.discovery_report()
|