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,172 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Shared dependencies and helpers for the FastAPI controller API."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import logging
9
+ import os
10
+ import time
11
+ from pathlib import Path
12
+
13
+ from fastapi import HTTPException
14
+ from starlette.requests import Request
15
+
16
+ from mas.lab.controller.constants import HIDDEN_FILES, LIBRARIES_DIR
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _manifest_store = None
21
+ _manifest_store_last_refresh = 0.0
22
+
23
+
24
+ def _refresh_interval_seconds() -> float:
25
+ """Return manifest refresh throttle interval.
26
+
27
+ Set MAS_CONTROLLER_REFRESH_INTERVAL_S=0 to keep legacy refresh-on-every-call behavior.
28
+ """
29
+ raw = os.environ.get("MAS_CONTROLLER_REFRESH_INTERVAL_S", "1.0").strip()
30
+ try:
31
+ interval = float(raw)
32
+ except ValueError:
33
+ return 1.0
34
+ return max(0.0, interval)
35
+
36
+
37
+ def store_from_request(request: Request):
38
+ """Prefer the app-injected store (tests); else the process singleton."""
39
+ return getattr(request.app.state, "manifest_store", None) or get_manifest_store()
40
+
41
+
42
+ def get_manifest_store():
43
+ """Lazy ManifestStore — discovers *.lab dirs and manifest_libraries."""
44
+ global _manifest_store, _manifest_store_last_refresh
45
+ if _manifest_store is None:
46
+ from mas.lab.controller.manifest_store import ManifestStore
47
+
48
+ try:
49
+ from mas.lab.workspace import WorkspaceConfig
50
+
51
+ ws = WorkspaceConfig.load()
52
+ except Exception:
53
+ ws = None
54
+ _manifest_store = ManifestStore(ws)
55
+ _manifest_store_last_refresh = time.monotonic()
56
+ else:
57
+ now = time.monotonic()
58
+ interval = _refresh_interval_seconds()
59
+ if interval <= 0.0 or (now - _manifest_store_last_refresh) >= interval:
60
+ _manifest_store.refresh()
61
+ _manifest_store_last_refresh = now
62
+ return _manifest_store
63
+
64
+
65
+ def get_library_path(library_name: str) -> Path:
66
+ """Resolve a library name to its directory path."""
67
+ store = get_manifest_store()
68
+ try:
69
+ return store.library_root(library_name)
70
+ except KeyError:
71
+ pass
72
+ lib_dir = LIBRARIES_DIR / library_name
73
+ if not lib_dir.exists() or not lib_dir.is_dir():
74
+ raise HTTPException(status_code=404, detail=f"Library '{library_name}' not found")
75
+ return lib_dir
76
+
77
+
78
+ def discover_tools(base_dir: Path, namespaces: list[str] | None = None) -> list[dict]:
79
+ from mas.lab.controller.artifact_discovery import discover_tools
80
+
81
+ return discover_tools(base_dir, namespaces)
82
+
83
+
84
+ def discover_skills(base_dir: Path, namespaces: list[str] | None = None) -> list[dict]:
85
+ from mas.lab.controller.artifact_discovery import discover_skills
86
+
87
+ return discover_skills(base_dir, namespaces)
88
+
89
+
90
+ async def run_cli(
91
+ cmd: list[str],
92
+ cwd: Path,
93
+ timeout: int = 60,
94
+ env_override: dict[str, str] | None = None,
95
+ ) -> dict:
96
+ """Execute a CLI command synchronously (for quick operations like validate)."""
97
+ env = os.environ.copy()
98
+ if env_override:
99
+ env.update(env_override)
100
+
101
+ logger.info("Running: %s (cwd=%s)", " ".join(cmd), cwd)
102
+
103
+ try:
104
+ proc = await asyncio.create_subprocess_exec(
105
+ *cmd,
106
+ cwd=str(cwd),
107
+ stdout=asyncio.subprocess.PIPE,
108
+ stderr=asyncio.subprocess.PIPE,
109
+ env=env,
110
+ )
111
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
112
+ except asyncio.TimeoutError:
113
+ proc.kill()
114
+ await proc.communicate()
115
+ raise HTTPException(
116
+ status_code=504,
117
+ detail=f"Command timed out after {timeout}s: {' '.join(cmd)}",
118
+ )
119
+ except FileNotFoundError:
120
+ raise HTTPException(
121
+ status_code=500,
122
+ detail=f"Command not found: {cmd[0]}. Ensure mas-runtime is on PATH.",
123
+ )
124
+
125
+ result = {
126
+ "exit_code": proc.returncode,
127
+ "stdout": stdout.decode(errors="replace"),
128
+ "stderr": stderr.decode(errors="replace"),
129
+ "command": " ".join(cmd),
130
+ }
131
+
132
+ if proc.returncode != 0:
133
+ result["error"] = f"Command failed with exit code {proc.returncode}"
134
+
135
+ return result
136
+
137
+
138
+ def build_tree(root: Path, base: Path) -> list[dict]:
139
+ """Recursively build a file/folder tree structure."""
140
+ entries = []
141
+ try:
142
+ children_iter = sorted(root.iterdir())
143
+ except (PermissionError, OSError):
144
+ return entries
145
+ for child in children_iter:
146
+ if child.name in HIDDEN_FILES:
147
+ continue
148
+ rel = str(child.relative_to(base))
149
+ try:
150
+ if child.is_dir():
151
+ children = build_tree(child, base)
152
+ entries.append({"name": child.name, "path": rel, "type": "directory", "children": children})
153
+ else:
154
+ entries.append({"name": child.name, "path": rel, "type": "file", "size": child.stat().st_size})
155
+ except (FileNotFoundError, PermissionError, OSError):
156
+ entries.append({"name": child.name, "path": rel, "type": "file", "size": 0})
157
+ return entries
158
+
159
+
160
+ def ensure_yaml_ext(name: str) -> str:
161
+ """Append .yaml extension when none is present."""
162
+ lower = name.lower()
163
+ if lower.endswith(".yaml") or lower.endswith(".yml"):
164
+ return name
165
+ return f"{name}.yaml"
166
+
167
+
168
+ async def validate_overlay_content(content: str, lib_dir: Path) -> list[str] | None:
169
+ """Validate overlay YAML content. Returns error list or None if valid."""
170
+ from mas.lab.controller.manifest_validation import validate_overlay_yaml_content
171
+
172
+ return validate_overlay_yaml_content(content, base_dir=lib_dir)
@@ -0,0 +1,166 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """
4
+ FastAPI server exposing mas-lab as a scope-based REST API.
5
+
6
+ Run via controller daemon:
7
+ mas-lab serve --port 8090
8
+ mas-lab serve --host 0.0.0.0 --token-file ./token --cors-origin https://ui.example
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import hmac
15
+ import logging
16
+ import os
17
+ from contextlib import asynccontextmanager
18
+ from pathlib import Path
19
+
20
+ from fastapi import FastAPI, Request
21
+ from fastapi.middleware.cors import CORSMiddleware
22
+ from fastapi.responses import JSONResponse
23
+ from mas.lab.controller.constants import LIBRARIES_DIR, MAS_LAB_ROOT
24
+ from mas.lab.controller.deps import _manifest_store, get_manifest_store
25
+ from mas.lab.controller.jobs import Job, JobStatus, _jobs
26
+ from mas.lab.controller.routes import register_routes
27
+ from mas.lab.controller.settings import ControllerSettings, is_loopback_host
28
+ from starlette.middleware.base import BaseHTTPMiddleware
29
+
30
+ logging.basicConfig(level=logging.INFO)
31
+ logger = logging.getLogger(__name__)
32
+
33
+ # Probes and the connect screen must work before a token is entered.
34
+ # Swagger and OpenAPI stay behind bearer when a token is configured.
35
+ _OPEN_PATHS = {
36
+ "/health",
37
+ "/api/health",
38
+ "/api/info",
39
+ }
40
+
41
+
42
+ def _tokens_match(offered: str, expected: str) -> bool:
43
+ """Compare bearer secrets without leaking length via ``hmac.compare_digest``."""
44
+ left = hashlib.sha256(offered.encode("utf-8")).digest()
45
+ right = hashlib.sha256(expected.encode("utf-8")).digest()
46
+ return hmac.compare_digest(left, right)
47
+
48
+
49
+ class BearerTokenMiddleware(BaseHTTPMiddleware):
50
+ """Require ``Authorization: Bearer`` when a token was configured."""
51
+
52
+ def __init__(self, app: FastAPI, token: str):
53
+ super().__init__(app)
54
+ self._token = token
55
+
56
+ async def dispatch(self, request: Request, call_next):
57
+ if request.url.path in _OPEN_PATHS or request.method == "OPTIONS":
58
+ return await call_next(request)
59
+ header = request.headers.get("authorization") or ""
60
+ scheme, _, offered = header.partition(" ")
61
+ got = offered.strip() if scheme.lower() == "bearer" else ""
62
+ if not _tokens_match(got, self._token):
63
+ return JSONResponse({"detail": "unauthorized"}, status_code=401)
64
+ return await call_next(request)
65
+
66
+
67
+ @asynccontextmanager
68
+ async def lifespan(app: FastAPI):
69
+ """Emit discovery report once the API process starts."""
70
+ if os.environ.get("MAS_CONTROLLER_DISABLE_STARTUP_DISCOVERY", "").strip().lower() in {
71
+ "1",
72
+ "true",
73
+ "yes",
74
+ }:
75
+ yield
76
+ return
77
+ try:
78
+ store = get_manifest_store()
79
+ report = store._registry.discovery_report()
80
+ logger.info("MAS Lab API startup discovery: %s", report)
81
+ except Exception as exc:
82
+ logger.warning("Startup discovery report failed: %s", exc)
83
+ yield
84
+
85
+
86
+ def create_app(settings: ControllerSettings | None = None) -> FastAPI:
87
+ """Build and configure the FastAPI application."""
88
+ settings = settings or ControllerSettings()
89
+ application = FastAPI(
90
+ title="MAS Lab API",
91
+ description="REST interface for mas-lab — scope-based HTTP API with persisted runs",
92
+ version=settings.api_version,
93
+ lifespan=lifespan,
94
+ )
95
+ application.state.settings = settings
96
+
97
+ # Last-added middleware is outermost. Bearer must sit inside CORS so a
98
+ # 401 JSONResponse still receives Access-Control-Allow-Origin.
99
+ if settings.token:
100
+ application.add_middleware(BearerTokenMiddleware, token=settings.token)
101
+ if not is_loopback_host(settings.host):
102
+ logger.warning(
103
+ "Bearer auth is enabled; clients must send Authorization: Bearer"
104
+ )
105
+ elif not is_loopback_host(settings.host):
106
+ raise RuntimeError(
107
+ f"refusing to bind {settings.host} without --token-file"
108
+ )
109
+
110
+ origins = settings.cors_allow_origins
111
+ if origins:
112
+ application.add_middleware(
113
+ CORSMiddleware,
114
+ allow_origins=origins,
115
+ allow_methods=["*"],
116
+ allow_headers=["*"],
117
+ )
118
+
119
+ from mas.lab.controller.job_store import init_job_store
120
+ from mas.lab.controller.run_store import init_run_store
121
+ from mas.lab.controller.uploads import init_upload_store
122
+ from mas.lab.controller.workspace_library import init_workspace_library, reset_workspace_library
123
+
124
+ project_root = None
125
+ try:
126
+ from mas.lab.workspace import project_workspace_root
127
+
128
+ project_root = project_workspace_root()
129
+ except Exception:
130
+ project_root = None
131
+
132
+ if settings.state_dir is not None:
133
+ state_dir: Path = Path(settings.state_dir)
134
+ state_dir.mkdir(parents=True, exist_ok=True)
135
+ init_job_store(db_path=state_dir / "jobs.sqlite")
136
+ init_run_store(state_dir / "runs.sqlite")
137
+ init_upload_store(state_dir / "uploads")
138
+ init_workspace_library(project_root or state_dir)
139
+ else:
140
+ import tempfile
141
+
142
+ init_job_store(db_path=None)
143
+ init_run_store(None)
144
+ init_upload_store(Path(tempfile.mkdtemp(prefix="mas-uploads-")))
145
+ if project_root is not None:
146
+ init_workspace_library(project_root)
147
+ else:
148
+ reset_workspace_library()
149
+
150
+ register_routes(application)
151
+ return application
152
+
153
+
154
+ app = create_app()
155
+
156
+
157
+ __all__ = [
158
+ "app",
159
+ "create_app",
160
+ "Job",
161
+ "JobStatus",
162
+ "LIBRARIES_DIR",
163
+ "MAS_LAB_ROOT",
164
+ "_jobs",
165
+ "_manifest_store",
166
+ ]
@@ -0,0 +1,243 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """HTTP API for mas-lab-ui (stdlib ThreadingHTTPServer)."""
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import logging
8
+ import re
9
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
10
+ from typing import Any, Callable, Optional
11
+ from urllib.parse import parse_qs, unquote, urlparse
12
+
13
+ from mas.lab.controller.api import ControllerAPI
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _CORS = {
18
+ "Access-Control-Allow-Origin": "*",
19
+ "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
20
+ "Access-Control-Allow-Headers": "Content-Type",
21
+ }
22
+
23
+
24
+ class ControllerHTTPHandler(BaseHTTPRequestHandler):
25
+ api: ControllerAPI | None = None
26
+
27
+ def log_message(self, fmt: str, *args: Any) -> None:
28
+ logger.debug(fmt, *args)
29
+
30
+ def _read_json(self) -> dict:
31
+ length = int(self.headers.get("Content-Length", "0"))
32
+ if length <= 0:
33
+ return {}
34
+ return json.loads(self.rfile.read(length).decode("utf-8"))
35
+
36
+ def _send(self, status: int, payload: Any) -> None:
37
+ body = json.dumps(payload).encode("utf-8")
38
+ self.send_response(status)
39
+ self.send_header("Content-Type", "application/json")
40
+ self.send_header("Content-Length", str(len(body)))
41
+ for key, value in _CORS.items():
42
+ self.send_header(key, value)
43
+ self.end_headers()
44
+ self.wfile.write(body)
45
+
46
+ def _error(self, status: int, detail: str) -> None:
47
+ self._send(status, {"detail": detail})
48
+
49
+ def do_OPTIONS(self) -> None: # noqa: N802
50
+ self.send_response(204)
51
+ for key, value in _CORS.items():
52
+ self.send_header(key, value)
53
+ self.end_headers()
54
+
55
+ def do_GET(self) -> None: # noqa: N802
56
+ api = self.api
57
+ if api is None:
58
+ return self._error(500, "API not initialized")
59
+ path = urlparse(self.path).path
60
+ query = parse_qs(urlparse(self.path).query)
61
+
62
+ if path == "/api/health":
63
+ return self._send(200, {"status": "ok"})
64
+ if path == "/api/libraries":
65
+ return self._send(200, {"libraries": api.list_libraries()})
66
+ if path == "/api/jobs":
67
+ status = (query.get("status") or [None])[0]
68
+ return self._send(200, {"jobs": api.list_jobs(status=status)})
69
+ if path.startswith("/api/jobs/"):
70
+ job_id = path.split("/", 3)[-1]
71
+ job = api.get_job(job_id)
72
+ if job is None:
73
+ return self._error(404, f"Job {job_id} not found")
74
+ return self._send(200, job)
75
+ if path == "/api/pipeline-step-types":
76
+ return self._send(200, api.pipeline_step_types())
77
+ if path == "/api/metrics/eval":
78
+ return self._send(200, {})
79
+ if path == "/api/metrics/mce":
80
+ return self._send(200, {})
81
+
82
+ m = re.match(r"^/api/libraries/([^/]+)/tools$", path)
83
+ if m:
84
+ return self._send(200, {"tools": api.list_tools(unquote(m.group(1)))})
85
+ m = re.match(r"^/api/libraries/([^/]+)/skills$", path)
86
+ if m:
87
+ return self._send(200, {"skills": api.list_skills(unquote(m.group(1)))})
88
+ m = re.match(r"^/api/libraries/([^/]+)/experiments$", path)
89
+ if m:
90
+ return self._send(200, {"experiments": api.list_experiments(unquote(m.group(1)))})
91
+ m = re.match(r"^/api/libraries/([^/]+)/experiments/([^/]+)$", path)
92
+ if m:
93
+ library, name = unquote(m.group(1)), unquote(m.group(2))
94
+ try:
95
+ return self._send(200, api.get_experiment_content(library, name))
96
+ except FileNotFoundError:
97
+ return self._error(404, "experiment not found")
98
+ m = re.match(r"^/api/libraries/([^/]+)/pipelines$", path)
99
+ if m:
100
+ return self._send(200, {"pipelines": api.list_pipelines(unquote(m.group(1)))})
101
+ m = re.match(r"^/api/libraries/([^/]+)/pipelines/([^/]+)$", path)
102
+ if m:
103
+ library, name = unquote(m.group(1)), unquote(m.group(2))
104
+ try:
105
+ return self._send(200, api.get_pipeline_content(library, name))
106
+ except FileNotFoundError:
107
+ return self._error(404, "pipeline not found")
108
+ m = re.match(r"^/api/libraries/([^/]+)/overlays$", path)
109
+ if m:
110
+ return self._send(200, {"overlays": api.list_overlays(unquote(m.group(1)))})
111
+ m = re.match(r"^/api/libraries/([^/]+)/overlays/([^/]+)$", path)
112
+ if m:
113
+ library, name = unquote(m.group(1)), unquote(m.group(2))
114
+ try:
115
+ return self._send(200, api.get_overlay_content(library, name))
116
+ except FileNotFoundError:
117
+ return self._error(404, "overlay not found")
118
+ m = re.match(r"^/api/libraries/([^/]+)/datasets$", path)
119
+ if m:
120
+ return self._send(200, {"datasets": api.list_datasets(unquote(m.group(1)))})
121
+ m = re.match(r"^/api/libraries/([^/]+)/datasets/([^/]+)$", path)
122
+ if m:
123
+ library, name = unquote(m.group(1)), unquote(m.group(2))
124
+ try:
125
+ return self._send(200, api.get_dataset_content(library, name))
126
+ except FileNotFoundError:
127
+ return self._error(404, "dataset not found")
128
+ m = re.match(r"^/api/libraries/([^/]+)/scenarios$", path)
129
+ if m:
130
+ return self._send(200, {"scenarios": api.list_scenarios(unquote(m.group(1)))})
131
+ m = re.match(r"^/api/libraries/([^/]+)/config-files$", path)
132
+ if m:
133
+ return self._send(200, api.config_files(unquote(m.group(1))))
134
+ m = re.match(r"^/api/libraries/([^/]+)/apps$", path)
135
+ if m:
136
+ return self._send(200, {"mas_resources": {}})
137
+
138
+ self._error(404, "not found")
139
+
140
+ def do_POST(self) -> None: # noqa: N802
141
+ return self._dispatch_write("POST")
142
+
143
+ def do_PUT(self) -> None: # noqa: N802
144
+ return self._dispatch_write("PUT")
145
+
146
+ def do_DELETE(self) -> None: # noqa: N802
147
+ api = self.api
148
+ if api is None:
149
+ return self._error(500, "API not initialized")
150
+ path = urlparse(self.path).path
151
+ m = re.match(r"^/api/libraries/([^/]+)/experiments/([^/]+)$", path)
152
+ if m:
153
+ api.delete_experiment(unquote(m.group(1)), unquote(m.group(2)))
154
+ return self._send(200, {"ok": True})
155
+ m = re.match(r"^/api/libraries/([^/]+)/pipelines/([^/]+)$", path)
156
+ if m:
157
+ api.delete_pipeline(unquote(m.group(1)), unquote(m.group(2)))
158
+ return self._send(200, {"ok": True})
159
+ m = re.match(r"^/api/libraries/([^/]+)/overlays/([^/]+)$", path)
160
+ if m:
161
+ api.delete_overlay(unquote(m.group(1)), unquote(m.group(2)))
162
+ return self._send(200, {"ok": True})
163
+ m = re.match(r"^/api/libraries/([^/]+)/datasets/([^/]+)$", path)
164
+ if m:
165
+ api.delete_dataset(unquote(m.group(1)), unquote(m.group(2)))
166
+ return self._send(200, {"ok": True})
167
+ self._error(404, "not found")
168
+
169
+ def _dispatch_write(self, method: str) -> None:
170
+ api = self.api
171
+ if api is None:
172
+ return self._error(500, "API not initialized")
173
+ path = urlparse(self.path).path
174
+ body = self._read_json()
175
+
176
+ m = re.match(r"^/api/libraries/([^/]+)/validate$", path)
177
+ if m and method == "POST":
178
+ return self._send(200, api.validate_manifest_yaml(body.get("manifest_yaml") or ""))
179
+ m = re.match(r"^/api/libraries/([^/]+)/run$", path)
180
+ if m and method == "POST":
181
+ return self._send(200, api.run_agent_job(unquote(m.group(1)), body))
182
+ m = re.match(r"^/api/libraries/([^/]+)/run-mas$", path)
183
+ if m and method == "POST":
184
+ return self._send(200, api.run_mas_job(unquote(m.group(1)), body))
185
+ m = re.match(r"^/api/libraries/([^/]+)/benchmark/run$", path)
186
+ if m and method == "POST":
187
+ return self._send(200, api.run_benchmark_job(unquote(m.group(1)), body))
188
+ m = re.match(r"^/api/libraries/([^/]+)/pipeline/run$", path)
189
+ if m and method == "POST":
190
+ return self._send(200, api.run_pipeline_job(unquote(m.group(1)), body))
191
+ m = re.match(r"^/api/libraries/([^/]+)/experiments$", path)
192
+ if m and method == "POST":
193
+ api.save_experiment(unquote(m.group(1)), body["name"], body["content"])
194
+ return self._send(200, {"ok": True})
195
+ m = re.match(r"^/api/libraries/([^/]+)/experiments/([^/]+)$", path)
196
+ if m and method == "PUT":
197
+ library = unquote(m.group(1))
198
+ api.save_experiment(library, body.get("name") or unquote(m.group(2)), body["content"])
199
+ return self._send(200, {"ok": True})
200
+ m = re.match(r"^/api/libraries/([^/]+)/pipelines$", path)
201
+ if m and method == "POST":
202
+ api.save_pipeline(unquote(m.group(1)), body["name"], body["content"])
203
+ return self._send(200, {"ok": True})
204
+ m = re.match(r"^/api/libraries/([^/]+)/pipelines/([^/]+)$", path)
205
+ if m and method == "PUT":
206
+ library = unquote(m.group(1))
207
+ api.save_pipeline(library, body.get("name") or unquote(m.group(2)), body["content"])
208
+ return self._send(200, {"ok": True})
209
+ m = re.match(r"^/api/libraries/([^/]+)/overlays/validate$", path)
210
+ if m and method == "POST":
211
+ return self._send(200, {"status": "ok", "errors": []})
212
+ m = re.match(r"^/api/libraries/([^/]+)/overlays$", path)
213
+ if m and method == "POST":
214
+ api.save_overlay(unquote(m.group(1)), body["name"], body["content"])
215
+ return self._send(200, {"ok": True})
216
+ m = re.match(r"^/api/libraries/([^/]+)/overlays/([^/]+)$", path)
217
+ if m and method == "PUT":
218
+ library = unquote(m.group(1))
219
+ api.save_overlay(library, body.get("name") or unquote(m.group(2)), body["content"])
220
+ return self._send(200, {"ok": True})
221
+ m = re.match(r"^/api/libraries/([^/]+)/pipelines/validate$", path)
222
+ if m and method == "POST":
223
+ return self._send(200, {"status": "ok"})
224
+ m = re.match(r"^/api/libraries/([^/]+)/datasets$", path)
225
+ if m and method == "POST":
226
+ api.save_dataset(unquote(m.group(1)), body["name"], body["content"])
227
+ return self._send(200, {"ok": True})
228
+ m = re.match(r"^/api/libraries/([^/]+)/datasets/([^/]+)$", path)
229
+ if m and method == "PUT":
230
+ library = unquote(m.group(1))
231
+ api.save_dataset(library, body.get("name") or unquote(m.group(2)), body["content"])
232
+ return self._send(200, {"ok": True})
233
+
234
+ self._error(404, "not found")
235
+
236
+
237
+ def start_http_server(*, api: ControllerAPI, host: str = "127.0.0.1", port: int = 9000) -> None:
238
+ class _Bound(ControllerHTTPHandler):
239
+ api = api
240
+
241
+ server = ThreadingHTTPServer((host, port), _Bound)
242
+ logger.info("Controller HTTP listening on http://%s:%s", host, port)
243
+ server.serve_forever()
@@ -0,0 +1,55 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Capture stdout/stderr from worker threads into WorkerRecord buffers."""
4
+ from __future__ import annotations
5
+
6
+ import logging
7
+ import sys
8
+ from contextlib import contextmanager
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from mas.lab.controller.worker_model import WorkerRecord
13
+
14
+
15
+ class _StreamCapture:
16
+ def __init__(self, record: "WorkerRecord", attr: str) -> None:
17
+ self._record = record
18
+ self._attr = attr
19
+
20
+ def write(self, text: str) -> int:
21
+ if text:
22
+ getattr(self._record, f"append_{self._attr}_chunk")(text)
23
+ return len(text)
24
+
25
+ def flush(self) -> None:
26
+ pass
27
+
28
+ def isatty(self) -> bool:
29
+ return False
30
+
31
+
32
+ @contextmanager
33
+ def capture_worker_io(record: "WorkerRecord"):
34
+ """Redirect sys.stdout/stderr and logging into the worker record.
35
+
36
+ Logging handlers are bound when the daemon starts (often to daemon.log),
37
+ so replacing sys.stderr alone does not move ``logger.error`` into the
38
+ stream the CLI polls. A temporary StreamHandler writes those lines
39
+ into the worker record as well.
40
+ """
41
+ out = _StreamCapture(record, "stdout")
42
+ err = _StreamCapture(record, "stderr")
43
+ old_out, old_err = sys.stdout, sys.stderr
44
+ handler = logging.StreamHandler(err)
45
+ handler.setLevel(logging.NOTSET)
46
+ handler.setFormatter(logging.Formatter("%(message)s"))
47
+ root = logging.getLogger()
48
+ sys.stdout, sys.stderr = out, err # type: ignore[assignment]
49
+ root.addHandler(handler)
50
+ try:
51
+ yield
52
+ finally:
53
+ root.removeHandler(handler)
54
+ handler.close()
55
+ sys.stdout, sys.stderr = old_out, old_err