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,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "Registry of all pipeline step types with their config schemas. Use this to build a dynamic pipeline editor in React.",
|
|
3
|
+
"step_types": [
|
|
4
|
+
{
|
|
5
|
+
"type": "service_start",
|
|
6
|
+
"label": "Start Service",
|
|
7
|
+
"description": "Start an infrastructure service (Neo4j, OTel collector, etc.) before the benchmark loop.",
|
|
8
|
+
"phase": "pre",
|
|
9
|
+
"category": "infrastructure",
|
|
10
|
+
"config": {
|
|
11
|
+
"service": {
|
|
12
|
+
"type": "string",
|
|
13
|
+
"required": true,
|
|
14
|
+
"description": "Service name declared in the infra bundle (e.g. neo4j, otel-collector)"
|
|
15
|
+
},
|
|
16
|
+
"health_timeout": {
|
|
17
|
+
"type": "integer",
|
|
18
|
+
"default": 60,
|
|
19
|
+
"description": "Seconds to wait for the service health check to pass"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"categories": [
|
|
25
|
+
{ "id": "data", "label": "Data", "color": "#3b82f6" },
|
|
26
|
+
{ "id": "execution", "label": "Execution", "color": "#8b5cf6" },
|
|
27
|
+
{ "id": "extraction", "label": "Extraction", "color": "#06b6d4" },
|
|
28
|
+
{ "id": "normalization", "label": "Normalization", "color": "#14b8a6" },
|
|
29
|
+
{ "id": "analysis", "label": "Analysis", "color": "#f59e0b" },
|
|
30
|
+
{ "id": "evaluation", "label": "Evaluation", "color": "#ef4444" },
|
|
31
|
+
{ "id": "visualization", "label": "Visualization", "color": "#22c55e" },
|
|
32
|
+
{ "id": "graph", "label": "Graph DB", "color": "#a855f7" },
|
|
33
|
+
{ "id": "infrastructure", "label": "Infrastructure", "color": "#64748b" }
|
|
34
|
+
]
|
|
35
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Libraries and labs as first-class scopes (a lab is never a library)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Iterable
|
|
10
|
+
|
|
11
|
+
from mas.lab.controller.kinds import LAB_KINDS, LIBRARY_BASE_KINDS
|
|
12
|
+
from mas.lab.controller.path_safety import UnsafeNameError, ensure_inside
|
|
13
|
+
from mas.runtime.constants import LAB_CONFIG_FILENAME, LIBRARY_MANIFEST_FILENAME
|
|
14
|
+
from mas.runtime.spec.source import load_yaml_file
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _title_from_slug(slug: str) -> str:
|
|
18
|
+
return slug.replace("-", " ").replace("_", " ").title()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _description_of(path: Path) -> str:
|
|
22
|
+
from mas.lab.controller.lab_registry import _library_description
|
|
23
|
+
|
|
24
|
+
return _library_description(path)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_lab_root(path: Path) -> bool:
|
|
28
|
+
if path.name.endswith(".lab"):
|
|
29
|
+
return True
|
|
30
|
+
return (path / LAB_CONFIG_FILENAME).is_file() and not (path / LIBRARY_MANIFEST_FILENAME).is_file()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _origin_from_source(source: str, *, path: Path) -> str:
|
|
34
|
+
src = source or ""
|
|
35
|
+
if src.startswith("entry-point"):
|
|
36
|
+
return "package"
|
|
37
|
+
if src.startswith("lab-local") or src.startswith("lab:"):
|
|
38
|
+
return "lab"
|
|
39
|
+
if "labs_search_paths" in src or "cwd-walk" in src or "labs_root" in src or "find_workspace_root" in src:
|
|
40
|
+
return "lab" if _is_lab_root(path) else "workspace"
|
|
41
|
+
if src.startswith("path:") or "MAS_LIBRARY_PATHS" in src:
|
|
42
|
+
return "path"
|
|
43
|
+
if src.startswith("manifest_libraries"):
|
|
44
|
+
return "workspace"
|
|
45
|
+
if _is_lab_root(path):
|
|
46
|
+
return "lab"
|
|
47
|
+
return "workspace"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Scope:
|
|
52
|
+
id: str
|
|
53
|
+
kind: str
|
|
54
|
+
name: str
|
|
55
|
+
title: str
|
|
56
|
+
description: str
|
|
57
|
+
origin: str
|
|
58
|
+
writable: bool
|
|
59
|
+
root: Path
|
|
60
|
+
libraries: list[str] = field(default_factory=list)
|
|
61
|
+
owner: str | None = None
|
|
62
|
+
kinds: list[str] = field(default_factory=list)
|
|
63
|
+
counts: dict[str, int] = field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
def to_public(self) -> dict[str, Any]:
|
|
66
|
+
payload: dict[str, Any] = {
|
|
67
|
+
"id": self.id,
|
|
68
|
+
"kind": self.kind,
|
|
69
|
+
"name": self.name,
|
|
70
|
+
"title": self.title,
|
|
71
|
+
"description": self.description,
|
|
72
|
+
"origin": self.origin,
|
|
73
|
+
"writable": self.writable,
|
|
74
|
+
"kinds": list(self.kinds),
|
|
75
|
+
}
|
|
76
|
+
if self.libraries:
|
|
77
|
+
payload["libraries"] = list(self.libraries)
|
|
78
|
+
if self.owner:
|
|
79
|
+
payload["owner"] = self.owner
|
|
80
|
+
if self.counts:
|
|
81
|
+
payload["counts"] = dict(self.counts)
|
|
82
|
+
return payload
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ScopeCatalog:
|
|
86
|
+
"""Index of library and lab scopes derived from :class:`LabRegistry`."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, scopes: dict[str, Scope]):
|
|
89
|
+
self._scopes = scopes
|
|
90
|
+
|
|
91
|
+
def all(self) -> list[Scope]:
|
|
92
|
+
def _sort_key(scope: Scope) -> tuple[str, str]:
|
|
93
|
+
return (scope.kind, scope.name)
|
|
94
|
+
|
|
95
|
+
return sorted(self._scopes.values(), key=_sort_key)
|
|
96
|
+
|
|
97
|
+
def get(self, scope_id: str) -> Scope:
|
|
98
|
+
scope = self._scopes.get(scope_id)
|
|
99
|
+
if scope is None:
|
|
100
|
+
# Allow a bare slug: try library first, then lab (old UI URLs).
|
|
101
|
+
if ":" not in scope_id:
|
|
102
|
+
for prefix in ("library:", "lab:"):
|
|
103
|
+
found = self._scopes.get(f"{prefix}{scope_id}")
|
|
104
|
+
if found is not None:
|
|
105
|
+
return found
|
|
106
|
+
raise KeyError(scope_id)
|
|
107
|
+
return scope
|
|
108
|
+
|
|
109
|
+
def visible_libraries(self, scope_id: str) -> list[Scope]:
|
|
110
|
+
"""Libraries this scope may reference, in documented search order.
|
|
111
|
+
|
|
112
|
+
Lab-local libraries first, then workspace, installed (package), path.
|
|
113
|
+
"""
|
|
114
|
+
origin_order = {"lab": 0, "workspace": 1, "package": 2, "path": 3}
|
|
115
|
+
scope = self.get(scope_id)
|
|
116
|
+
libraries = [s for s in self.all() if s.kind == "library"]
|
|
117
|
+
if scope.kind == "lab":
|
|
118
|
+
owned = [s for s in libraries if s.owner == scope.id]
|
|
119
|
+
others = [s for s in libraries if s.owner != scope.id]
|
|
120
|
+
owned.sort(key=lambda s: s.name)
|
|
121
|
+
others.sort(key=lambda s: (origin_order.get(s.origin, 9), s.name))
|
|
122
|
+
return owned + others
|
|
123
|
+
libraries.sort(key=lambda s: (origin_order.get(s.origin, 9), s.name))
|
|
124
|
+
return libraries
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def from_registry(
|
|
128
|
+
cls,
|
|
129
|
+
registry: Any,
|
|
130
|
+
*,
|
|
131
|
+
read_only_slugs: Iterable[str] | None = None,
|
|
132
|
+
) -> "ScopeCatalog":
|
|
133
|
+
read_only = set(read_only_slugs or ())
|
|
134
|
+
libraries: dict[str, Path] = dict(registry.library_paths())
|
|
135
|
+
sources: dict[str, str] = dict(getattr(registry, "_library_sources", {}) or {})
|
|
136
|
+
|
|
137
|
+
labs: list[Scope] = []
|
|
138
|
+
lib_scopes: list[Scope] = []
|
|
139
|
+
|
|
140
|
+
for slug, path in libraries.items():
|
|
141
|
+
source = sources.get(slug, "")
|
|
142
|
+
origin = _origin_from_source(source, path=path)
|
|
143
|
+
if _is_lab_root(path):
|
|
144
|
+
name = slug
|
|
145
|
+
try:
|
|
146
|
+
doc = load_yaml_file(path / LAB_CONFIG_FILENAME) if (path / LAB_CONFIG_FILENAME).is_file() else {}
|
|
147
|
+
lab_block = (doc or {}).get("lab") or {}
|
|
148
|
+
name = str(lab_block.get("name") or slug)
|
|
149
|
+
except Exception:
|
|
150
|
+
name = slug
|
|
151
|
+
labs.append(
|
|
152
|
+
Scope(
|
|
153
|
+
id=f"lab:{name}",
|
|
154
|
+
kind="lab",
|
|
155
|
+
name=name,
|
|
156
|
+
title=_title_from_slug(name),
|
|
157
|
+
description=_description_of(path),
|
|
158
|
+
origin="lab",
|
|
159
|
+
writable=True,
|
|
160
|
+
root=path.resolve(),
|
|
161
|
+
kinds=list(LAB_KINDS),
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
continue
|
|
165
|
+
# A lab root is never a library, even if someone added library.yaml.
|
|
166
|
+
writable = origin != "package" and slug not in read_only
|
|
167
|
+
lib_scopes.append(
|
|
168
|
+
Scope(
|
|
169
|
+
id=f"library:{slug}",
|
|
170
|
+
kind="library",
|
|
171
|
+
name=slug,
|
|
172
|
+
title=_title_from_slug(slug),
|
|
173
|
+
description=_description_of(path),
|
|
174
|
+
origin=origin if origin != "lab" else "workspace",
|
|
175
|
+
writable=writable,
|
|
176
|
+
root=path.resolve(),
|
|
177
|
+
kinds=list(LIBRARY_BASE_KINDS),
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Nested lab-local libraries (lab.libraries + immediate children with library.yaml).
|
|
182
|
+
known_roots = {s.root for s in lib_scopes}
|
|
183
|
+
for lab in labs:
|
|
184
|
+
for child in _lab_local_libraries(lab.root):
|
|
185
|
+
if child.resolve() in known_roots:
|
|
186
|
+
continue
|
|
187
|
+
slug = child.name
|
|
188
|
+
lib_scopes.append(
|
|
189
|
+
Scope(
|
|
190
|
+
id=f"library:{slug}",
|
|
191
|
+
kind="library",
|
|
192
|
+
name=slug,
|
|
193
|
+
title=_title_from_slug(slug),
|
|
194
|
+
description=_description_of(child),
|
|
195
|
+
origin="lab",
|
|
196
|
+
writable=True,
|
|
197
|
+
root=child.resolve(),
|
|
198
|
+
owner=lab.id,
|
|
199
|
+
kinds=list(LIBRARY_BASE_KINDS),
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
known_roots.add(child.resolve())
|
|
203
|
+
|
|
204
|
+
for lib in lib_scopes:
|
|
205
|
+
for lab in labs:
|
|
206
|
+
try:
|
|
207
|
+
lib.root.relative_to(lab.root)
|
|
208
|
+
except ValueError:
|
|
209
|
+
continue
|
|
210
|
+
lib.origin = "lab"
|
|
211
|
+
lib.owner = lab.id
|
|
212
|
+
break
|
|
213
|
+
|
|
214
|
+
by_id = {s.id: s for s in labs + lib_scopes}
|
|
215
|
+
for lab in labs:
|
|
216
|
+
lab.libraries = [
|
|
217
|
+
s.id for s in lib_scopes if s.owner == lab.id
|
|
218
|
+
]
|
|
219
|
+
return cls(by_id)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _lab_local_libraries(lab_root: Path) -> list[Path]:
|
|
223
|
+
found: list[Path] = []
|
|
224
|
+
seen: set[Path] = set()
|
|
225
|
+
config = lab_root / LAB_CONFIG_FILENAME
|
|
226
|
+
if config.is_file():
|
|
227
|
+
try:
|
|
228
|
+
doc = load_yaml_file(config) or {}
|
|
229
|
+
except Exception:
|
|
230
|
+
doc = {}
|
|
231
|
+
listed = ((doc.get("lab") or {}).get("libraries")) or []
|
|
232
|
+
for rel in listed:
|
|
233
|
+
try:
|
|
234
|
+
child = ensure_inside(lab_root, lab_root / str(rel))
|
|
235
|
+
except (UnsafeNameError, OSError):
|
|
236
|
+
continue
|
|
237
|
+
if (child / LIBRARY_MANIFEST_FILENAME).is_file() and child not in seen:
|
|
238
|
+
found.append(child)
|
|
239
|
+
seen.add(child)
|
|
240
|
+
if lab_root.is_dir():
|
|
241
|
+
for child in sorted(lab_root.iterdir()):
|
|
242
|
+
if child.is_dir() and (child / LIBRARY_MANIFEST_FILENAME).is_file() and child.resolve() not in seen:
|
|
243
|
+
found.append(child.resolve())
|
|
244
|
+
seen.add(child.resolve())
|
|
245
|
+
return found
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def get_scope_catalog(store: Any | None = None) -> ScopeCatalog:
|
|
249
|
+
"""Build a catalog from the current manifest store / registry."""
|
|
250
|
+
if store is None:
|
|
251
|
+
from mas.lab.controller.deps import get_manifest_store
|
|
252
|
+
|
|
253
|
+
store = get_manifest_store()
|
|
254
|
+
registry = getattr(store, "_registry", None)
|
|
255
|
+
if registry is None:
|
|
256
|
+
from mas.lab.controller.lab_registry import get_lab_registry
|
|
257
|
+
|
|
258
|
+
registry = get_lab_registry()
|
|
259
|
+
read_only = getattr(store, "_read_only_slugs", None)
|
|
260
|
+
catalog = ScopeCatalog.from_registry(registry, read_only_slugs=read_only)
|
|
261
|
+
from mas.lab.controller.workspace_library import workspace_scope
|
|
262
|
+
|
|
263
|
+
extra = workspace_scope()
|
|
264
|
+
if extra is not None and extra.id not in catalog._scopes:
|
|
265
|
+
catalog._scopes[extra.id] = extra
|
|
266
|
+
return catalog
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Create lab scopes on a writable labs search path."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from mas.lab.controller.item_service import ItemError, _catalog
|
|
12
|
+
from mas.lab.controller.path_safety import UnsafeNameError, validate_name
|
|
13
|
+
from mas.runtime.constants import LAB_CONFIG_FILENAME, LIBRARY_MANIFEST_FILENAME
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_lab(
|
|
17
|
+
*,
|
|
18
|
+
name: str,
|
|
19
|
+
description: str = "",
|
|
20
|
+
local_library: bool = True,
|
|
21
|
+
store: Any | None = None,
|
|
22
|
+
) -> Any:
|
|
23
|
+
"""Write ``{name}.lab`` under the first writable labs search path."""
|
|
24
|
+
try:
|
|
25
|
+
name = validate_name(name)
|
|
26
|
+
except UnsafeNameError as exc:
|
|
27
|
+
raise ItemError(400, str(exc)) from exc
|
|
28
|
+
if store is None:
|
|
29
|
+
from mas.lab.controller.deps import get_manifest_store
|
|
30
|
+
|
|
31
|
+
store = get_manifest_store()
|
|
32
|
+
catalog = _catalog(store)
|
|
33
|
+
try:
|
|
34
|
+
catalog.get(f"lab:{name}")
|
|
35
|
+
except KeyError:
|
|
36
|
+
pass
|
|
37
|
+
else:
|
|
38
|
+
raise ItemError(409, f"lab {name!r} already exists")
|
|
39
|
+
parent = _labs_parent(store)
|
|
40
|
+
lab_dir = parent / f"{name}.lab"
|
|
41
|
+
if lab_dir.exists():
|
|
42
|
+
raise ItemError(409, f"lab directory {lab_dir.name} already exists")
|
|
43
|
+
lab_dir.mkdir(parents=True)
|
|
44
|
+
(lab_dir / "experiments").mkdir()
|
|
45
|
+
(lab_dir / "overlays").mkdir()
|
|
46
|
+
(lab_dir / "datasets").mkdir()
|
|
47
|
+
lib_rel = ""
|
|
48
|
+
if local_library:
|
|
49
|
+
lib_rel = f"{name.replace('-', '_')}_lab"
|
|
50
|
+
lib_dir = lab_dir / lib_rel
|
|
51
|
+
lib_dir.mkdir()
|
|
52
|
+
(lib_dir / "apps").mkdir()
|
|
53
|
+
(lib_dir / LIBRARY_MANIFEST_FILENAME).write_text(
|
|
54
|
+
_library_manifest(lib_rel, name),
|
|
55
|
+
encoding="utf-8",
|
|
56
|
+
)
|
|
57
|
+
(lab_dir / LAB_CONFIG_FILENAME).write_text(
|
|
58
|
+
_lab_config(name, description, lib_rel),
|
|
59
|
+
encoding="utf-8",
|
|
60
|
+
)
|
|
61
|
+
_register_created(store, name, lab_dir)
|
|
62
|
+
return _catalog(store).get(f"lab:{name}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _labs_parent(store: Any) -> Path:
|
|
66
|
+
registry = getattr(store, "_registry", None)
|
|
67
|
+
search_paths: list[str] = []
|
|
68
|
+
if registry is not None and hasattr(registry, "_labs_search_paths"):
|
|
69
|
+
search_paths = list(registry._labs_search_paths())
|
|
70
|
+
if not search_paths:
|
|
71
|
+
search_paths = ["labs"]
|
|
72
|
+
base = None
|
|
73
|
+
if registry is not None and hasattr(registry, "_workspace_root"):
|
|
74
|
+
base = registry._workspace_root()
|
|
75
|
+
if base is not None:
|
|
76
|
+
for entry in search_paths:
|
|
77
|
+
path = Path(str(entry)).expanduser()
|
|
78
|
+
if not path.is_absolute():
|
|
79
|
+
path = base / path
|
|
80
|
+
path = path.resolve()
|
|
81
|
+
if path.name.endswith(".lab"):
|
|
82
|
+
path = path.parent
|
|
83
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
return path
|
|
85
|
+
for scope in _catalog(store).all():
|
|
86
|
+
if scope.kind == "lab" and scope.writable:
|
|
87
|
+
return scope.root.parent
|
|
88
|
+
fallback = Path.cwd() / "labs"
|
|
89
|
+
fallback.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
return fallback
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _register_created(store: Any, slug: str, lab_dir: Path) -> None:
|
|
94
|
+
resolved = lab_dir.resolve()
|
|
95
|
+
registry = getattr(store, "_registry", None)
|
|
96
|
+
was_live = bool(getattr(store, "_refreshed", False)) and bool(
|
|
97
|
+
getattr(registry, "_refreshed", False)
|
|
98
|
+
)
|
|
99
|
+
if registry is not None:
|
|
100
|
+
libraries = getattr(registry, "_libraries", None)
|
|
101
|
+
sources = getattr(registry, "_library_sources", None)
|
|
102
|
+
if isinstance(libraries, dict):
|
|
103
|
+
libraries[slug] = resolved
|
|
104
|
+
if isinstance(sources, dict):
|
|
105
|
+
sources[slug] = "created"
|
|
106
|
+
if hasattr(registry, "invalidate_manifest_cache"):
|
|
107
|
+
registry.invalidate_manifest_cache()
|
|
108
|
+
store_libs = getattr(store, "_libraries", None)
|
|
109
|
+
if isinstance(store_libs, dict):
|
|
110
|
+
store_libs[slug] = resolved
|
|
111
|
+
if was_live and hasattr(store, "refresh"):
|
|
112
|
+
store.refresh()
|
|
113
|
+
elif hasattr(store, "invalidate"):
|
|
114
|
+
store.invalidate()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _lab_config(name: str, description: str, library: str) -> str:
|
|
118
|
+
lines = ["lab:", f" name: {name}"]
|
|
119
|
+
if description.strip():
|
|
120
|
+
lines.append(f" description: {json.dumps(description.strip())}")
|
|
121
|
+
if library:
|
|
122
|
+
lines.append(" libraries:")
|
|
123
|
+
lines.append(f" - {library}/")
|
|
124
|
+
return "\n".join(lines) + "\n"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _library_manifest(lib_name: str, lab_name: str) -> str:
|
|
128
|
+
desc = json.dumps(f"Local library for {lab_name}")
|
|
129
|
+
return (
|
|
130
|
+
"apiVersion: mas/v1\n"
|
|
131
|
+
"kind: Library\n"
|
|
132
|
+
f"name: {lib_name}\n"
|
|
133
|
+
f"description: {desc}\n"
|
|
134
|
+
)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Client session registry — idle auto-shutdown when no users and no workers."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from typing import TYPE_CHECKING, Optional
|
|
9
|
+
|
|
10
|
+
from mas.lab.controller.worker_model import WorkerStatus
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from mas.lab.controller.api import ControllerAPI
|
|
14
|
+
|
|
15
|
+
IDLE_SHUTDOWN_SEC = float(os.environ.get("MAS_CONTROLLER_IDLE_SEC", "30"))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SessionRegistry:
|
|
19
|
+
"""Track CLI / test client sessions; signal idle shutdown when empty."""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self._sessions: dict[str, float] = {}
|
|
23
|
+
self._idle_since: Optional[float] = None
|
|
24
|
+
|
|
25
|
+
def acquire(self, session_id: str) -> None:
|
|
26
|
+
self._sessions[session_id] = time.time()
|
|
27
|
+
self._idle_since = None
|
|
28
|
+
|
|
29
|
+
def release(self, session_id: str) -> None:
|
|
30
|
+
self._sessions.pop(session_id, None)
|
|
31
|
+
|
|
32
|
+
def touch(self, session_id: str) -> None:
|
|
33
|
+
if session_id in self._sessions:
|
|
34
|
+
self._sessions[session_id] = time.time()
|
|
35
|
+
|
|
36
|
+
def count(self) -> int:
|
|
37
|
+
return len(self._sessions)
|
|
38
|
+
|
|
39
|
+
def active_worker_count(self, api: ControllerAPI) -> int:
|
|
40
|
+
return sum(
|
|
41
|
+
1
|
|
42
|
+
for w in api.workers.list_workers()
|
|
43
|
+
if w.status in (WorkerStatus.PENDING, WorkerStatus.RUNNING)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def should_shutdown(self, api: ControllerAPI, *, idle_sec: float | None = None) -> bool:
|
|
47
|
+
"""True when no sessions, no active workers, and idle for *idle_sec*."""
|
|
48
|
+
threshold = idle_sec if idle_sec is not None else IDLE_SHUTDOWN_SEC
|
|
49
|
+
if self._sessions or self.active_worker_count(api) > 0:
|
|
50
|
+
self._idle_since = None
|
|
51
|
+
return False
|
|
52
|
+
now = time.time()
|
|
53
|
+
if self._idle_since is None:
|
|
54
|
+
self._idle_since = now
|
|
55
|
+
return threshold <= 0
|
|
56
|
+
return (now - self._idle_since) >= threshold
|
|
57
|
+
|
|
58
|
+
def status(self) -> dict:
|
|
59
|
+
return {
|
|
60
|
+
"sessions": self.count(),
|
|
61
|
+
"idle_since": self._idle_since,
|
|
62
|
+
"idle_shutdown_sec": IDLE_SHUTDOWN_SEC,
|
|
63
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Explicit controller settings (no environment variables in runtime code)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_LOOPBACK_UI_ORIGINS = (
|
|
12
|
+
"http://localhost:5173",
|
|
13
|
+
"http://127.0.0.1:5173",
|
|
14
|
+
"http://localhost:4173",
|
|
15
|
+
"http://127.0.0.1:4173",
|
|
16
|
+
"http://localhost:8080",
|
|
17
|
+
"http://127.0.0.1:8080",
|
|
18
|
+
"http://localhost:8090",
|
|
19
|
+
"http://127.0.0.1:8090",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_loopback_host(host: str) -> bool:
|
|
24
|
+
"""True for binds that only listen on the local machine."""
|
|
25
|
+
return host in {"127.0.0.1", "localhost", "::1"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ControllerSettings:
|
|
30
|
+
"""Flags the daemon / ``mas-lab serve`` pass into :func:`create_app`."""
|
|
31
|
+
|
|
32
|
+
host: str = "127.0.0.1"
|
|
33
|
+
port: int = 8090
|
|
34
|
+
cors_origins: list[str] = field(default_factory=list)
|
|
35
|
+
token: str | None = None
|
|
36
|
+
state_dir: Path | None = None
|
|
37
|
+
api_version: str = "0.3.0"
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def auth_mode(self) -> str:
|
|
41
|
+
return "bearer" if self.token else "none"
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def cors_allow_origins(self) -> list[str]:
|
|
45
|
+
if self.cors_origins:
|
|
46
|
+
return list(self.cors_origins)
|
|
47
|
+
if is_loopback_host(self.host):
|
|
48
|
+
return list(_LOOPBACK_UI_ORIGINS)
|
|
49
|
+
return []
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def resolved_state_dir(self) -> Path:
|
|
53
|
+
if self.state_dir is not None:
|
|
54
|
+
return Path(self.state_dir)
|
|
55
|
+
from mas.lab.controller.config import controller_dir
|
|
56
|
+
|
|
57
|
+
return controller_dir() / "state"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_token_file(path: Path) -> str:
|
|
61
|
+
"""Read a bearer token from a file. Empty files are treated as no token."""
|
|
62
|
+
text = path.read_text(encoding="utf-8").strip()
|
|
63
|
+
if not text:
|
|
64
|
+
raise ValueError(f"token file {path} is empty")
|
|
65
|
+
return text
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
from mas.lab.controller.ui_stream.app import mount_ui_stream, state
|
|
4
|
+
from mas.lab.controller.ui_stream.refs import STREAMS, DataSourceRef, envelope, ref_from_mapping
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"DataSourceRef",
|
|
8
|
+
"STREAMS",
|
|
9
|
+
"envelope",
|
|
10
|
+
"mount_ui_stream",
|
|
11
|
+
"ref_from_mapping",
|
|
12
|
+
"state",
|
|
13
|
+
]
|