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,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
|