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,83 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Worker registry — thread-safe in-process store."""
4
+ from __future__ import annotations
5
+
6
+ import threading
7
+ from typing import Dict, List, Optional
8
+
9
+ from mas.lab.controller.worker_model import WorkerKind, WorkerRecord, WorkerStatus, new_worker_id
10
+
11
+
12
+ class WorkerRegistry:
13
+ def __init__(self) -> None:
14
+ self._workers: Dict[str, WorkerRecord] = {}
15
+ self._lock = threading.Lock()
16
+
17
+ def create(
18
+ self,
19
+ kind: WorkerKind,
20
+ *,
21
+ spec: Optional[dict] = None,
22
+ command: str = "",
23
+ endpoint: str = "",
24
+ parent_id: Optional[str] = None,
25
+ worker_id: Optional[str] = None,
26
+ ) -> WorkerRecord:
27
+ prefix = {"application": "app", "benchmark": "bench", "pipeline": "pipe"}.get(
28
+ kind.value, "w"
29
+ )
30
+ record = WorkerRecord(
31
+ id=worker_id or new_worker_id(prefix),
32
+ kind=kind,
33
+ spec=dict(spec or {}),
34
+ command=command,
35
+ endpoint=endpoint,
36
+ parent_id=parent_id,
37
+ )
38
+ with self._lock:
39
+ self._workers[record.id] = record
40
+ return record
41
+
42
+ def get(self, worker_id: str) -> Optional[WorkerRecord]:
43
+ with self._lock:
44
+ return self._workers.get(worker_id)
45
+
46
+ def list_workers(
47
+ self,
48
+ *,
49
+ kind: Optional[WorkerKind] = None,
50
+ status: Optional[WorkerStatus] = None,
51
+ ) -> List[WorkerRecord]:
52
+ with self._lock:
53
+ items = list(self._workers.values())
54
+ if kind is not None:
55
+ items = [w for w in items if w.kind == kind]
56
+ if status is not None:
57
+ items = [w for w in items if w.status == status]
58
+ items.sort(key=lambda w: w.created_at, reverse=True)
59
+ return items
60
+
61
+ def update(self, worker_id: str, **fields: object) -> Optional[WorkerRecord]:
62
+ with self._lock:
63
+ record = self._workers.get(worker_id)
64
+ if record is None:
65
+ return None
66
+ for key, value in fields.items():
67
+ if hasattr(record, key):
68
+ setattr(record, key, value)
69
+ return record
70
+
71
+ def cancel(self, worker_id: str) -> bool:
72
+ record = self.get(worker_id)
73
+ if record is None:
74
+ return False
75
+ if record.status in (WorkerStatus.COMPLETED, WorkerStatus.FAILED, WorkerStatus.CANCELLED):
76
+ return True
77
+ record.status = WorkerStatus.CANCELLED
78
+ record.finished_at = record.finished_at or __import__("time").time()
79
+ return True
80
+
81
+ def drop(self, worker_id: str) -> bool:
82
+ with self._lock:
83
+ return self._workers.pop(worker_id, None) is not None
@@ -0,0 +1,82 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """FastAPI route registration for the MAS Lab controller API."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from fastapi import FastAPI
8
+ from mas.lab.controller.routes import (
9
+ apps,
10
+ artifacts,
11
+ benchmark,
12
+ choices,
13
+ datasets,
14
+ design_patterns,
15
+ eval_output,
16
+ experiments,
17
+ health,
18
+ items,
19
+ jobs,
20
+ libraries,
21
+ overlays,
22
+ pipelines,
23
+ registry,
24
+ run,
25
+ runs,
26
+ scopes,
27
+ uploads,
28
+ validate,
29
+ )
30
+
31
+ _DEPRECATED_PREFIXES = (
32
+ "/api/libraries",
33
+ "/api/jobs",
34
+ "/api/experiments",
35
+ )
36
+
37
+
38
+ def register_routes(app: FastAPI) -> None:
39
+ """Attach all API routers to the application."""
40
+ app.include_router(jobs.router)
41
+ app.include_router(registry.router)
42
+ app.include_router(libraries.router)
43
+ app.include_router(overlays.router)
44
+ app.include_router(validate.router)
45
+ app.include_router(run.router)
46
+ app.include_router(datasets.router)
47
+ app.include_router(benchmark.router)
48
+ app.include_router(experiments.router)
49
+ app.include_router(pipelines.router)
50
+ app.include_router(eval_output.router)
51
+ app.include_router(apps.router)
52
+ app.include_router(health.router)
53
+ app.include_router(scopes.router)
54
+ app.include_router(items.router)
55
+ app.include_router(choices.router)
56
+ app.include_router(design_patterns.router)
57
+ app.include_router(runs.router)
58
+ app.include_router(artifacts.router)
59
+ app.include_router(uploads.router)
60
+ _mark_app_deprecated(app)
61
+
62
+
63
+ def _iter_api_routes(node) -> list:
64
+ routes = []
65
+ for route in getattr(node, "routes", []) or []:
66
+ original = getattr(route, "original_router", None)
67
+ if original is not None:
68
+ routes.extend(_iter_api_routes(original))
69
+ continue
70
+ nested = getattr(route, "app", None)
71
+ if nested is not None and hasattr(nested, "routes") and nested is not node:
72
+ routes.extend(_iter_api_routes(nested))
73
+ routes.append(route)
74
+ return routes
75
+
76
+
77
+ def _mark_app_deprecated(app: FastAPI) -> None:
78
+ for route in _iter_api_routes(app):
79
+ path = getattr(route, "path", "") or ""
80
+ if any(path == prefix or path.startswith(prefix + "/") for prefix in _DEPRECATED_PREFIXES):
81
+ if hasattr(route, "deprecated"):
82
+ route.deprecated = True
@@ -0,0 +1,15 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Shared imports for route modules — patch ``mas.lab.controller.deps`` in tests."""
4
+
5
+ from mas.lab.controller import deps, jobs
6
+ from mas.lab.controller.constants import LIBRARIES_DIR, MAS_LAB_ROOT
7
+ from mas.lab.controller.pipeline_validation import validate_pipeline_yaml
8
+
9
+ __all__ = [
10
+ "LIBRARIES_DIR",
11
+ "MAS_LAB_ROOT",
12
+ "deps",
13
+ "jobs",
14
+ "validate_pipeline_yaml",
15
+ ]
@@ -0,0 +1,190 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """MAS app resource CRUD endpoints."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import shutil
8
+ from pathlib import Path
9
+
10
+ import yaml
11
+ from fastapi import APIRouter, HTTPException
12
+
13
+ from mas.ctl.overlay.canvas import apply_ui_canvas_to_yaml, persist_ui_canvas_yaml
14
+ from mas.lab.controller.lab_registry import LabRegistry
15
+ from mas.lab.controller.models import SaveMASResourceRequest
16
+ from mas.lab.controller.routes._api import deps, jobs, LIBRARIES_DIR, validate_pipeline_yaml
17
+
18
+ router = APIRouter()
19
+
20
+
21
+ def _persist_ui_canvas(app_dir: Path, mas_yaml: str, agents: dict[str, str], mas_name: str) -> tuple[str, dict[str, str]]:
22
+ """Move canvas ``x-*`` fields from MAS/agent YAML onto a layout-only Overlay."""
23
+ return persist_ui_canvas_yaml(app_dir, mas_yaml, agents, mas_name=mas_name)
24
+
25
+
26
+ def _write_mas_resource(apps_dir: Path, mas_name: str, mas_yaml: str, agents: dict[str, str]) -> dict:
27
+ """Write a MAS manifest + agent files to disk. Returns summary."""
28
+ app_dir = apps_dir / mas_name
29
+ app_dir.mkdir(parents=True, exist_ok=True)
30
+ mas_yaml, agents = _persist_ui_canvas(app_dir, mas_yaml, agents, mas_name)
31
+
32
+ mas_file = app_dir / "mas.yaml"
33
+ mas_file.write_text(mas_yaml, encoding="utf-8")
34
+
35
+ created_agents = []
36
+ if agents:
37
+ agents_dir = app_dir / "agents"
38
+ agents_dir.mkdir(parents=True, exist_ok=True)
39
+ for agent_name, agent_content in agents.items():
40
+ filename = f"{agent_name}.yaml" if not agent_name.endswith(".yaml") else agent_name
41
+ (agents_dir / filename).write_text(agent_content, encoding="utf-8")
42
+ created_agents.append(filename)
43
+
44
+ return {
45
+ "mas_name": mas_name,
46
+ "mas_file": f"apps/{mas_name}/mas.yaml",
47
+ "agents": sorted(created_agents),
48
+ }
49
+
50
+
51
+ @router.post("/api/libraries/{library_name}/apps", tags=["Libraries"], status_code=201)
52
+ async def save_mas_resource(library_name: str, req: SaveMASResourceRequest):
53
+ """Save a MAS manifest + agent files under apps/<mas_name>/."""
54
+ lib_dir = deps.get_library_path(library_name)
55
+ apps_dir = lib_dir / "apps"
56
+ app_dir = apps_dir / req.mas_name
57
+
58
+ if (app_dir / "mas.yaml").exists():
59
+ raise HTTPException(
60
+ status_code=409,
61
+ detail=f"MAS resource '{req.mas_name}' already exists",
62
+ )
63
+
64
+ result = _write_mas_resource(apps_dir, req.mas_name, req.mas_yaml, req.agents)
65
+ return result
66
+
67
+
68
+ @router.put("/api/libraries/{library_name}/apps/{old_mas_name}", tags=["Libraries"])
69
+ async def update_mas_resource(library_name: str, old_mas_name: str, req: SaveMASResourceRequest):
70
+ """Update a MAS resource. Overwrites mas.yaml and agents/ only, preserving other app contents."""
71
+ lib_dir = deps.get_library_path(library_name)
72
+ apps_dir = lib_dir / "apps"
73
+ old_app_dir = apps_dir / old_mas_name
74
+
75
+ if not (old_app_dir / "mas.yaml").exists():
76
+ raise HTTPException(
77
+ status_code=404,
78
+ detail=f"MAS resource '{old_mas_name}' not found",
79
+ )
80
+
81
+ renaming = req.mas_name != old_mas_name
82
+ if renaming:
83
+ new_app_dir = apps_dir / req.mas_name
84
+ if new_app_dir.exists() and (new_app_dir / "mas.yaml").exists():
85
+ raise HTTPException(
86
+ status_code=409,
87
+ detail=f"MAS resource '{req.mas_name}' already exists",
88
+ )
89
+
90
+ if renaming:
91
+ target_dir = apps_dir / req.mas_name
92
+ old_app_dir.rename(target_dir)
93
+ else:
94
+ target_dir = old_app_dir
95
+
96
+ target_dir.mkdir(parents=True, exist_ok=True)
97
+ mas_yaml, agents = _persist_ui_canvas(target_dir, req.mas_yaml, req.agents, req.mas_name)
98
+ (target_dir / "mas.yaml").write_text(mas_yaml, encoding="utf-8")
99
+
100
+ agents_dir = target_dir / "agents"
101
+ if agents_dir.exists():
102
+ shutil.rmtree(agents_dir)
103
+ if agents:
104
+ agents_dir.mkdir(parents=True, exist_ok=True)
105
+ for agent_name, agent_content in agents.items():
106
+ filename = f"{agent_name}.yaml" if not agent_name.endswith(".yaml") else agent_name
107
+ (agents_dir / filename).write_text(agent_content, encoding="utf-8")
108
+
109
+ return {
110
+ "mas_name": req.mas_name,
111
+ "mas_file": f"apps/{req.mas_name}/mas.yaml",
112
+ "agents": sorted(
113
+ f"{a}.yaml" if not a.endswith(".yaml") else a for a in agents
114
+ ),
115
+ }
116
+
117
+
118
+ @router.get("/api/libraries/{library_name}/apps", tags=["Libraries"])
119
+ async def list_mas_resources(library_name: str):
120
+ """List all MAS resources with their manifests and agents."""
121
+ return {"mas_resources": deps.get_manifest_store().collect_mas_resources(library_name)}
122
+
123
+
124
+ @router.get("/api/libraries/{library_name}/apps/{mas_name}", tags=["Libraries"])
125
+ async def get_mas_resource(library_name: str, mas_name: str):
126
+ """Return a single MAS resource (manifest + agents)."""
127
+ resources = deps.get_manifest_store().collect_mas_resources(library_name)
128
+ entry = resources.get(mas_name)
129
+ if entry is None:
130
+ raise HTTPException(
131
+ status_code=404,
132
+ detail=f"MAS resource '{mas_name}' not found",
133
+ )
134
+
135
+ mas_yaml = entry["mas_yaml"]
136
+ agents = dict(entry.get("agents") or {})
137
+ store = deps.get_manifest_store()
138
+ lib_dir = deps.get_library_path(library_name)
139
+ mas_path = entry.get("path")
140
+ mas_dir = (lib_dir / mas_path).parent if mas_path else lib_dir / "apps" / mas_name
141
+ registry = getattr(store, "_registry", None)
142
+ if not isinstance(registry, LabRegistry):
143
+ registry = LabRegistry._ephemeral()
144
+ target_name = mas_name
145
+ try:
146
+ mas_doc = yaml.safe_load(mas_yaml) or {}
147
+ if isinstance(mas_doc, dict):
148
+ target_name = str((mas_doc.get("metadata") or {}).get("name") or mas_name).strip() or mas_name
149
+ except Exception:
150
+ pass
151
+ seen: set[Path] = set()
152
+ matched: list[tuple[Path, dict]] = []
153
+ for name in (target_name, mas_name):
154
+ for path, doc in registry.layout_overlays_targeting(lib_dir, kind="MAS", name=name):
155
+ key = path.resolve()
156
+ if key in seen:
157
+ continue
158
+ seen.add(key)
159
+ matched.append((path, doc))
160
+ mas_yaml, agents, overlay_path = apply_ui_canvas_to_yaml(
161
+ mas_dir, mas_yaml, agents, overlays=matched
162
+ )
163
+ body: dict = {
164
+ "mas_name": mas_name,
165
+ "mas_yaml": mas_yaml,
166
+ "agents": agents,
167
+ }
168
+ if overlay_path is not None:
169
+ try:
170
+ body["ui_canvas_overlay"] = str(overlay_path.relative_to(lib_dir))
171
+ except ValueError:
172
+ body["ui_canvas_overlay"] = str(overlay_path)
173
+ return body
174
+
175
+
176
+ @router.delete("/api/libraries/{library_name}/apps/{mas_name}", tags=["Libraries"])
177
+ async def delete_mas_resource(library_name: str, mas_name: str):
178
+ """Delete a MAS app folder (manifest + agents)."""
179
+ lib_dir = deps.get_library_path(library_name)
180
+ apps_dir = lib_dir / "apps"
181
+ app_dir = apps_dir / mas_name
182
+
183
+ if not (app_dir / "mas.yaml").exists():
184
+ raise HTTPException(
185
+ status_code=404,
186
+ detail=f"MAS resource '{mas_name}' not found",
187
+ )
188
+
189
+ shutil.rmtree(app_dir)
190
+ return {"deleted": mas_name}
@@ -0,0 +1,37 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Serve run artifacts by issued id only."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from fastapi import APIRouter, HTTPException
8
+ from fastapi.responses import FileResponse, PlainTextResponse
9
+ from mas.lab.controller.artifacts import get_artifact_registry
10
+
11
+ router = APIRouter()
12
+
13
+ _TEXT_TYPES = {
14
+ "text/plain",
15
+ "text/csv",
16
+ "text/yaml",
17
+ "text/html",
18
+ "text/xml",
19
+ "application/json",
20
+ "application/yaml",
21
+ "image/svg+xml",
22
+ }
23
+
24
+
25
+ @router.get("/api/artifacts/{artifact_id}", tags=["Artifacts"])
26
+ async def get_artifact(artifact_id: str, download: bool = False):
27
+ art = get_artifact_registry().get(artifact_id)
28
+ if art is None or art.path is None or not art.path.is_file():
29
+ raise HTTPException(status_code=404, detail="artifact not found")
30
+ if download or art.media_type not in _TEXT_TYPES and not art.media_type.startswith("text/"):
31
+ return FileResponse(
32
+ path=str(art.path),
33
+ media_type=art.media_type,
34
+ filename=art.name,
35
+ )
36
+ text = art.path.read_text(encoding="utf-8", errors="replace")
37
+ return PlainTextResponse(text, media_type=art.media_type or "text/plain")