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.
Files changed (89) hide show
  1. mas/lab/controller/__init__.py +7 -0
  2. mas/lab/controller/__main__.py +5 -0
  3. mas/lab/controller/agent_chat.py +226 -0
  4. mas/lab/controller/api.py +364 -0
  5. mas/lab/controller/artifact_discovery.py +107 -0
  6. mas/lab/controller/artifacts.py +112 -0
  7. mas/lab/controller/cli.py +493 -0
  8. mas/lab/controller/client.py +271 -0
  9. mas/lab/controller/config.py +75 -0
  10. mas/lab/controller/constants.py +30 -0
  11. mas/lab/controller/daemon.py +190 -0
  12. mas/lab/controller/deps.py +172 -0
  13. mas/lab/controller/fastapi_app.py +166 -0
  14. mas/lab/controller/http_server.py +243 -0
  15. mas/lab/controller/io_capture.py +55 -0
  16. mas/lab/controller/item_service.py +625 -0
  17. mas/lab/controller/job_store.py +209 -0
  18. mas/lab/controller/jobs.py +375 -0
  19. mas/lab/controller/kinds.py +149 -0
  20. mas/lab/controller/lab_registry.py +1076 -0
  21. mas/lab/controller/manifest_layout.py +832 -0
  22. mas/lab/controller/manifest_store.py +214 -0
  23. mas/lab/controller/manifest_validation.py +90 -0
  24. mas/lab/controller/models.py +215 -0
  25. mas/lab/controller/path_safety.py +68 -0
  26. mas/lab/controller/pipeline_validation.py +79 -0
  27. mas/lab/controller/registry.py +83 -0
  28. mas/lab/controller/routes/__init__.py +82 -0
  29. mas/lab/controller/routes/_api.py +15 -0
  30. mas/lab/controller/routes/apps.py +190 -0
  31. mas/lab/controller/routes/artifacts.py +37 -0
  32. mas/lab/controller/routes/benchmark.py +307 -0
  33. mas/lab/controller/routes/choices.py +141 -0
  34. mas/lab/controller/routes/datasets.py +91 -0
  35. mas/lab/controller/routes/design_patterns.py +121 -0
  36. mas/lab/controller/routes/eval_output.py +31 -0
  37. mas/lab/controller/routes/experiments.py +194 -0
  38. mas/lab/controller/routes/health.py +178 -0
  39. mas/lab/controller/routes/items.py +187 -0
  40. mas/lab/controller/routes/jobs.py +107 -0
  41. mas/lab/controller/routes/libraries.py +144 -0
  42. mas/lab/controller/routes/overlays.py +164 -0
  43. mas/lab/controller/routes/pipelines.py +139 -0
  44. mas/lab/controller/routes/registry.py +40 -0
  45. mas/lab/controller/routes/run.py +215 -0
  46. mas/lab/controller/routes/runs.py +392 -0
  47. mas/lab/controller/routes/scopes.py +139 -0
  48. mas/lab/controller/routes/uploads.py +24 -0
  49. mas/lab/controller/routes/validate.py +36 -0
  50. mas/lab/controller/run_bridge.py +276 -0
  51. mas/lab/controller/run_store.py +190 -0
  52. mas/lab/controller/schema_registry.py +109 -0
  53. mas/lab/controller/schemas/pipeline-step-types-post.json +764 -0
  54. mas/lab/controller/schemas/pipeline-step-types-pre.json +35 -0
  55. mas/lab/controller/scope_catalog.py +266 -0
  56. mas/lab/controller/scope_service.py +134 -0
  57. mas/lab/controller/sessions.py +63 -0
  58. mas/lab/controller/settings.py +65 -0
  59. mas/lab/controller/ui_stream/__init__.py +13 -0
  60. mas/lab/controller/ui_stream/app.py +2147 -0
  61. mas/lab/controller/ui_stream/artefact_bind.py +308 -0
  62. mas/lab/controller/ui_stream/auth.py +136 -0
  63. mas/lab/controller/ui_stream/bus.py +156 -0
  64. mas/lab/controller/ui_stream/export.py +400 -0
  65. mas/lab/controller/ui_stream/governance.py +141 -0
  66. mas/lab/controller/ui_stream/ingest.py +300 -0
  67. mas/lab/controller/ui_stream/inspect.py +55 -0
  68. mas/lab/controller/ui_stream/layouts.py +161 -0
  69. mas/lab/controller/ui_stream/metrics.py +274 -0
  70. mas/lab/controller/ui_stream/plot_folds.py +609 -0
  71. mas/lab/controller/ui_stream/plugin_registry.py +139 -0
  72. mas/lab/controller/ui_stream/refs.py +259 -0
  73. mas/lab/controller/ui_stream/replay.py +236 -0
  74. mas/lab/controller/ui_stream/replay_cli.py +707 -0
  75. mas/lab/controller/ui_stream/run_index.py +337 -0
  76. mas/lab/controller/ui_stream/spec_load.py +48 -0
  77. mas/lab/controller/ui_stream/spec_snapshot.py +313 -0
  78. mas/lab/controller/ui_stream/stream.py +95 -0
  79. mas/lab/controller/ui_stream/streams.py +42 -0
  80. mas/lab/controller/ui_stream/timeline.py +183 -0
  81. mas/lab/controller/ui_stream/ui_trace.py +258 -0
  82. mas/lab/controller/uploads.py +61 -0
  83. mas/lab/controller/worker_model.py +137 -0
  84. mas/lab/controller/workers.py +296 -0
  85. mas/lab/controller/workspace_library.py +70 -0
  86. mas_lab_component_controller-0.2.0.dist-info/METADATA +21 -0
  87. mas_lab_component_controller-0.2.0.dist-info/RECORD +89 -0
  88. mas_lab_component_controller-0.2.0.dist-info/WHEEL +4 -0
  89. 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()