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,271 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Unix-socket JSON-RPC client for the controller daemon."""
4
+ from __future__ import annotations
5
+
6
+ import atexit
7
+ import json
8
+ import logging
9
+ import os
10
+ import socket
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ import uuid
15
+ from pathlib import Path
16
+ from typing import Any, Dict, Optional
17
+
18
+ from mas.lab.controller import config as cfg
19
+ from mas.lab.controller.config import DEFAULT_HTTP_PORT, ensure_mas_dirs
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _process_session_id: str | None = None
24
+ _process_session_registered = False
25
+
26
+
27
+ def _release_process_session() -> None:
28
+ global _process_session_id
29
+ if not _process_session_id:
30
+ return
31
+ try:
32
+ ControllerClient().call(
33
+ "release_session",
34
+ {"session_id": _process_session_id},
35
+ timeout=5.0,
36
+ )
37
+ except Exception:
38
+ pass
39
+ _process_session_id = None
40
+
41
+
42
+ def _register_process_session(client: ControllerClient) -> str:
43
+ """Acquire one daemon session per OS process (CLI or test subprocess)."""
44
+ global _process_session_id, _process_session_registered
45
+ if _process_session_id is None:
46
+ _process_session_id = f"cli-{os.getpid()}-{uuid.uuid4().hex[:8]}"
47
+ if not _process_session_registered:
48
+ atexit.register(_release_process_session)
49
+ _process_session_registered = True
50
+ client.call("acquire_session", {"session_id": _process_session_id}, timeout=5.0)
51
+ return _process_session_id
52
+
53
+
54
+ class ControllerClient:
55
+ def __init__(self, socket_path: Path | None = None) -> None:
56
+ self.socket_path = Path(socket_path or cfg.socket_path())
57
+
58
+ def is_running(self) -> bool:
59
+ if not self.socket_path.exists():
60
+ return False
61
+ try:
62
+ self.call("ping")
63
+ return True
64
+ except OSError:
65
+ return False
66
+
67
+ def call(self, method: str, params: Optional[dict] = None, timeout: float = 30.0) -> Any:
68
+ payload = json.dumps({"method": method, "params": params or {}}).encode("utf-8")
69
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
70
+ sock.settimeout(timeout)
71
+ try:
72
+ sock.connect(str(self.socket_path))
73
+ sock.sendall(payload + b"\n")
74
+ chunks: list[bytes] = []
75
+ while True:
76
+ part = sock.recv(65536)
77
+ if not part:
78
+ break
79
+ chunks.append(part)
80
+ if b"\n" in part:
81
+ break
82
+ raw = b"".join(chunks).split(b"\n", 1)[0]
83
+ data = json.loads(raw.decode("utf-8"))
84
+ if "error" in data:
85
+ raise RuntimeError(data["error"])
86
+ return data.get("result")
87
+ finally:
88
+ sock.close()
89
+
90
+ def ensure_running(
91
+ self,
92
+ *,
93
+ port: int = DEFAULT_HTTP_PORT,
94
+ auto_start: bool = True,
95
+ restart_stale: bool = False,
96
+ ) -> None:
97
+ """Connect to the daemon, starting it if needed.
98
+
99
+ A daemon started from other ``mas.*`` sources or another environment
100
+ than this process is reported. With *restart_stale* an idle one is
101
+ restarted so new work runs with the caller's code and environment.
102
+ """
103
+ if self.is_running():
104
+ drift = self.drift()
105
+ if not drift:
106
+ _register_process_session(self)
107
+ return
108
+ if not restart_stale:
109
+ logger.warning(drift_message(drift))
110
+ _register_process_session(self)
111
+ return
112
+ self._stop_stale(drift)
113
+ if not auto_start:
114
+ raise RuntimeError(
115
+ f"Controller daemon is not running (socket {self.socket_path}). "
116
+ "Start it with: mas-lab control start"
117
+ )
118
+ start_daemon(port=port, detach=True)
119
+ for _ in range(40):
120
+ if self.is_running():
121
+ _register_process_session(self)
122
+ return
123
+ time.sleep(0.25)
124
+ raise RuntimeError("Controller daemon failed to start")
125
+
126
+ def drift(self) -> list[str]:
127
+ """Differences between the running daemon and this process (empty when in sync)."""
128
+ info = self.call("ping") or {}
129
+ issues: list[str] = []
130
+ if info.get("code") != cfg.code_fingerprint():
131
+ issues.append("it runs another mas-lab installation or older sources")
132
+ theirs = info.get("env")
133
+ if isinstance(theirs, dict):
134
+ ours = cfg.env_fingerprint()
135
+ changed = sorted(k for k in set(theirs) | set(ours) if theirs.get(k) != ours.get(k))
136
+ if changed:
137
+ issues.append("its environment differs for " + ", ".join(changed))
138
+ return issues
139
+
140
+ def _stop_stale(self, drift: list[str]) -> None:
141
+ running = (self.call("status") or {}).get("running", 0)
142
+ if running:
143
+ raise RuntimeError(
144
+ f"{drift_message(drift)} It has {running} running worker(s); wait for "
145
+ "them to finish, then run: mas-lab control stop"
146
+ )
147
+ logger.warning("Restarting controller daemon: %s.", "; ".join(drift))
148
+ pid_file = cfg.pid_path()
149
+ pid = pid_file.read_text(encoding="utf-8").strip() if pid_file.exists() else ""
150
+ stop_daemon()
151
+ # The old daemon unlinks the socket and pid file on exit; wait so it
152
+ # cannot remove the ones the new daemon creates.
153
+ if pid.isdigit():
154
+ for _ in range(40):
155
+ try:
156
+ os.kill(int(pid), 0)
157
+ except ProcessLookupError:
158
+ break
159
+ time.sleep(0.25)
160
+
161
+
162
+ def drift_message(drift: list[str]) -> str:
163
+ return (
164
+ f"The controller daemon ({cfg.socket_path()}) does not match this shell: "
165
+ + "; ".join(drift)
166
+ + ". Work it runs may behave differently from what you expect. "
167
+ "Run `mas-lab control stop`; the next command starts a fresh daemon from this shell."
168
+ )
169
+
170
+
171
+ def controller_session(*, port: int = DEFAULT_HTTP_PORT) -> ControllerClient:
172
+ """Context manager: ensure daemon + acquire session; release on exit."""
173
+ client = ControllerClient()
174
+ client.ensure_running(port=port, auto_start=True)
175
+ return client
176
+
177
+
178
+ def start_daemon(*, port: int = DEFAULT_HTTP_PORT, detach: bool = True, foreground: bool = False) -> None:
179
+ ensure_mas_dirs()
180
+ cmd = [
181
+ sys.executable,
182
+ "-m",
183
+ "mas.lab.controller.daemon",
184
+ "--port",
185
+ str(port),
186
+ ]
187
+ if os.environ.get("MAS_CONTROLLER_NO_HTTP", "1").strip().lower() in ("1", "true", "yes"):
188
+ cmd.append("--no-http")
189
+ if foreground:
190
+ subprocess.run(cmd, check=False)
191
+ return
192
+ env = os.environ.copy()
193
+ env.setdefault("MAS_CONTROLLER_PORT", str(port))
194
+ log_path = Path(os.environ.get("MAS_CONTROLLER_LOG", cfg.controller_dir() / "daemon.log"))
195
+ log_path.parent.mkdir(parents=True, exist_ok=True)
196
+ log_file = open(log_path, "a", encoding="utf-8")
197
+ subprocess.Popen(
198
+ cmd,
199
+ stdout=log_file,
200
+ stderr=subprocess.STDOUT,
201
+ start_new_session=detach,
202
+ env=env,
203
+ )
204
+
205
+
206
+ def stop_daemon() -> bool:
207
+ client = ControllerClient()
208
+ if not client.is_running():
209
+ cfg.pid_path().unlink(missing_ok=True)
210
+ cfg.socket_path().unlink(missing_ok=True)
211
+ return True
212
+ try:
213
+ client.call("shutdown")
214
+ except Exception:
215
+ pass
216
+ cfg.pid_path().unlink(missing_ok=True)
217
+ cfg.socket_path().unlink(missing_ok=True)
218
+ return True
219
+
220
+
221
+ def follow_worker(
222
+ worker_id: str,
223
+ *,
224
+ timeout: float = 86400.0,
225
+ poll: float = 0.5,
226
+ stream: bool = True,
227
+ ) -> Dict[str, Any]:
228
+ """Poll worker state until terminal; optionally stream stdout/stderr deltas to the terminal."""
229
+ import sys
230
+
231
+ client = ControllerClient()
232
+ client.ensure_running()
233
+ last_stdout = ""
234
+ last_stderr = ""
235
+ deadline = time.time() + timeout
236
+ terminal = {"completed", "failed", "cancelled"}
237
+
238
+ while time.time() < deadline:
239
+ detail = client.call("get_worker", {"worker_id": worker_id})
240
+ if detail is None:
241
+ raise RuntimeError(f"Worker {worker_id!r} not found")
242
+
243
+ if stream:
244
+ stdout = detail.get("stdout") or ""
245
+ stderr = detail.get("stderr") or ""
246
+ if len(stdout) > len(last_stdout):
247
+ sys.stdout.write(stdout[len(last_stdout) :])
248
+ sys.stdout.flush()
249
+ last_stdout = stdout
250
+ if len(stderr) > len(last_stderr):
251
+ sys.stderr.write(stderr[len(last_stderr) :])
252
+ sys.stderr.flush()
253
+ last_stderr = stderr
254
+
255
+ status = detail.get("status")
256
+ if status in terminal:
257
+ return detail
258
+ time.sleep(poll)
259
+
260
+ raise TimeoutError(f"Worker {worker_id!r} did not finish within {timeout}s")
261
+
262
+
263
+ def wait_for_worker(
264
+ worker_id: str,
265
+ *,
266
+ timeout: float = 86400.0,
267
+ poll: float = 1.0,
268
+ stream: bool = False,
269
+ ) -> Dict[str, Any]:
270
+ """Wait for a worker to finish (optional streaming via :func:`follow_worker`)."""
271
+ return follow_worker(worker_id, timeout=timeout, poll=poll, stream=stream)
@@ -0,0 +1,75 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Controller daemon paths and defaults."""
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ from collections.abc import Mapping
8
+ from pathlib import Path
9
+
10
+ from mas.runtime.xdg import mas_data_root
11
+
12
+ DEFAULT_HTTP_PORT = int(os.environ.get("MAS_CONTROLLER_PORT", "9000"))
13
+
14
+
15
+ def mas_dir() -> Path:
16
+ return Path(os.environ.get("MAS_HOME", mas_data_root()))
17
+
18
+
19
+ def controller_dir() -> Path:
20
+ return Path(os.environ.get("MAS_CONTROLLER_DIR", mas_dir() / "controller"))
21
+
22
+
23
+ def socket_path() -> Path:
24
+ return Path(os.environ.get("MAS_CONTROLLER_SOCKET", mas_dir() / "controller.sock"))
25
+
26
+
27
+ def pid_path() -> Path:
28
+ return Path(os.environ.get("MAS_CONTROLLER_PID", controller_dir() / "controller.pid"))
29
+
30
+
31
+ def ensure_mas_dirs() -> None:
32
+ mas_dir().mkdir(parents=True, exist_ok=True)
33
+ controller_dir().mkdir(parents=True, exist_ok=True)
34
+
35
+ def code_fingerprint() -> str:
36
+ """Identify the interpreter and installed ``mas.*`` sources.
37
+
38
+ The daemon keeps the modules it imported at startup, so a client compares
39
+ this value with the daemon's to detect an upgrade (wheel or editable).
40
+ """
41
+ import hashlib
42
+ import sys
43
+
44
+ import mas
45
+
46
+ digest = hashlib.sha256(sys.executable.encode("utf-8"))
47
+ for root in sorted(mas.__path__):
48
+ for dirpath, dirnames, filenames in os.walk(root):
49
+ dirnames[:] = sorted(d for d in dirnames if d != "__pycache__")
50
+ for name in sorted(filenames):
51
+ if not name.endswith(".py"):
52
+ continue
53
+ path = os.path.join(dirpath, name)
54
+ st = os.stat(path)
55
+ digest.update(f"{path}:{st.st_size}:{st.st_mtime_ns}\n".encode("utf-8"))
56
+ return digest.hexdigest()[:16]
57
+
58
+
59
+ _RUN_ENV_PREFIXES = ("MAS_", "OPENAI_", "AZURE_", "ANTHROPIC_", "LLM_", "LITELLM_", "OTEL_")
60
+ _RUN_ENV_NAMES = frozenset({"SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"})
61
+ # Set by the client/daemon machinery itself, not by the user.
62
+ _DAEMON_ENV_PREFIXES = ("MAS_CONTROLLER_", "MAS_DOTENV_LOADED")
63
+
64
+
65
+ def env_fingerprint(environ: Mapping[str, str] | None = None) -> dict[str, str]:
66
+ """Hash of each environment variable that can change a run; values never leave the process."""
67
+ import hashlib
68
+
69
+ env = os.environ if environ is None else environ
70
+ return {
71
+ name: hashlib.sha256(value.encode("utf-8")).hexdigest()[:12]
72
+ for name, value in env.items()
73
+ if (name.startswith(_RUN_ENV_PREFIXES) or name in _RUN_ENV_NAMES)
74
+ and not name.startswith(_DAEMON_ENV_PREFIXES)
75
+ }
@@ -0,0 +1,30 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Shared constants for the FastAPI controller API."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ from pathlib import Path
9
+
10
+ from mas.runtime.xdg import mas_cache_root, mas_data_root
11
+
12
+ LIBRARIES_DIR = Path(
13
+ os.getenv(
14
+ "MAS_LIBRARIES_DIR",
15
+ str(Path.home() / "mas-lab"),
16
+ )
17
+ )
18
+
19
+ MAX_TIMEOUT = 1800
20
+ MAX_UPLOAD_BYTES = 10 * 1024 * 1024
21
+
22
+ MAS_LAB_ROOT = Path(os.environ.get("MAS_LAB_ROOT", mas_data_root()))
23
+
24
+ WEB_SEARCH_CACHE_DIR = mas_cache_root() / "web_search"
25
+
26
+ HIDDEN_FILES = {".DS_Store", ".run_ref", ".gitkeep"}
27
+
28
+ SCHEMAS_DIR = Path(__file__).parent / "schemas"
29
+ PIPELINE_STEP_TYPES_PRE_PATH = SCHEMAS_DIR / "pipeline-step-types-pre.json"
30
+ PIPELINE_STEP_TYPES_POST_PATH = SCHEMAS_DIR / "pipeline-step-types-post.json"
@@ -0,0 +1,190 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Controller daemon — Unix socket IPC + optional HTTP."""
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import logging
9
+ import os
10
+ import signal
11
+ import socket
12
+ import threading
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from mas.lab.controller import config as cfg
17
+ from mas.lab.controller.api import ControllerAPI, set_controller_api
18
+ from mas.lab.controller.config import DEFAULT_HTTP_PORT, ensure_mas_dirs
19
+ from mas.lab.controller.sessions import SessionRegistry
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _shutdown = threading.Event()
24
+ _api: ControllerAPI | None = None
25
+ _sessions = SessionRegistry()
26
+ _code_fingerprint: str | None = None
27
+ _env_fingerprint: dict[str, str] | None = None
28
+
29
+
30
+ def _serve_http(*, host: str, port: int, settings=None) -> None:
31
+ import uvicorn
32
+ from mas.lab.controller.fastapi_app import create_app
33
+
34
+ app = create_app(settings) if settings is not None else create_app()
35
+ uvicorn.run(app, host=host, port=port, log_level="info")
36
+
37
+
38
+ def _handle_request(payload: dict) -> dict:
39
+ global _api
40
+ assert _api is not None
41
+ method = payload.get("method")
42
+ params = payload.get("params") or {}
43
+
44
+ dispatch = {
45
+ "ping": lambda _p: {"status": "ok", "code": _code_fingerprint, "env": _env_fingerprint},
46
+ "status": lambda _p: _api.status(),
47
+ "shutdown": lambda _p: _shutdown.set() or {"status": "stopping"},
48
+ "acquire_session": lambda p: _sessions.acquire(p["session_id"]) or {"ok": True},
49
+ "release_session": lambda p: _sessions.release(p["session_id"]) or {"ok": True},
50
+ "list_workers": lambda p: _api.list_workers(kind=p.get("kind"), status=p.get("status")),
51
+ "get_worker": lambda p: _api.get_worker(p["worker_id"]),
52
+ "cancel_worker": lambda p: {"ok": _api.cancel_worker(p["worker_id"])},
53
+ "submit_benchmark": lambda p: _api.submit_benchmark(p),
54
+ "submit_application": lambda p: _api.submit_application(p),
55
+ "submit_pipeline": lambda p: _api.submit_pipeline(p),
56
+ "list_libraries": lambda _p: _api.list_libraries(),
57
+ "list_runtime_runners": lambda _p: _api.list_runtime_runners(),
58
+ }
59
+ if method not in dispatch:
60
+ return {"error": f"unknown method: {method}"}
61
+ try:
62
+ return {"result": dispatch[method](params)}
63
+ except Exception as exc:
64
+ logger.exception("RPC %s failed", method)
65
+ return {"error": str(exc)}
66
+
67
+
68
+ def _serve_socket(path: Path) -> None:
69
+ path.parent.mkdir(parents=True, exist_ok=True)
70
+ if path.exists():
71
+ path.unlink()
72
+ server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
73
+ server.bind(str(path))
74
+ server.listen(5)
75
+ logger.info("Controller IPC listening on %s", path)
76
+
77
+ while not _shutdown.is_set():
78
+ server.settimeout(1.0)
79
+ try:
80
+ conn, _addr = server.accept()
81
+ except TimeoutError:
82
+ continue
83
+ except OSError:
84
+ break
85
+ with conn:
86
+ data = b""
87
+ while b"\n" not in data:
88
+ chunk = conn.recv(65536)
89
+ if not chunk:
90
+ break
91
+ data += chunk
92
+ if not data:
93
+ continue
94
+ line = data.split(b"\n", 1)[0]
95
+ try:
96
+ payload = json.loads(line.decode("utf-8"))
97
+ except json.JSONDecodeError:
98
+ try:
99
+ conn.sendall(json.dumps({"error": "invalid json"}).encode("utf-8") + b"\n")
100
+ except OSError:
101
+ pass
102
+ continue
103
+ response = _handle_request(payload)
104
+ conn.sendall(json.dumps(response).encode("utf-8") + b"\n")
105
+ server.close()
106
+ path.unlink(missing_ok=True)
107
+
108
+
109
+ def main(argv: list[str] | None = None) -> int:
110
+ parser = argparse.ArgumentParser(description="MAS Lab controller daemon")
111
+ parser.add_argument("--port", type=int, default=int(os.environ.get("MAS_CONTROLLER_PORT", DEFAULT_HTTP_PORT)))
112
+ parser.add_argument("--host", default="127.0.0.1")
113
+ parser.add_argument("--socket", type=Path, default=None)
114
+ parser.add_argument("--no-http", action="store_true")
115
+ parser.add_argument("--cors-origin", action="append", default=[])
116
+ parser.add_argument("--token-file", type=Path, default=None)
117
+ parser.add_argument("--state-dir", type=Path, default=None)
118
+ parser.add_argument("--workspace", type=Path, default=None)
119
+ args = parser.parse_args(argv)
120
+ if args.workspace is not None:
121
+ os.environ["MAS_WORKSPACE_ROOT"] = str(Path(args.workspace).expanduser().resolve())
122
+ socket = args.socket or cfg.socket_path()
123
+
124
+ from mas.lab.controller.settings import ControllerSettings, load_token_file
125
+
126
+ token = load_token_file(args.token_file) if args.token_file else None
127
+ http_settings = ControllerSettings(
128
+ host=args.host,
129
+ port=args.port,
130
+ cors_origins=list(args.cors_origin or []),
131
+ token=token,
132
+ state_dir=args.state_dir or (cfg.controller_dir() / "state"),
133
+ )
134
+
135
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
136
+ ensure_mas_dirs()
137
+
138
+ global _api, _code_fingerprint, _env_fingerprint
139
+ _code_fingerprint = cfg.code_fingerprint()
140
+ _env_fingerprint = cfg.env_fingerprint()
141
+ try:
142
+ from mas.lab.workspace import WorkspaceConfig
143
+
144
+ workspace = WorkspaceConfig.load()
145
+ except Exception:
146
+ workspace = None
147
+ _api = ControllerAPI(workspace)
148
+ set_controller_api(_api)
149
+
150
+ cfg.pid_path().write_text(str(os.getpid()), encoding="utf-8")
151
+
152
+ def _handle_signal(signum: int, _frame: Any) -> None:
153
+ logger.info("Received signal %s — shutting down", signum)
154
+ _shutdown.set()
155
+
156
+ signal.signal(signal.SIGTERM, _handle_signal)
157
+ signal.signal(signal.SIGINT, _handle_signal)
158
+
159
+ threads = [
160
+ threading.Thread(target=_serve_socket, args=(socket,), daemon=True, name="ipc"),
161
+ ]
162
+ if not args.no_http:
163
+ threads.append(
164
+ threading.Thread(
165
+ target=_serve_http,
166
+ kwargs={"host": args.host, "port": args.port, "settings": http_settings},
167
+ daemon=True,
168
+ name="http",
169
+ )
170
+ )
171
+ for thread in threads:
172
+ thread.start()
173
+
174
+ logger.info("MAS Lab controller running (HTTP :%s)", args.port)
175
+ try:
176
+ while not _shutdown.is_set():
177
+ if _sessions.should_shutdown(_api):
178
+ logger.info(
179
+ "Idle shutdown — no client sessions and no active workers"
180
+ )
181
+ _shutdown.set()
182
+ break
183
+ _shutdown.wait(timeout=5.0)
184
+ finally:
185
+ cfg.pid_path().unlink(missing_ok=True)
186
+ return 0
187
+
188
+
189
+ if __name__ == "__main__":
190
+ raise SystemExit(main())