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,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())
|