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,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
+ ]