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,215 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Agent and MAS run endpoints."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, HTTPException
|
|
14
|
+
|
|
15
|
+
from mas.lab.controller.constants import WEB_SEARCH_CACHE_DIR
|
|
16
|
+
from mas.lab.controller.models import MultiTurnRequest, RunAgentRequest, RunMASRequest
|
|
17
|
+
from mas.lab.controller.routes._api import deps, jobs, LIBRARIES_DIR, validate_pipeline_yaml
|
|
18
|
+
|
|
19
|
+
router = APIRouter()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _resolve_overlay_arg(lib_dir: Path, ov: str) -> str:
|
|
23
|
+
raw = Path(ov)
|
|
24
|
+
if raw.is_file():
|
|
25
|
+
try:
|
|
26
|
+
return str(raw.resolve().relative_to(lib_dir.resolve()))
|
|
27
|
+
except ValueError:
|
|
28
|
+
return str(raw)
|
|
29
|
+
candidate = lib_dir / ov
|
|
30
|
+
if candidate.is_file():
|
|
31
|
+
return str(candidate.relative_to(lib_dir))
|
|
32
|
+
stem = raw.stem
|
|
33
|
+
hits = [
|
|
34
|
+
path
|
|
35
|
+
for path in lib_dir.rglob(f"{stem}.yaml")
|
|
36
|
+
if "overlays" in path.parts and "versions" not in path.parts
|
|
37
|
+
]
|
|
38
|
+
hits.sort(key=lambda path: (0 if path.parent.name == "overlays" else 1, str(path)))
|
|
39
|
+
if hits:
|
|
40
|
+
return str(hits[0].relative_to(lib_dir))
|
|
41
|
+
return ov
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _infra_args(lib_dir: Path, infra_refs: list[str]) -> list[str]:
|
|
45
|
+
args: list[str] = []
|
|
46
|
+
if infra_refs:
|
|
47
|
+
for item in infra_refs:
|
|
48
|
+
path = Path(item)
|
|
49
|
+
resolved = path if path.is_absolute() else (lib_dir / item)
|
|
50
|
+
args += ["--infra-ref", str(resolved)]
|
|
51
|
+
return args
|
|
52
|
+
seen: set[Path] = set()
|
|
53
|
+
for infra_dir in [lib_dir / "infra", lib_dir.parent / "infra"]:
|
|
54
|
+
if infra_dir.is_dir():
|
|
55
|
+
for infra_file in sorted(infra_dir.glob("*.yaml")):
|
|
56
|
+
resolved = infra_file.resolve()
|
|
57
|
+
if resolved not in seen:
|
|
58
|
+
seen.add(resolved)
|
|
59
|
+
args += ["--infra-ref", str(infra_file)]
|
|
60
|
+
return args
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@router.post("/api/libraries/{library_name}/run", tags=["Libraries"], status_code=202)
|
|
64
|
+
async def run_agent(library_name: str, req: RunAgentRequest):
|
|
65
|
+
"""Run an agent with a single query in-process. Returns a job_id for polling."""
|
|
66
|
+
lib_dir = deps.get_library_path(library_name)
|
|
67
|
+
|
|
68
|
+
if req.no_cache and WEB_SEARCH_CACHE_DIR.exists():
|
|
69
|
+
shutil.rmtree(WEB_SEARCH_CACHE_DIR, ignore_errors=True)
|
|
70
|
+
|
|
71
|
+
job = jobs.submit_agent_chat_job(
|
|
72
|
+
endpoint=f"/api/libraries/{library_name}/run",
|
|
73
|
+
manifest_yaml=req.manifest_yaml,
|
|
74
|
+
query=req.query,
|
|
75
|
+
lib_dir=lib_dir,
|
|
76
|
+
flavour=req.flavour,
|
|
77
|
+
session_id=req.session_id,
|
|
78
|
+
timeout=req.timeout,
|
|
79
|
+
request_body={"query": req.query, "flavour": req.flavour},
|
|
80
|
+
)
|
|
81
|
+
return {
|
|
82
|
+
"job_id": job.id,
|
|
83
|
+
"status": job.status.value,
|
|
84
|
+
"session_id": job.session_id,
|
|
85
|
+
"command": job.command,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@router.post("/api/libraries/{library_name}/run/multi-turn", tags=["Libraries"], status_code=202)
|
|
90
|
+
async def run_agent_multi_turn(library_name: str, req: MultiTurnRequest):
|
|
91
|
+
"""Run an agent with multiple sequential queries. Returns a job_id for polling."""
|
|
92
|
+
lib_dir = deps.get_library_path(library_name)
|
|
93
|
+
|
|
94
|
+
tmp = tempfile.NamedTemporaryFile(
|
|
95
|
+
mode="w", suffix=".yaml", prefix="agent-mt-", delete=False, dir=str(lib_dir)
|
|
96
|
+
)
|
|
97
|
+
tmp.write(req.manifest_yaml)
|
|
98
|
+
tmp.close()
|
|
99
|
+
tmp_path = Path(tmp.name)
|
|
100
|
+
|
|
101
|
+
cmd = ["mas-runtime", "run-agent", tmp_path.name]
|
|
102
|
+
if req.verbose:
|
|
103
|
+
cmd.append("-v")
|
|
104
|
+
for ov in req.overlays:
|
|
105
|
+
cmd += ["--overlay", ov]
|
|
106
|
+
if req.flavour:
|
|
107
|
+
cmd += ["--flavour", req.flavour]
|
|
108
|
+
for q in req.queries:
|
|
109
|
+
cmd += ["-q", q]
|
|
110
|
+
job = jobs.submit_job(
|
|
111
|
+
endpoint=f"/api/libraries/{library_name}/run/multi-turn",
|
|
112
|
+
cmd=cmd,
|
|
113
|
+
cwd=lib_dir,
|
|
114
|
+
timeout=req.timeout,
|
|
115
|
+
request_body=req.model_dump(),
|
|
116
|
+
cleanup_paths=[tmp_path],
|
|
117
|
+
)
|
|
118
|
+
return {"job_id": job.id, "status": job.status.value, "command": job.command}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@router.post("/api/libraries/{library_name}/run-mas", tags=["Libraries"], status_code=202)
|
|
122
|
+
async def run_mas(library_name: str, req: RunMASRequest):
|
|
123
|
+
"""Run a MAS with a single query. Returns a job_id for polling."""
|
|
124
|
+
import yaml
|
|
125
|
+
|
|
126
|
+
lib_dir = deps.get_library_path(library_name)
|
|
127
|
+
cleanup_paths: list[Path] = []
|
|
128
|
+
mas_rel: str
|
|
129
|
+
|
|
130
|
+
if req.mas_path:
|
|
131
|
+
mas_file = lib_dir / req.mas_path
|
|
132
|
+
if not mas_file.is_file():
|
|
133
|
+
raise HTTPException(status_code=404, detail=f"MAS not found: {req.mas_path}")
|
|
134
|
+
mas_rel = req.mas_path
|
|
135
|
+
elif req.manifest_yaml.strip():
|
|
136
|
+
manifest_dir = lib_dir
|
|
137
|
+
try:
|
|
138
|
+
manifest_data = yaml.safe_load(req.manifest_yaml)
|
|
139
|
+
mas_name = (manifest_data or {}).get("metadata", {}).get("name", "")
|
|
140
|
+
if mas_name:
|
|
141
|
+
app_dir = lib_dir / "apps" / mas_name
|
|
142
|
+
if app_dir.is_dir():
|
|
143
|
+
manifest_dir = app_dir
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
tmp = tempfile.NamedTemporaryFile(
|
|
147
|
+
mode="w", suffix=".yaml", prefix="mas-run-", delete=False, dir=str(manifest_dir)
|
|
148
|
+
)
|
|
149
|
+
tmp.write(req.manifest_yaml)
|
|
150
|
+
tmp.close()
|
|
151
|
+
tmp_path = Path(tmp.name)
|
|
152
|
+
cleanup_paths.append(tmp_path)
|
|
153
|
+
mas_rel = str(tmp_path.relative_to(lib_dir))
|
|
154
|
+
else:
|
|
155
|
+
raise HTTPException(status_code=400, detail="needs: mas_path or manifest_yaml")
|
|
156
|
+
|
|
157
|
+
cmd = ["mas-ctl"]
|
|
158
|
+
if req.verbose:
|
|
159
|
+
cmd.append("-v")
|
|
160
|
+
cmd += ["run-mas", mas_rel]
|
|
161
|
+
for ov in req.overlays:
|
|
162
|
+
cmd += ["--overlay", _resolve_overlay_arg(lib_dir, ov)]
|
|
163
|
+
if req.flavour:
|
|
164
|
+
cmd += ["--flavour", req.flavour]
|
|
165
|
+
cmd += _infra_args(lib_dir, req.infra_refs)
|
|
166
|
+
cmd += ["-q", req.query]
|
|
167
|
+
env_override: dict[str, str] = {}
|
|
168
|
+
if req.demo_ui_ingest_url:
|
|
169
|
+
env_override["MAS_DEMO_UI_INGEST_URL"] = req.demo_ui_ingest_url
|
|
170
|
+
if req.demo_ui_ref:
|
|
171
|
+
env_override["MAS_DEMO_UI_REF"] = json.dumps(req.demo_ui_ref)
|
|
172
|
+
events_file = Path(tempfile.mkdtemp(prefix="mas-demo-ui-")) / "traces" / "events.jsonl"
|
|
173
|
+
events_file.parent.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
env_override["MAS_EVENTS_FILE"] = str(events_file)
|
|
175
|
+
_run_pre_steps(lib_dir, req.pre)
|
|
176
|
+
job = jobs.submit_job(
|
|
177
|
+
endpoint=f"/api/libraries/{library_name}/run-mas",
|
|
178
|
+
cmd=cmd,
|
|
179
|
+
cwd=lib_dir,
|
|
180
|
+
timeout=req.timeout,
|
|
181
|
+
env_override=env_override or None,
|
|
182
|
+
request_body={
|
|
183
|
+
"query": req.query,
|
|
184
|
+
"flavour": req.flavour,
|
|
185
|
+
"mas_path": req.mas_path,
|
|
186
|
+
"demo_ui_ref": req.demo_ui_ref,
|
|
187
|
+
},
|
|
188
|
+
cleanup_paths=cleanup_paths,
|
|
189
|
+
)
|
|
190
|
+
return {
|
|
191
|
+
"job_id": job.id,
|
|
192
|
+
"status": job.status.value,
|
|
193
|
+
"command": job.command,
|
|
194
|
+
"events_path": str(events_file),
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _run_pre_steps(lib_dir: Path, steps: list[dict]) -> None:
|
|
199
|
+
for step in steps or []:
|
|
200
|
+
kind = str(step.get("type") or "shell")
|
|
201
|
+
command = str(step.get("command") or (step.get("config") or {}).get("command") or "").strip()
|
|
202
|
+
if kind != "shell" or not command:
|
|
203
|
+
continue
|
|
204
|
+
completed = subprocess.run(
|
|
205
|
+
["/bin/sh", "-c", command],
|
|
206
|
+
cwd=str(lib_dir),
|
|
207
|
+
check=False,
|
|
208
|
+
capture_output=True,
|
|
209
|
+
text=True,
|
|
210
|
+
)
|
|
211
|
+
if completed.returncode != 0:
|
|
212
|
+
raise HTTPException(
|
|
213
|
+
status_code=400,
|
|
214
|
+
detail=f"pre shell failed ({completed.returncode}): {command}\n{completed.stderr}",
|
|
215
|
+
)
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Start, list, stream, and delete runs. Bodies carry item ids, never paths."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import shutil
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
15
|
+
from fastapi.responses import StreamingResponse
|
|
16
|
+
from mas.lab.controller import run_bridge
|
|
17
|
+
from mas.lab.controller.api import peek_controller_api
|
|
18
|
+
from mas.lab.controller.artifacts import get_artifact_registry
|
|
19
|
+
from mas.lab.controller.constants import MAX_TIMEOUT
|
|
20
|
+
from mas.lab.controller.deps import store_from_request
|
|
21
|
+
from mas.lab.controller.item_service import ItemError, get_item
|
|
22
|
+
from mas.lab.controller.kinds import KIND_CATALOG
|
|
23
|
+
from mas.lab.controller.run_store import _now, get_run_store, new_run
|
|
24
|
+
from pydantic import BaseModel, Field
|
|
25
|
+
|
|
26
|
+
router = APIRouter()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_RUN_ACTIONS = {"run", "analyze", "import", "export"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class StartRunRequest(BaseModel):
|
|
33
|
+
scope: str
|
|
34
|
+
kind: str
|
|
35
|
+
item: str
|
|
36
|
+
overlays: list[str] = Field(default_factory=list)
|
|
37
|
+
bind: Optional[str] = None
|
|
38
|
+
query: Optional[str] = None
|
|
39
|
+
manifest: Optional[str] = None
|
|
40
|
+
action: str = "run"
|
|
41
|
+
timeout: int = Field(default=MAX_TIMEOUT, ge=1, le=MAX_TIMEOUT)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@router.post("/api/runs", tags=["Runs"], status_code=202)
|
|
45
|
+
async def start_run(req: StartRunRequest, request: Request):
|
|
46
|
+
store = store_from_request(request)
|
|
47
|
+
action = (req.action or "run").lower()
|
|
48
|
+
if action not in _RUN_ACTIONS:
|
|
49
|
+
raise HTTPException(status_code=400, detail=f"unknown run action {req.action!r}")
|
|
50
|
+
if action == "run" and not KIND_CATALOG.get(req.kind, {}).get("runnable"):
|
|
51
|
+
raise HTTPException(status_code=400, detail=f"cannot run kind {req.kind!r}")
|
|
52
|
+
cwd = _cwd_for(req.scope, store)
|
|
53
|
+
if action == "run" and not req.manifest:
|
|
54
|
+
try:
|
|
55
|
+
get_item(req.scope, req.kind, req.item, store=store)
|
|
56
|
+
except ItemError as exc:
|
|
57
|
+
raise HTTPException(status_code=exc.status, detail=exc.detail) from exc
|
|
58
|
+
run = new_run(
|
|
59
|
+
scope=req.scope,
|
|
60
|
+
kind=req.kind,
|
|
61
|
+
item=req.item,
|
|
62
|
+
request_summary={
|
|
63
|
+
"overlays": req.overlays,
|
|
64
|
+
"bind": req.bind,
|
|
65
|
+
"query": req.query,
|
|
66
|
+
"action": action,
|
|
67
|
+
"has_inline_manifest": bool(req.manifest),
|
|
68
|
+
"source": "ui",
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
runs = get_run_store()
|
|
72
|
+
run.status = "running"
|
|
73
|
+
run.started_at = _now()
|
|
74
|
+
if req.query:
|
|
75
|
+
run.log_lines.append(req.query)
|
|
76
|
+
runs.save(run)
|
|
77
|
+
|
|
78
|
+
api = peek_controller_api()
|
|
79
|
+
if api is not None and action == "run":
|
|
80
|
+
path, cleanup = _item_path(req, store)
|
|
81
|
+
submitted = run_bridge.submit_run(api, req, path, cwd)
|
|
82
|
+
worker_id = submitted.get("worker_id") or submitted.get("job_id")
|
|
83
|
+
run.job_id = worker_id
|
|
84
|
+
detail = api.get_worker(worker_id) if worker_id else None
|
|
85
|
+
if detail:
|
|
86
|
+
run_bridge.apply_worker_to_run(run, detail, cwd=cwd)
|
|
87
|
+
if run.status not in {"pending", "running"}:
|
|
88
|
+
_attach_artifacts(run, cwd)
|
|
89
|
+
status = (detail or submitted).get("status")
|
|
90
|
+
if str(status) in run_bridge._TERMINAL:
|
|
91
|
+
for leftover in cleanup:
|
|
92
|
+
shutil.rmtree(leftover, ignore_errors=True)
|
|
93
|
+
else:
|
|
94
|
+
run_bridge.register_cleanup(run.id, cleanup)
|
|
95
|
+
runs.save(run)
|
|
96
|
+
return {"id": run.id, "status": run.status, "job_id": run.job_id}
|
|
97
|
+
|
|
98
|
+
cmd, cleanup = _command_for_run(req, store, cwd=cwd)
|
|
99
|
+
from mas.lab.controller import jobs as jobs_mod
|
|
100
|
+
|
|
101
|
+
job = jobs_mod.submit_job(
|
|
102
|
+
endpoint="/api/runs",
|
|
103
|
+
cmd=cmd,
|
|
104
|
+
cwd=cwd,
|
|
105
|
+
timeout=req.timeout,
|
|
106
|
+
request_body=req.model_dump(),
|
|
107
|
+
cleanup_paths=cleanup or None,
|
|
108
|
+
)
|
|
109
|
+
run.job_id = job.id
|
|
110
|
+
if job.status in jobs_mod.TERMINAL_STATUSES:
|
|
111
|
+
_apply_job_to_run(run, job, cwd=cwd)
|
|
112
|
+
else:
|
|
113
|
+
asyncio.create_task(_follow_job(run.id, job.id, cwd))
|
|
114
|
+
runs.save(run)
|
|
115
|
+
|
|
116
|
+
return {"id": run.id, "status": run.status, "job_id": job.id}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _item_path(req: StartRunRequest, store) -> tuple[Path, list[Path]]:
|
|
120
|
+
cleanup: list[Path] = []
|
|
121
|
+
if req.manifest:
|
|
122
|
+
tmp_dir = Path(tempfile.mkdtemp(prefix="mas-run-"))
|
|
123
|
+
path = tmp_dir / f"{req.kind}.yaml"
|
|
124
|
+
path.write_text(req.manifest, encoding="utf-8")
|
|
125
|
+
cleanup.append(tmp_dir)
|
|
126
|
+
return path, cleanup
|
|
127
|
+
from mas.lab.controller.manifest_layout import layout_for
|
|
128
|
+
from mas.lab.controller.scope_catalog import get_scope_catalog
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
scope = get_scope_catalog(store).get(req.scope)
|
|
132
|
+
except KeyError as exc:
|
|
133
|
+
raise HTTPException(status_code=404, detail=f"scope {req.scope!r} not found") from exc
|
|
134
|
+
located = layout_for(req.kind).locate(scope, req.item)
|
|
135
|
+
if located is None or not located.manifest_path.is_file():
|
|
136
|
+
raise HTTPException(status_code=404, detail=f"{req.kind} {req.item!r} not found")
|
|
137
|
+
return located.manifest_path, cleanup
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _command_for_run(req: StartRunRequest, store, *, cwd: Path) -> tuple[list[str], list[Path]]:
|
|
141
|
+
"""Build the mas-ctl / mas-lab argv for a started run."""
|
|
142
|
+
action = (req.action or "run").lower()
|
|
143
|
+
if action in {"analyze", "export"} and ("/" in req.item or "\\" in req.item or ".." in req.item):
|
|
144
|
+
raise HTTPException(status_code=400, detail="item must be an id, not a path")
|
|
145
|
+
if action == "analyze":
|
|
146
|
+
return ["mas-lab", "benchmark", "analyze", req.item], []
|
|
147
|
+
if action == "import":
|
|
148
|
+
from mas.lab.controller.uploads import get_upload_store
|
|
149
|
+
|
|
150
|
+
uploaded = get_upload_store().get(req.item)
|
|
151
|
+
if uploaded is None:
|
|
152
|
+
raise HTTPException(status_code=404, detail=f"upload {req.item!r} not found")
|
|
153
|
+
return ["mas-lab", "benchmark", "import", str(uploaded.path)], []
|
|
154
|
+
if action == "export":
|
|
155
|
+
out_dir = cwd / "output"
|
|
156
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
out_path = out_dir / f"{Path(req.item).name}.tar.gz"
|
|
158
|
+
return ["mas-lab", "benchmark", "export", req.item, "--output", str(out_path)], []
|
|
159
|
+
path, cleanup = _item_path(req, store)
|
|
160
|
+
if req.kind == "experiment":
|
|
161
|
+
return ["mas-lab", "benchmark", "run", str(path)], cleanup
|
|
162
|
+
if req.kind == "pipeline":
|
|
163
|
+
return ["mas-lab", "benchmark", "pipeline", "run", str(path)], cleanup
|
|
164
|
+
cmd = ["mas-ctl", "run-mas", str(path)]
|
|
165
|
+
if req.query:
|
|
166
|
+
cmd.extend(["-q", req.query])
|
|
167
|
+
for overlay in req.overlays:
|
|
168
|
+
cmd.extend(["--overlay", overlay])
|
|
169
|
+
return cmd, cleanup
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _apply_job_to_run(run, job, *, cwd: Path | None = None) -> None:
|
|
173
|
+
status = job.status.value if hasattr(job.status, "value") else str(job.status)
|
|
174
|
+
run.status = {
|
|
175
|
+
"pending": "pending",
|
|
176
|
+
"running": "running",
|
|
177
|
+
"completed": "completed",
|
|
178
|
+
"failed": "failed",
|
|
179
|
+
"cancelled": "failed",
|
|
180
|
+
"timeout": "failed",
|
|
181
|
+
"interrupted": "interrupted",
|
|
182
|
+
}.get(status, status)
|
|
183
|
+
run.exit_code = job.exit_code
|
|
184
|
+
if job.started_at:
|
|
185
|
+
run.started_at = job.started_at
|
|
186
|
+
run.finished_at = job.finished_at
|
|
187
|
+
seen = set(run.log_lines)
|
|
188
|
+
for chunk in (job.stdout, job.stderr, job.error):
|
|
189
|
+
text = (chunk or "").rstrip()
|
|
190
|
+
if text and text not in seen:
|
|
191
|
+
run.log_lines.append(text)
|
|
192
|
+
seen.add(text)
|
|
193
|
+
if run.status not in {"pending", "running"}:
|
|
194
|
+
_attach_artifacts(run, cwd)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _attach_artifacts(run, cwd: Path | None) -> None:
|
|
198
|
+
registry = get_artifact_registry()
|
|
199
|
+
if registry.for_run(run.id):
|
|
200
|
+
return
|
|
201
|
+
root = None
|
|
202
|
+
if run.result_dir:
|
|
203
|
+
candidate = Path(run.result_dir)
|
|
204
|
+
if candidate.is_dir():
|
|
205
|
+
root = candidate
|
|
206
|
+
if root is None and cwd is not None:
|
|
207
|
+
for name in ("output", "results"):
|
|
208
|
+
candidate = cwd / name
|
|
209
|
+
if candidate.is_dir():
|
|
210
|
+
root = candidate
|
|
211
|
+
break
|
|
212
|
+
if root is None:
|
|
213
|
+
return
|
|
214
|
+
registry.register_tree(run.id, root)
|
|
215
|
+
run.result_dir = str(root)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def _follow_job(run_id: str, job_id: str, cwd: Path) -> None:
|
|
219
|
+
from mas.lab.controller import jobs as jobs_mod
|
|
220
|
+
|
|
221
|
+
store = get_run_store()
|
|
222
|
+
while True:
|
|
223
|
+
job = jobs_mod.get_job(job_id)
|
|
224
|
+
run = store.get(run_id)
|
|
225
|
+
if run is None or job is None:
|
|
226
|
+
return
|
|
227
|
+
_apply_job_to_run(run, job, cwd=cwd)
|
|
228
|
+
store.save(run)
|
|
229
|
+
if job.status in jobs_mod.TERMINAL_STATUSES:
|
|
230
|
+
return
|
|
231
|
+
await asyncio.sleep(0.2)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _lookup_run(run_id: str):
|
|
235
|
+
store = get_run_store()
|
|
236
|
+
return store.get(run_id) or next((row for row in store.list() if row.job_id == run_id), None)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _cwd_for(scope_id: str, store) -> Path:
|
|
240
|
+
from mas.lab.controller.scope_catalog import get_scope_catalog
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
return get_scope_catalog(store).get(scope_id).root
|
|
244
|
+
except KeyError as exc:
|
|
245
|
+
raise HTTPException(status_code=404, detail=f"scope {scope_id!r} not found") from exc
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _adopt_labs_dir_runs(store) -> None:
|
|
249
|
+
"""Expose ``paths.labs_dir`` / XDG experiment trees as completed runs.
|
|
250
|
+
|
|
251
|
+
``mas-lab benchmark import`` and ``task fetch-results`` land here, not in
|
|
252
|
+
the controller run store. The experiment page lists runs by ``item``
|
|
253
|
+
name, so a matching folder becomes a result tree the UI can open.
|
|
254
|
+
"""
|
|
255
|
+
try:
|
|
256
|
+
from mas.lab.paths import labs_root
|
|
257
|
+
|
|
258
|
+
root = labs_root()
|
|
259
|
+
except Exception:
|
|
260
|
+
return
|
|
261
|
+
if not root.is_dir():
|
|
262
|
+
return
|
|
263
|
+
from mas.lab.controller.manifest_layout import layout_for
|
|
264
|
+
from mas.lab.controller.scope_catalog import get_scope_catalog
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
layout = layout_for("experiment")
|
|
268
|
+
catalog = get_scope_catalog(store)
|
|
269
|
+
except Exception:
|
|
270
|
+
return
|
|
271
|
+
runs = get_run_store()
|
|
272
|
+
known: set[tuple[str, str, str]] = set()
|
|
273
|
+
for row in runs.list():
|
|
274
|
+
if not row.result_dir:
|
|
275
|
+
continue
|
|
276
|
+
try:
|
|
277
|
+
known.add((row.scope, row.item, str(Path(row.result_dir).resolve())))
|
|
278
|
+
except OSError:
|
|
279
|
+
continue
|
|
280
|
+
for scope in catalog.all():
|
|
281
|
+
try:
|
|
282
|
+
located = layout.list_located(scope)
|
|
283
|
+
except Exception:
|
|
284
|
+
continue
|
|
285
|
+
for item in located:
|
|
286
|
+
out = root / item.item_id
|
|
287
|
+
if not out.is_dir():
|
|
288
|
+
continue
|
|
289
|
+
try:
|
|
290
|
+
resolved = str(out.resolve())
|
|
291
|
+
except OSError:
|
|
292
|
+
continue
|
|
293
|
+
if (scope.id, item.item_id, resolved) in known:
|
|
294
|
+
continue
|
|
295
|
+
run = new_run(
|
|
296
|
+
scope=scope.id,
|
|
297
|
+
kind="experiment",
|
|
298
|
+
item=item.item_id,
|
|
299
|
+
request_summary={"source": "labs_dir", "action": "import"},
|
|
300
|
+
)
|
|
301
|
+
run.id = f"labsdir-{scope.id.replace(':', '-')}-{item.item_id}"
|
|
302
|
+
run.status = "completed"
|
|
303
|
+
run.result_dir = resolved
|
|
304
|
+
runs.save(run)
|
|
305
|
+
_attach_artifacts(run, out)
|
|
306
|
+
runs.save(run)
|
|
307
|
+
known.add((scope.id, item.item_id, resolved))
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _sync_runs(store) -> None:
|
|
311
|
+
run_bridge.sync_workers(store)
|
|
312
|
+
_adopt_labs_dir_runs(store)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@router.get("/api/runs", tags=["Runs"])
|
|
316
|
+
async def list_runs(request: Request):
|
|
317
|
+
_sync_runs(store_from_request(request))
|
|
318
|
+
return {"runs": [r.to_public() for r in get_run_store().list()]}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@router.get("/api/runs/{run_id}", tags=["Runs"])
|
|
322
|
+
async def get_run(run_id: str, request: Request):
|
|
323
|
+
_sync_runs(store_from_request(request))
|
|
324
|
+
run = _lookup_run(run_id)
|
|
325
|
+
if run is None:
|
|
326
|
+
raise HTTPException(status_code=404, detail=f"run {run_id} not found")
|
|
327
|
+
run_bridge.refresh_run(run)
|
|
328
|
+
get_run_store().save(run)
|
|
329
|
+
return run.to_public()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@router.get("/api/runs/{run_id}/events", tags=["Runs"])
|
|
333
|
+
async def run_events(run_id: str, request: Request):
|
|
334
|
+
_sync_runs(store_from_request(request))
|
|
335
|
+
run = _lookup_run(run_id)
|
|
336
|
+
if run is None:
|
|
337
|
+
raise HTTPException(status_code=404, detail=f"run {run_id} not found")
|
|
338
|
+
tracked_id = run.id
|
|
339
|
+
|
|
340
|
+
async def gen():
|
|
341
|
+
last = 0
|
|
342
|
+
while True:
|
|
343
|
+
current = get_run_store().get(tracked_id)
|
|
344
|
+
if current is None:
|
|
345
|
+
break
|
|
346
|
+
run_bridge.refresh_run(current)
|
|
347
|
+
get_run_store().save(current)
|
|
348
|
+
lines = current.log_lines[last:]
|
|
349
|
+
for line in lines:
|
|
350
|
+
yield f"event: log\ndata: {json.dumps({'line': line})}\n\n"
|
|
351
|
+
last += len(lines)
|
|
352
|
+
yield f"event: status\ndata: {json.dumps({'status': current.status})}\n\n"
|
|
353
|
+
if current.status not in {"pending", "running"}:
|
|
354
|
+
yield f"event: done\ndata: {json.dumps({'status': current.status})}\n\n"
|
|
355
|
+
break
|
|
356
|
+
import asyncio
|
|
357
|
+
|
|
358
|
+
await asyncio.sleep(0.2)
|
|
359
|
+
|
|
360
|
+
return StreamingResponse(gen(), media_type="text/event-stream")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@router.get("/api/runs/{run_id}/artifacts", tags=["Runs"])
|
|
364
|
+
async def run_artifacts(run_id: str, request: Request):
|
|
365
|
+
_sync_runs(store_from_request(request))
|
|
366
|
+
run = _lookup_run(run_id)
|
|
367
|
+
if run is None:
|
|
368
|
+
raise HTTPException(status_code=404, detail=f"run {run_id} not found")
|
|
369
|
+
arts = get_artifact_registry().for_run(run.id)
|
|
370
|
+
if not arts:
|
|
371
|
+
cwd = None
|
|
372
|
+
try:
|
|
373
|
+
cwd = _cwd_for(run.scope, None)
|
|
374
|
+
except HTTPException:
|
|
375
|
+
cwd = Path(run.result_dir) if run.result_dir else None
|
|
376
|
+
_attach_artifacts(run, cwd)
|
|
377
|
+
get_run_store().save(run)
|
|
378
|
+
arts = get_artifact_registry().for_run(run.id)
|
|
379
|
+
return {"artifacts": [a.public_dict() for a in arts]}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@router.delete("/api/runs/{run_id}", tags=["Runs"])
|
|
383
|
+
async def delete_run(run_id: str):
|
|
384
|
+
store = get_run_store()
|
|
385
|
+
run = _lookup_run(run_id)
|
|
386
|
+
if run is None:
|
|
387
|
+
raise HTTPException(status_code=404, detail=f"run {run_id} not found")
|
|
388
|
+
if run.job_id:
|
|
389
|
+
run_bridge.forget_worker(run.job_id)
|
|
390
|
+
get_artifact_registry().drop_run(run.id)
|
|
391
|
+
store.delete(run.id)
|
|
392
|
+
return {"ok": True}
|