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