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,139 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Scope listing — libraries and labs as first-class catalogs."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
10
|
+
from mas.lab.controller.deps import store_from_request
|
|
11
|
+
from mas.lab.controller.item_service import ItemError, group_items
|
|
12
|
+
from mas.lab.controller.kinds import LAB_KINDS, LIBRARY_BASE_KINDS
|
|
13
|
+
from mas.lab.controller.scope_catalog import get_scope_catalog
|
|
14
|
+
from mas.lab.controller.scope_service import create_lab
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
router = APIRouter()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CreateScopeRequest(BaseModel):
|
|
21
|
+
kind: str = "lab"
|
|
22
|
+
name: str
|
|
23
|
+
description: Optional[str] = ""
|
|
24
|
+
local_library: bool = Field(default=True)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.get("/api/scopes", tags=["Scopes"])
|
|
28
|
+
async def list_scopes(request: Request):
|
|
29
|
+
catalog = get_scope_catalog(store_from_request(request))
|
|
30
|
+
return {"scopes": [_public_scope(scope, counts=True) for scope in catalog.all()]}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.post("/api/scopes", tags=["Scopes"], status_code=201)
|
|
34
|
+
async def create_scope(req: CreateScopeRequest, request: Request):
|
|
35
|
+
if req.kind != "lab":
|
|
36
|
+
raise HTTPException(status_code=400, detail=f"cannot create scope kind {req.kind!r}")
|
|
37
|
+
store = store_from_request(request)
|
|
38
|
+
try:
|
|
39
|
+
scope = create_lab(
|
|
40
|
+
name=req.name,
|
|
41
|
+
description=req.description or "",
|
|
42
|
+
local_library=req.local_library,
|
|
43
|
+
store=store,
|
|
44
|
+
)
|
|
45
|
+
except ItemError as exc:
|
|
46
|
+
raise HTTPException(status_code=exc.status, detail=exc.detail) from exc
|
|
47
|
+
return _public_scope(scope, counts=True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@router.get("/api/scopes/{scope_id}", tags=["Scopes"])
|
|
51
|
+
async def get_scope(scope_id: str, request: Request):
|
|
52
|
+
catalog = get_scope_catalog(store_from_request(request))
|
|
53
|
+
try:
|
|
54
|
+
scope = catalog.get(scope_id)
|
|
55
|
+
except KeyError as exc:
|
|
56
|
+
raise HTTPException(status_code=404, detail=f"scope {scope_id!r} not found") from exc
|
|
57
|
+
return _public_scope(scope, counts=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@router.get("/api/scopes/{scope_id}/config", tags=["Scopes"])
|
|
61
|
+
async def scope_config(scope_id: str, request: Request):
|
|
62
|
+
"""Infra / flavour / workspace YAML for the control panel (basename keys only)."""
|
|
63
|
+
catalog = get_scope_catalog(store_from_request(request))
|
|
64
|
+
try:
|
|
65
|
+
scope = catalog.get(scope_id)
|
|
66
|
+
except KeyError as exc:
|
|
67
|
+
raise HTTPException(status_code=404, detail=f"scope {scope_id!r} not found") from exc
|
|
68
|
+
return _public_config(scope)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@router.get("/api/scopes/{scope_id}/visible-libraries", tags=["Scopes"])
|
|
72
|
+
async def visible_libraries(scope_id: str, request: Request):
|
|
73
|
+
catalog = get_scope_catalog(store_from_request(request))
|
|
74
|
+
try:
|
|
75
|
+
libraries = catalog.visible_libraries(scope_id)
|
|
76
|
+
except KeyError as exc:
|
|
77
|
+
raise HTTPException(status_code=404, detail=f"scope {scope_id!r} not found") from exc
|
|
78
|
+
return {"libraries": [s.to_public() for s in libraries]}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _public_scope(scope, *, counts: bool = False) -> dict:
|
|
82
|
+
payload = scope.to_public()
|
|
83
|
+
payload["kinds"] = _kinds_for(scope)
|
|
84
|
+
if not counts:
|
|
85
|
+
return payload
|
|
86
|
+
tallies: dict[str, int] = {}
|
|
87
|
+
for kind in payload["kinds"]:
|
|
88
|
+
try:
|
|
89
|
+
tallies[kind] = len(group_items(scope, kind))
|
|
90
|
+
except KeyError:
|
|
91
|
+
tallies[kind] = 0
|
|
92
|
+
payload["counts"] = tallies
|
|
93
|
+
return payload
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _kinds_for(scope) -> list[str]:
|
|
97
|
+
if scope.kinds:
|
|
98
|
+
kinds = list(scope.kinds)
|
|
99
|
+
elif scope.kind == "lab":
|
|
100
|
+
kinds = list(LAB_KINDS)
|
|
101
|
+
else:
|
|
102
|
+
kinds = list(LIBRARY_BASE_KINDS)
|
|
103
|
+
if scope.kind != "library":
|
|
104
|
+
return kinds
|
|
105
|
+
# Advertise experiments/pipelines when the library actually has them.
|
|
106
|
+
from mas.lab.controller.manifest_layout import layout_for
|
|
107
|
+
|
|
108
|
+
for extra in ("experiment", "pipeline"):
|
|
109
|
+
if extra in kinds:
|
|
110
|
+
continue
|
|
111
|
+
try:
|
|
112
|
+
if layout_for(extra).list_located(scope):
|
|
113
|
+
kinds.append(extra)
|
|
114
|
+
except Exception:
|
|
115
|
+
continue
|
|
116
|
+
return kinds
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _public_config(scope) -> dict[str, dict[str, str]]:
|
|
120
|
+
from pathlib import Path
|
|
121
|
+
|
|
122
|
+
from mas.lab.controller.lab_registry import LabRegistry
|
|
123
|
+
from mas.runtime.constants import WORKSPACE_CONFIG_FILENAME
|
|
124
|
+
|
|
125
|
+
groups: dict[str, dict[str, str]] = {"infra": {}, "flavours": {}, "workspace": {}}
|
|
126
|
+
root = scope.root
|
|
127
|
+
for group, folder_name in (("infra", "infra"), ("flavours", "flavours")):
|
|
128
|
+
folder = root / folder_name
|
|
129
|
+
if not folder.is_dir():
|
|
130
|
+
continue
|
|
131
|
+
for path in sorted(folder.iterdir()):
|
|
132
|
+
if path.is_file() and path.suffix in {".yaml", ".yml"}:
|
|
133
|
+
groups[group][path.name] = path.read_text(encoding="utf-8")
|
|
134
|
+
ws = root / WORKSPACE_CONFIG_FILENAME
|
|
135
|
+
if ws.is_file():
|
|
136
|
+
groups["workspace"][ws.name] = ws.read_text(encoding="utf-8")
|
|
137
|
+
for key, content in LabRegistry.discover_bundled_infra().items():
|
|
138
|
+
groups["infra"].setdefault(Path(key).name, content)
|
|
139
|
+
return groups
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Upload files and get an opaque upload_id back."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, File, HTTPException, UploadFile
|
|
10
|
+
from mas.lab.controller.uploads import get_upload_store
|
|
11
|
+
|
|
12
|
+
router = APIRouter()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@router.post("/api/uploads", tags=["Uploads"], status_code=201)
|
|
16
|
+
async def upload(file: Annotated[UploadFile, File()]):
|
|
17
|
+
data = await file.read()
|
|
18
|
+
if not data:
|
|
19
|
+
raise HTTPException(status_code=400, detail="empty upload")
|
|
20
|
+
try:
|
|
21
|
+
item = get_upload_store().save(file.filename or "upload", data)
|
|
22
|
+
except ValueError as exc:
|
|
23
|
+
raise HTTPException(status_code=413, detail=str(exc)) from exc
|
|
24
|
+
return item.public_dict()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Manifest validation endpoints."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
from fastapi import APIRouter
|
|
9
|
+
|
|
10
|
+
from mas.lab.controller.manifest_validation import validate_manifest_yaml_content
|
|
11
|
+
from mas.lab.controller.models import ValidateRequest
|
|
12
|
+
from mas.lab.controller.routes._api import deps
|
|
13
|
+
|
|
14
|
+
router = APIRouter()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@router.post("/api/libraries/{library_name}/validate", tags=["Libraries"])
|
|
18
|
+
async def validate_manifest(library_name: str, req: ValidateRequest):
|
|
19
|
+
"""Validate an agent or MAS manifest in-process. Resolves refs relative to the library dir."""
|
|
20
|
+
lib_dir = deps.get_library_path(library_name)
|
|
21
|
+
# Unsaved drafts use __MAS_NAME__ placeholder — skip file-based ref
|
|
22
|
+
# resolution since the agent files don't exist on disk yet.
|
|
23
|
+
resolve = "__MAS_NAME__" not in req.manifest_yaml
|
|
24
|
+
|
|
25
|
+
base_dir = lib_dir
|
|
26
|
+
try:
|
|
27
|
+
data = yaml.safe_load(req.manifest_yaml)
|
|
28
|
+
kind = (data.get("kind") or "").lower() if isinstance(data, dict) else ""
|
|
29
|
+
mas_name = data.get("metadata", {}).get("name", "") if isinstance(data, dict) else ""
|
|
30
|
+
if mas_name:
|
|
31
|
+
app_dir = lib_dir / "apps" / mas_name
|
|
32
|
+
base_dir = app_dir / "agents" if kind == "agent" else app_dir
|
|
33
|
+
except Exception:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
return validate_manifest_yaml_content(req.manifest_yaml, base_dir=base_dir, resolve_refs=resolve)
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Mirror controller workers into the HTTP run store so CLI and UI share one catalog."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import shutil
|
|
9
|
+
import threading
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from mas.lab.controller.api import peek_controller_api
|
|
14
|
+
from mas.lab.controller.run_store import _now, get_run_store, new_run
|
|
15
|
+
|
|
16
|
+
_TERMINAL = {"completed", "failed", "cancelled"}
|
|
17
|
+
_dropped: set[str] = set()
|
|
18
|
+
_cleanup_by_run: dict[str, list[Path]] = {}
|
|
19
|
+
_lock = threading.RLock()
|
|
20
|
+
|
|
21
|
+
_WORKER_KIND_TO_ITEM = {
|
|
22
|
+
"benchmark": "experiment",
|
|
23
|
+
"experiment": "experiment",
|
|
24
|
+
"pipeline": "pipeline",
|
|
25
|
+
"application": "application",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def submit_run(api, req, path: Path, cwd: Path) -> dict[str, Any]:
|
|
30
|
+
"""Submit the same worker the CLI uses for this kind."""
|
|
31
|
+
ref = {
|
|
32
|
+
"origin": "ui",
|
|
33
|
+
"scope": req.scope,
|
|
34
|
+
"kind": req.kind,
|
|
35
|
+
"item": req.item,
|
|
36
|
+
}
|
|
37
|
+
common = {
|
|
38
|
+
"cwd": str(cwd),
|
|
39
|
+
"scope": req.scope,
|
|
40
|
+
"item": req.item,
|
|
41
|
+
"demo_ui_ref": ref,
|
|
42
|
+
}
|
|
43
|
+
if req.kind == "experiment":
|
|
44
|
+
spec = {
|
|
45
|
+
**common,
|
|
46
|
+
"experiment_yaml": str(path),
|
|
47
|
+
"progress": True,
|
|
48
|
+
}
|
|
49
|
+
return api.submit_benchmark(spec)
|
|
50
|
+
if req.kind == "pipeline":
|
|
51
|
+
spec = {**common, "pipeline_yaml": str(path)}
|
|
52
|
+
return api.submit_pipeline(spec)
|
|
53
|
+
spec = {
|
|
54
|
+
**common,
|
|
55
|
+
"spec_path": str(path),
|
|
56
|
+
"prompt": req.query or "",
|
|
57
|
+
"flavour": req.bind,
|
|
58
|
+
"config": {"overlays": list(req.overlays or [])},
|
|
59
|
+
"command": f"mas-ctl run-mas {path}",
|
|
60
|
+
}
|
|
61
|
+
return api.submit_application(spec)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def apply_worker_to_run(run, detail: dict[str, Any], *, cwd: Path | None = None) -> None: # noqa: ARG001
|
|
65
|
+
status = str(detail.get("status") or "running")
|
|
66
|
+
run.status = {
|
|
67
|
+
"pending": "pending",
|
|
68
|
+
"running": "running",
|
|
69
|
+
"completed": "completed",
|
|
70
|
+
"failed": "failed",
|
|
71
|
+
"cancelled": "failed",
|
|
72
|
+
}.get(status, status)
|
|
73
|
+
run.exit_code = detail.get("exit_code")
|
|
74
|
+
if detail.get("started_at"):
|
|
75
|
+
run.started_at = detail["started_at"]
|
|
76
|
+
elif run.status == "running" and not run.started_at:
|
|
77
|
+
run.started_at = _now()
|
|
78
|
+
run.finished_at = detail.get("finished_at")
|
|
79
|
+
lines: list[str] = []
|
|
80
|
+
query = (run.request_summary or {}).get("query")
|
|
81
|
+
if query:
|
|
82
|
+
lines.append(str(query))
|
|
83
|
+
stdout = (detail.get("stdout") or "").rstrip()
|
|
84
|
+
stderr = (detail.get("stderr") or "").rstrip()
|
|
85
|
+
error = (detail.get("error") or "").rstrip()
|
|
86
|
+
if stdout:
|
|
87
|
+
lines.extend(stdout.splitlines())
|
|
88
|
+
if stderr:
|
|
89
|
+
lines.extend(stderr.splitlines())
|
|
90
|
+
if error:
|
|
91
|
+
lines.append(error)
|
|
92
|
+
progress = progress_line(detail.get("request_body") or {})
|
|
93
|
+
if progress:
|
|
94
|
+
lines = [line for line in lines if not line.startswith("ok=")]
|
|
95
|
+
lines.append(progress)
|
|
96
|
+
run.log_lines = lines
|
|
97
|
+
spec = detail.get("request_body") or {}
|
|
98
|
+
output_dir = spec.get("output_dir")
|
|
99
|
+
if output_dir:
|
|
100
|
+
run.result_dir = str(output_dir)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def progress_line(spec: dict[str, Any]) -> str | None:
|
|
104
|
+
"""Same tally ``mas-lab benchmark follow`` prints from ``run_info.json``."""
|
|
105
|
+
out = spec.get("output_dir")
|
|
106
|
+
if not out:
|
|
107
|
+
yaml_path = spec.get("experiment_yaml")
|
|
108
|
+
if yaml_path:
|
|
109
|
+
out = str(Path(yaml_path).parent / "output")
|
|
110
|
+
if not out:
|
|
111
|
+
return None
|
|
112
|
+
root = Path(out)
|
|
113
|
+
if not root.is_dir():
|
|
114
|
+
return None
|
|
115
|
+
counts: dict[str, int] = {}
|
|
116
|
+
for path in root.rglob("run_info.json"):
|
|
117
|
+
try:
|
|
118
|
+
info = json.loads(path.read_text(encoding="utf-8"))
|
|
119
|
+
status = str(info.get("status", "unknown"))
|
|
120
|
+
except Exception:
|
|
121
|
+
status = "unknown"
|
|
122
|
+
counts[status] = counts.get(status, 0) + 1
|
|
123
|
+
if not counts:
|
|
124
|
+
return None
|
|
125
|
+
done = counts.get("ok", 0)
|
|
126
|
+
err = counts.get("error", 0)
|
|
127
|
+
return f"ok={done} err={err} done={done + err}"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def locate_scope_item(path: Path, store, kind: str) -> tuple[str, str]:
|
|
131
|
+
from mas.lab.controller.manifest_layout import layout_for
|
|
132
|
+
from mas.lab.controller.scope_catalog import get_scope_catalog
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
resolved = path.resolve()
|
|
136
|
+
except OSError:
|
|
137
|
+
resolved = path
|
|
138
|
+
try:
|
|
139
|
+
catalog = get_scope_catalog(store)
|
|
140
|
+
layout = layout_for(kind)
|
|
141
|
+
except Exception:
|
|
142
|
+
return "", path.stem
|
|
143
|
+
for scope in catalog.all():
|
|
144
|
+
if kind not in (scope.kinds or []):
|
|
145
|
+
continue
|
|
146
|
+
try:
|
|
147
|
+
located = layout.list_located(scope)
|
|
148
|
+
except Exception:
|
|
149
|
+
continue
|
|
150
|
+
for item in located:
|
|
151
|
+
try:
|
|
152
|
+
if item.manifest_path.resolve() == resolved:
|
|
153
|
+
return scope.id, item.item_id
|
|
154
|
+
except OSError:
|
|
155
|
+
continue
|
|
156
|
+
try:
|
|
157
|
+
resolved.relative_to(scope.root.resolve())
|
|
158
|
+
return scope.id, path.stem
|
|
159
|
+
except ValueError:
|
|
160
|
+
continue
|
|
161
|
+
return "", path.stem
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def run_from_worker(detail: dict[str, Any], store) -> Any:
|
|
165
|
+
spec = detail.get("request_body") or {}
|
|
166
|
+
worker_kind = str(detail.get("kind") or spec.get("kind") or "")
|
|
167
|
+
kind = _WORKER_KIND_TO_ITEM.get(worker_kind, worker_kind or "experiment")
|
|
168
|
+
scope = str(spec.get("scope") or "")
|
|
169
|
+
item = str(spec.get("item") or "")
|
|
170
|
+
ref = spec.get("demo_ui_ref") if isinstance(spec.get("demo_ui_ref"), dict) else {}
|
|
171
|
+
if not scope:
|
|
172
|
+
scope = str(ref.get("scope") or "")
|
|
173
|
+
if not item:
|
|
174
|
+
item = str(ref.get("item") or "")
|
|
175
|
+
if ref.get("kind"):
|
|
176
|
+
kind = str(ref["kind"])
|
|
177
|
+
path_key = {
|
|
178
|
+
"experiment": "experiment_yaml",
|
|
179
|
+
"pipeline": "pipeline_yaml",
|
|
180
|
+
"application": "spec_path",
|
|
181
|
+
}.get(kind)
|
|
182
|
+
path = Path(spec[path_key]) if path_key and spec.get(path_key) else None
|
|
183
|
+
if (not scope or not item) and path is not None:
|
|
184
|
+
found_scope, found_item = locate_scope_item(path, store, kind)
|
|
185
|
+
scope = scope or found_scope
|
|
186
|
+
item = item or found_item
|
|
187
|
+
if not item:
|
|
188
|
+
item = path.stem if path is not None else (detail.get("id") or "run")
|
|
189
|
+
if not scope:
|
|
190
|
+
scope = "library:unknown"
|
|
191
|
+
source = "ui" if ref.get("origin") == "ui" else "cli"
|
|
192
|
+
run = new_run(
|
|
193
|
+
scope=scope,
|
|
194
|
+
kind=kind,
|
|
195
|
+
item=item,
|
|
196
|
+
request_summary={
|
|
197
|
+
"source": source,
|
|
198
|
+
"command": detail.get("command") or "",
|
|
199
|
+
"action": "run",
|
|
200
|
+
},
|
|
201
|
+
)
|
|
202
|
+
worker_id = detail.get("id")
|
|
203
|
+
if worker_id:
|
|
204
|
+
run.id = str(worker_id)
|
|
205
|
+
run.job_id = str(worker_id)
|
|
206
|
+
apply_worker_to_run(run, detail, cwd=Path(spec["cwd"]) if spec.get("cwd") else None)
|
|
207
|
+
return run
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def refresh_run(run) -> None:
|
|
211
|
+
api = peek_controller_api()
|
|
212
|
+
if api is None or not run.job_id:
|
|
213
|
+
return
|
|
214
|
+
detail = api.get_worker(run.job_id)
|
|
215
|
+
if detail is None:
|
|
216
|
+
return
|
|
217
|
+
cwd = None
|
|
218
|
+
spec = detail.get("request_body") or {}
|
|
219
|
+
if spec.get("cwd"):
|
|
220
|
+
cwd = Path(spec["cwd"])
|
|
221
|
+
apply_worker_to_run(run, detail, cwd=cwd)
|
|
222
|
+
if run.status not in {"pending", "running"}:
|
|
223
|
+
from mas.lab.controller.routes.runs import _attach_artifacts
|
|
224
|
+
|
|
225
|
+
_attach_artifacts(run, cwd)
|
|
226
|
+
_finish_cleanup(run.id)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def sync_workers(store) -> None:
|
|
230
|
+
api = peek_controller_api()
|
|
231
|
+
if api is None:
|
|
232
|
+
return
|
|
233
|
+
runs = get_run_store()
|
|
234
|
+
with _lock:
|
|
235
|
+
dropped = set(_dropped)
|
|
236
|
+
by_job = {row.job_id: row for row in runs.list() if row.job_id}
|
|
237
|
+
for summary in api.list_workers():
|
|
238
|
+
worker_id = summary.get("id")
|
|
239
|
+
if not worker_id or worker_id in dropped:
|
|
240
|
+
continue
|
|
241
|
+
detail = api.get_worker(worker_id) or summary
|
|
242
|
+
# ``list_workers`` summaries omit kind/spec; prefer the detail record.
|
|
243
|
+
if "kind" not in detail and "kind" in summary:
|
|
244
|
+
detail = {**detail, "kind": summary["kind"]}
|
|
245
|
+
run = by_job.get(worker_id)
|
|
246
|
+
if run is None:
|
|
247
|
+
run = run_from_worker(detail, store)
|
|
248
|
+
runs.save(run)
|
|
249
|
+
by_job[worker_id] = run
|
|
250
|
+
else:
|
|
251
|
+
refresh_run(run)
|
|
252
|
+
runs.save(run)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def register_cleanup(run_id: str, paths: list[Path]) -> None:
|
|
256
|
+
if paths:
|
|
257
|
+
with _lock:
|
|
258
|
+
_cleanup_by_run[run_id] = list(paths)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _finish_cleanup(run_id: str) -> None:
|
|
262
|
+
with _lock:
|
|
263
|
+
paths = _cleanup_by_run.pop(run_id, [])
|
|
264
|
+
for path in paths:
|
|
265
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def forget_worker(worker_id: str) -> None:
|
|
269
|
+
with _lock:
|
|
270
|
+
_dropped.add(worker_id)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def reset_follow_state() -> None:
|
|
274
|
+
with _lock:
|
|
275
|
+
_dropped.clear()
|
|
276
|
+
_cleanup_by_run.clear()
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Persisted runs (job store rebuilt on the run/artifact model)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import sqlite3
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_CREATE = """\
|
|
19
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
20
|
+
id TEXT PRIMARY KEY,
|
|
21
|
+
scope TEXT NOT NULL,
|
|
22
|
+
kind TEXT NOT NULL,
|
|
23
|
+
item TEXT NOT NULL,
|
|
24
|
+
status TEXT NOT NULL,
|
|
25
|
+
started_at TEXT,
|
|
26
|
+
finished_at TEXT,
|
|
27
|
+
created_at TEXT NOT NULL,
|
|
28
|
+
exit_code INTEGER,
|
|
29
|
+
request_summary TEXT NOT NULL DEFAULT '{}',
|
|
30
|
+
job_id TEXT,
|
|
31
|
+
result_dir TEXT
|
|
32
|
+
);
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _now() -> str:
|
|
37
|
+
return datetime.now(timezone.utc).isoformat()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Run:
|
|
42
|
+
id: str
|
|
43
|
+
scope: str
|
|
44
|
+
kind: str
|
|
45
|
+
item: str
|
|
46
|
+
status: str
|
|
47
|
+
created_at: str
|
|
48
|
+
started_at: Optional[str] = None
|
|
49
|
+
finished_at: Optional[str] = None
|
|
50
|
+
exit_code: Optional[int] = None
|
|
51
|
+
request_summary: dict[str, Any] = field(default_factory=dict)
|
|
52
|
+
job_id: Optional[str] = None
|
|
53
|
+
result_dir: Optional[str] = None
|
|
54
|
+
log_lines: list[str] = field(default_factory=list)
|
|
55
|
+
|
|
56
|
+
def to_public(self) -> dict[str, Any]:
|
|
57
|
+
return {
|
|
58
|
+
"id": self.id,
|
|
59
|
+
"scope": self.scope,
|
|
60
|
+
"kind": self.kind,
|
|
61
|
+
"item": self.item,
|
|
62
|
+
"status": self.status,
|
|
63
|
+
"created_at": self.created_at,
|
|
64
|
+
"started_at": self.started_at,
|
|
65
|
+
"finished_at": self.finished_at,
|
|
66
|
+
"exit_code": self.exit_code,
|
|
67
|
+
"request_summary": self.request_summary,
|
|
68
|
+
"job_id": self.job_id,
|
|
69
|
+
"log": "\n".join(self.log_lines),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class RunStore:
|
|
74
|
+
def __init__(self, db_path: Path | None = None):
|
|
75
|
+
self._memory: dict[str, Run] = {}
|
|
76
|
+
self._conn: sqlite3.Connection | None = None
|
|
77
|
+
if db_path is not None:
|
|
78
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
self._conn = sqlite3.connect(str(db_path), check_same_thread=False)
|
|
80
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
81
|
+
self._conn.execute(_CREATE)
|
|
82
|
+
self._conn.commit()
|
|
83
|
+
self._load()
|
|
84
|
+
|
|
85
|
+
def _load(self) -> None:
|
|
86
|
+
if self._conn is None:
|
|
87
|
+
return
|
|
88
|
+
rows = self._conn.execute(
|
|
89
|
+
"SELECT id, scope, kind, item, status, started_at, finished_at, "
|
|
90
|
+
"created_at, exit_code, request_summary, job_id, result_dir FROM runs"
|
|
91
|
+
).fetchall()
|
|
92
|
+
for row in rows:
|
|
93
|
+
summary = row[9]
|
|
94
|
+
try:
|
|
95
|
+
summary_obj = json.loads(summary) if summary else {}
|
|
96
|
+
except json.JSONDecodeError:
|
|
97
|
+
summary_obj = {}
|
|
98
|
+
run = Run(
|
|
99
|
+
id=row[0],
|
|
100
|
+
scope=row[1],
|
|
101
|
+
kind=row[2],
|
|
102
|
+
item=row[3],
|
|
103
|
+
status=row[4],
|
|
104
|
+
started_at=row[5],
|
|
105
|
+
finished_at=row[6],
|
|
106
|
+
created_at=row[7],
|
|
107
|
+
exit_code=row[8],
|
|
108
|
+
request_summary=summary_obj,
|
|
109
|
+
job_id=row[10],
|
|
110
|
+
result_dir=row[11],
|
|
111
|
+
)
|
|
112
|
+
self._memory[run.id] = run
|
|
113
|
+
|
|
114
|
+
def save(self, run: Run) -> None:
|
|
115
|
+
self._memory[run.id] = run
|
|
116
|
+
if self._conn is None:
|
|
117
|
+
return
|
|
118
|
+
self._conn.execute(
|
|
119
|
+
"INSERT OR REPLACE INTO runs "
|
|
120
|
+
"(id, scope, kind, item, status, started_at, finished_at, created_at, "
|
|
121
|
+
"exit_code, request_summary, job_id, result_dir) "
|
|
122
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
123
|
+
(
|
|
124
|
+
run.id,
|
|
125
|
+
run.scope,
|
|
126
|
+
run.kind,
|
|
127
|
+
run.item,
|
|
128
|
+
run.status,
|
|
129
|
+
run.started_at,
|
|
130
|
+
run.finished_at,
|
|
131
|
+
run.created_at,
|
|
132
|
+
run.exit_code,
|
|
133
|
+
json.dumps(run.request_summary),
|
|
134
|
+
run.job_id,
|
|
135
|
+
run.result_dir,
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
self._conn.commit()
|
|
139
|
+
|
|
140
|
+
def get(self, run_id: str) -> Run | None:
|
|
141
|
+
return self._memory.get(run_id)
|
|
142
|
+
|
|
143
|
+
def list(self) -> list[Run]:
|
|
144
|
+
return sorted(self._memory.values(), key=lambda r: r.created_at, reverse=True)
|
|
145
|
+
|
|
146
|
+
def delete(self, run_id: str) -> None:
|
|
147
|
+
self._memory.pop(run_id, None)
|
|
148
|
+
if self._conn is not None:
|
|
149
|
+
self._conn.execute("DELETE FROM runs WHERE id = ?", (run_id,))
|
|
150
|
+
self._conn.commit()
|
|
151
|
+
|
|
152
|
+
def close(self) -> None:
|
|
153
|
+
if self._conn is not None:
|
|
154
|
+
self._conn.close()
|
|
155
|
+
self._conn = None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def new_run(*, scope: str, kind: str, item: str, request_summary: dict | None = None) -> Run:
|
|
159
|
+
return Run(
|
|
160
|
+
id=str(uuid.uuid4()),
|
|
161
|
+
scope=scope,
|
|
162
|
+
kind=kind,
|
|
163
|
+
item=item,
|
|
164
|
+
status="pending",
|
|
165
|
+
created_at=_now(),
|
|
166
|
+
request_summary=request_summary or {},
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
_store: RunStore | None = None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def get_run_store() -> RunStore:
|
|
174
|
+
global _store
|
|
175
|
+
if _store is None:
|
|
176
|
+
_store = RunStore()
|
|
177
|
+
return _store
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def init_run_store(db_path: Path | None) -> RunStore:
|
|
181
|
+
global _store
|
|
182
|
+
if _store is not None:
|
|
183
|
+
_store.close()
|
|
184
|
+
_store = RunStore(db_path)
|
|
185
|
+
return _store
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def set_run_store(store: RunStore | None) -> None:
|
|
189
|
+
global _store
|
|
190
|
+
_store = store
|