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,2147 @@
|
|
|
1
|
+
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""UI stream plane hosted by mas-lab serve: envelopes, /ws, commands, replay-by-id."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import uuid
|
|
12
|
+
from contextlib import asynccontextmanager
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
|
18
|
+
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
|
19
|
+
from mas.lab.controller.ui_stream.auth import authorize_http, authorize_ws, security_headers
|
|
20
|
+
from mas.lab.controller.ui_stream.bus import EventBus
|
|
21
|
+
from mas.lab.controller.ui_stream.governance import GovernanceStore
|
|
22
|
+
from mas.lab.controller.ui_stream.ingest import NativeIngest
|
|
23
|
+
from mas.lab.controller.ui_stream.inspect import extra_native_kinds, infer_inspect
|
|
24
|
+
from mas.lab.controller.ui_stream.layouts import load_layout
|
|
25
|
+
from mas.lab.controller.ui_stream.metrics import MetricsStore
|
|
26
|
+
from mas.lab.controller.ui_stream.refs import (
|
|
27
|
+
STREAMS,
|
|
28
|
+
DataSourceRef,
|
|
29
|
+
envelope,
|
|
30
|
+
envelope_from_mapping,
|
|
31
|
+
ref_from_mapping,
|
|
32
|
+
reset_clock,
|
|
33
|
+
reset_nonce,
|
|
34
|
+
use_clock,
|
|
35
|
+
use_nonce,
|
|
36
|
+
)
|
|
37
|
+
from mas.lab.controller.ui_stream.timeline import (
|
|
38
|
+
envelopes_to_scheduled,
|
|
39
|
+
merge_timeline,
|
|
40
|
+
pump_timeline,
|
|
41
|
+
records_to_scheduled,
|
|
42
|
+
)
|
|
43
|
+
from mas.lab.controller.ui_stream.ui_trace import split_envelopes, write_ui_trace
|
|
44
|
+
from mas.lab.controller.ui_stream.replay import (
|
|
45
|
+
custom_panel_records,
|
|
46
|
+
demo_records,
|
|
47
|
+
merge_replay_records,
|
|
48
|
+
parse_jsonl,
|
|
49
|
+
trace_metric_records,
|
|
50
|
+
)
|
|
51
|
+
from mas.lab.controller.ui_stream.run_index import RunRecord, infer_mas_rel, resolve_run
|
|
52
|
+
from mas.lab.controller.ui_stream.spec_load import snapshot_for_ref
|
|
53
|
+
from mas.lab.controller.ui_stream.stream import command_payload, is_command_frame, parse_frame
|
|
54
|
+
from mas.runtime.constants import WORKSPACE_CONFIG_FILENAME
|
|
55
|
+
from pydantic import BaseModel, Field
|
|
56
|
+
|
|
57
|
+
logger = logging.getLogger(__name__)
|
|
58
|
+
|
|
59
|
+
UI_ORIGIN = "demo-ui"
|
|
60
|
+
CONTROL_VERBS = frozenset(
|
|
61
|
+
{
|
|
62
|
+
"pause",
|
|
63
|
+
"resume",
|
|
64
|
+
"steer",
|
|
65
|
+
"enqueue_input",
|
|
66
|
+
"send_message",
|
|
67
|
+
"navigate",
|
|
68
|
+
"inspect",
|
|
69
|
+
"inspect_context",
|
|
70
|
+
"list_checkpoints",
|
|
71
|
+
"cancel_inflight",
|
|
72
|
+
"snapshot",
|
|
73
|
+
"persist",
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _stock_layouts() -> Path:
|
|
79
|
+
here = Path(__file__).resolve()
|
|
80
|
+
for parent in here.parents:
|
|
81
|
+
candidate = parent / "demo-ui" / "layouts"
|
|
82
|
+
if candidate.is_dir():
|
|
83
|
+
return candidate
|
|
84
|
+
return Path.cwd() / "demo-ui" / "layouts"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def layouts_root() -> Path:
|
|
88
|
+
override = os.environ.get("MAS_DEMO_UI_LAYOUTS")
|
|
89
|
+
if override:
|
|
90
|
+
first = override.split(os.pathsep)[0].strip()
|
|
91
|
+
if first:
|
|
92
|
+
return Path(first).expanduser()
|
|
93
|
+
return _stock_layouts()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def layout_roots() -> list[Path]:
|
|
97
|
+
seen: set[Path] = set()
|
|
98
|
+
roots: list[Path] = []
|
|
99
|
+
|
|
100
|
+
def _add(path: Path) -> None:
|
|
101
|
+
resolved = path.expanduser().resolve()
|
|
102
|
+
if resolved.is_dir() and resolved not in seen:
|
|
103
|
+
seen.add(resolved)
|
|
104
|
+
roots.append(resolved)
|
|
105
|
+
|
|
106
|
+
_add(_stock_layouts())
|
|
107
|
+
for raw in os.environ.get("MAS_DEMO_UI_LAYOUTS", "").split(os.pathsep):
|
|
108
|
+
raw = raw.strip()
|
|
109
|
+
if raw:
|
|
110
|
+
_add(Path(raw))
|
|
111
|
+
for raw in os.environ.get("MAS_LIBRARY_PATHS", "").split(os.pathsep):
|
|
112
|
+
raw = raw.strip()
|
|
113
|
+
if not raw:
|
|
114
|
+
continue
|
|
115
|
+
base = Path(raw).expanduser()
|
|
116
|
+
_add(base / "demo-ui" / "layouts")
|
|
117
|
+
if base.name == "layouts":
|
|
118
|
+
_add(base)
|
|
119
|
+
if base.is_dir():
|
|
120
|
+
nested = (
|
|
121
|
+
list(base.glob("*/demo-ui/layouts"))
|
|
122
|
+
+ list(base.glob("*.lab/demo-ui/layouts"))
|
|
123
|
+
+ list(base.glob("labs/*.lab/demo-ui/layouts"))
|
|
124
|
+
)
|
|
125
|
+
for layouts in nested:
|
|
126
|
+
_add(layouts)
|
|
127
|
+
return roots
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _controller_ingest_url() -> str:
|
|
131
|
+
base = os.environ.get("MAS_CONTROLLER_URL") or os.environ.get("MAS_DEMO_UI_INGEST_URL") or "http://127.0.0.1:8090"
|
|
132
|
+
return base.rstrip("/") + ("/api/ingest" if not base.rstrip("/").endswith("/api/ingest") else "")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class UiStreamState:
|
|
136
|
+
def __init__(self) -> None:
|
|
137
|
+
self.bus = EventBus()
|
|
138
|
+
self.metrics = MetricsStore()
|
|
139
|
+
self.governance = GovernanceStore()
|
|
140
|
+
self.ingest = NativeIngest(self.bus, self.metrics, self.governance)
|
|
141
|
+
self.ingest._demand = self.wants_inspect
|
|
142
|
+
self.ingest._on_inspect = self._schedule_inspect
|
|
143
|
+
self._inspect_ids: dict[int, tuple[str, ...]] = {}
|
|
144
|
+
self._inspect_tasks: dict[str, asyncio.Task[None]] = {}
|
|
145
|
+
self._job_seen: dict[str, str] = {}
|
|
146
|
+
self._job_refs: dict[str, DataSourceRef] = {}
|
|
147
|
+
self._runs: list[dict[str, Any]] = []
|
|
148
|
+
self._deleted: set[str] = set()
|
|
149
|
+
self._indexed: dict[str, RunRecord] = {}
|
|
150
|
+
self._replay_tasks: dict[str, asyncio.Task[None]] = {}
|
|
151
|
+
self._job_progress: dict[str, dict[str, Any]] = {}
|
|
152
|
+
self._poll_task: asyncio.Task[None] | None = None
|
|
153
|
+
self._closed_until_run = False
|
|
154
|
+
self._replay_nonce: str | None = None
|
|
155
|
+
|
|
156
|
+
def lookup_job(self, job_id: str) -> dict[str, Any] | None:
|
|
157
|
+
"""Replay/UI job status. Used by GET /api/jobs/{id} (controller route wins)."""
|
|
158
|
+
if job_id in self._deleted:
|
|
159
|
+
return None
|
|
160
|
+
status = self._job_seen.get(job_id)
|
|
161
|
+
if not status and job_id not in self._replay_tasks:
|
|
162
|
+
return None
|
|
163
|
+
ref = self._job_refs.get(job_id)
|
|
164
|
+
payload: dict[str, Any] = {
|
|
165
|
+
"id": job_id,
|
|
166
|
+
"status": status or "running",
|
|
167
|
+
"origin": UI_ORIGIN,
|
|
168
|
+
"ref": ref.as_dict() if ref else {},
|
|
169
|
+
}
|
|
170
|
+
progress = self._job_progress.get(job_id)
|
|
171
|
+
if isinstance(progress, dict):
|
|
172
|
+
payload.update(progress)
|
|
173
|
+
return payload
|
|
174
|
+
|
|
175
|
+
def reset(self) -> None:
|
|
176
|
+
self.bus.clear()
|
|
177
|
+
self.metrics.clear()
|
|
178
|
+
self.governance.clear()
|
|
179
|
+
self.ingest.clear()
|
|
180
|
+
self._job_seen.clear()
|
|
181
|
+
self._job_refs.clear()
|
|
182
|
+
self._job_progress.clear()
|
|
183
|
+
self._runs.clear()
|
|
184
|
+
self._deleted.clear()
|
|
185
|
+
self._indexed.clear()
|
|
186
|
+
self._closed_until_run = False
|
|
187
|
+
self._inspect_ids.clear()
|
|
188
|
+
for task in list(self._inspect_tasks.values()):
|
|
189
|
+
task.cancel()
|
|
190
|
+
self._inspect_tasks.clear()
|
|
191
|
+
for task in list(self._replay_tasks.values()):
|
|
192
|
+
task.cancel()
|
|
193
|
+
self._replay_tasks.clear()
|
|
194
|
+
self._replay_nonce = None
|
|
195
|
+
|
|
196
|
+
def wants_inspect(self, pull_id: str, ref: DataSourceRef) -> bool:
|
|
197
|
+
from mas.lab.controller.ui_stream.inspect import PULLS
|
|
198
|
+
|
|
199
|
+
spec = PULLS.get(pull_id)
|
|
200
|
+
for sub in self.bus._subs:
|
|
201
|
+
if pull_id in self._inspect_ids.get(id(sub), ()) and sub.ref.matches(ref):
|
|
202
|
+
return True
|
|
203
|
+
if spec is not None and self.bus.demands_topic(spec.topic, ref):
|
|
204
|
+
return True
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
def inspect_native_kinds(self, ref: DataSourceRef) -> frozenset[str]:
|
|
208
|
+
ids: list[str] = []
|
|
209
|
+
for sub in self.bus._subs:
|
|
210
|
+
ids.extend(self._inspect_ids.get(id(sub), infer_inspect(sub.topics)))
|
|
211
|
+
if self.wants_inspect("context", ref) and "context" not in ids:
|
|
212
|
+
ids.append("context")
|
|
213
|
+
return extra_native_kinds(ids)
|
|
214
|
+
|
|
215
|
+
def _schedule_inspect(self, pull_id: str, ref: DataSourceRef) -> None:
|
|
216
|
+
if pull_id != "context" or not ref.session:
|
|
217
|
+
return
|
|
218
|
+
if not self.wants_inspect("context", ref):
|
|
219
|
+
return
|
|
220
|
+
key = ref.session
|
|
221
|
+
existing = self._inspect_tasks.get(key)
|
|
222
|
+
if existing is not None and not existing.done():
|
|
223
|
+
return
|
|
224
|
+
try:
|
|
225
|
+
loop = asyncio.get_running_loop()
|
|
226
|
+
except RuntimeError:
|
|
227
|
+
return
|
|
228
|
+
self._inspect_tasks[key] = loop.create_task(_pull_inspect_context(ref), name=f"inspect-ctx-{key[:8]}")
|
|
229
|
+
|
|
230
|
+
async def close(self) -> None:
|
|
231
|
+
if self._poll_task is not None:
|
|
232
|
+
self._poll_task.cancel()
|
|
233
|
+
self.reset()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
state = UiStreamState()
|
|
237
|
+
router = APIRouter()
|
|
238
|
+
_MAX_INGEST_RECORDS = 20_000
|
|
239
|
+
_MAX_REPLAY_TASKS = 8
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _prune_replay_tasks() -> None:
|
|
243
|
+
for job_id, task in list(state._replay_tasks.items()):
|
|
244
|
+
if task.done():
|
|
245
|
+
state._replay_tasks.pop(job_id, None)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _guard_ingest(n: int) -> None:
|
|
249
|
+
if n > _MAX_INGEST_RECORDS:
|
|
250
|
+
raise HTTPException(status_code=413, detail="ingest payload too large")
|
|
251
|
+
_prune_replay_tasks()
|
|
252
|
+
if len(state._replay_tasks) >= _MAX_REPLAY_TASKS:
|
|
253
|
+
raise HTTPException(status_code=429, detail="too many ingest jobs")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _register_replay_task(job_id: str, task: asyncio.Task[None]) -> None:
|
|
257
|
+
state._replay_tasks[job_id] = task
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _lib_dir(library: str) -> Path:
|
|
261
|
+
from mas.lab.controller.deps import get_library_path
|
|
262
|
+
|
|
263
|
+
return get_library_path(library)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _abs_infra(lib_dir: Path, ref: str) -> str:
|
|
267
|
+
path = Path(ref)
|
|
268
|
+
if not path.is_absolute():
|
|
269
|
+
path = (lib_dir / path).resolve()
|
|
270
|
+
return str(path)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _workspace_infra_refs(lib_dir: Path) -> list[str]:
|
|
274
|
+
"""Library workspace-config infra, else an SRE demo bundle if present."""
|
|
275
|
+
refs: list[str] = []
|
|
276
|
+
cfg = lib_dir / WORKSPACE_CONFIG_FILENAME
|
|
277
|
+
if cfg.is_file():
|
|
278
|
+
try:
|
|
279
|
+
from mas.runtime.spec.source import load_yaml_file
|
|
280
|
+
|
|
281
|
+
data = load_yaml_file(cfg) or {}
|
|
282
|
+
except Exception:
|
|
283
|
+
data = {}
|
|
284
|
+
if not isinstance(data, dict):
|
|
285
|
+
data = {}
|
|
286
|
+
raw = data.get("infra_refs") or data.get("default_infra")
|
|
287
|
+
if isinstance(raw, str) and raw.strip():
|
|
288
|
+
refs = [raw.strip()]
|
|
289
|
+
elif isinstance(raw, list):
|
|
290
|
+
refs = [str(item).strip() for item in raw if str(item).strip()]
|
|
291
|
+
if not refs:
|
|
292
|
+
bundle = lib_dir / "infra" / "demo.yaml"
|
|
293
|
+
if bundle.is_file():
|
|
294
|
+
refs = [str(bundle)]
|
|
295
|
+
return [_abs_infra(lib_dir, item) for item in refs]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _scenario_cache_infra(lib_dir: Path, scenario: str) -> list[str]:
|
|
299
|
+
name = str(scenario or "").strip().lower()
|
|
300
|
+
if not name:
|
|
301
|
+
return []
|
|
302
|
+
infra = lib_dir / "infra"
|
|
303
|
+
for filename in (
|
|
304
|
+
f"llm-cache-demo-{name}-replay.yaml",
|
|
305
|
+
f"llm-cache-{name}-replay.yaml",
|
|
306
|
+
):
|
|
307
|
+
path = infra / filename
|
|
308
|
+
if path.is_file():
|
|
309
|
+
return [str(path.resolve())]
|
|
310
|
+
return []
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _live_infra(lib_dir: Path, targets: list[DataSourceRef]) -> tuple[list[str], dict[str, list[str]]]:
|
|
314
|
+
base = _workspace_infra_refs(lib_dir)
|
|
315
|
+
per: dict[str, list[str]] = {}
|
|
316
|
+
scenarios = [str(item.scenario) for item in targets if item.scenario]
|
|
317
|
+
if not scenarios:
|
|
318
|
+
infra = lib_dir / "infra"
|
|
319
|
+
if infra.is_dir():
|
|
320
|
+
for path in sorted(infra.glob("llm-cache-demo-*-replay.yaml")):
|
|
321
|
+
stem = path.stem.replace("llm-cache-demo-", "").replace("-replay", "")
|
|
322
|
+
if stem:
|
|
323
|
+
scenarios.append(stem)
|
|
324
|
+
for scenario in scenarios:
|
|
325
|
+
extra = _scenario_cache_infra(lib_dir, scenario)
|
|
326
|
+
if extra:
|
|
327
|
+
per[scenario] = [*base, *extra]
|
|
328
|
+
return base, per
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _live_overlays(targets: list[DataSourceRef]) -> dict[str, list[str]]:
|
|
332
|
+
"""Lane overlays from the layout (e.g. baseline-round-cap) keyed by scenario."""
|
|
333
|
+
per: dict[str, list[str]] = {}
|
|
334
|
+
for item in targets:
|
|
335
|
+
scenario = str(item.scenario or "").strip()
|
|
336
|
+
overlay = str(item.overlay or "").strip()
|
|
337
|
+
if not scenario or not overlay:
|
|
338
|
+
continue
|
|
339
|
+
bucket = per.setdefault(scenario, [])
|
|
340
|
+
if overlay not in bucket:
|
|
341
|
+
bucket.append(overlay)
|
|
342
|
+
return per
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _session_dir() -> Path:
|
|
346
|
+
raw = os.environ.get("MAS_SESSION_DIRECTORY") or os.environ.get("MAS_CONTROL_DIRECTORY")
|
|
347
|
+
if raw:
|
|
348
|
+
return Path(raw).expanduser()
|
|
349
|
+
return Path.home() / ".local" / "share" / "mas" / "sessions"
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
async def _pull_inspect_context(ref: DataSourceRef) -> None:
|
|
353
|
+
"""Live inspect_context only while a UI plugin has subscribed to it."""
|
|
354
|
+
if not ref.session or not state.wants_inspect("context", ref):
|
|
355
|
+
return
|
|
356
|
+
try:
|
|
357
|
+
from mas.library.standard.plugins.control.attach import attach
|
|
358
|
+
from mas.library.standard.plugins.control.directory import FileSessionDirectory
|
|
359
|
+
from mas.library.standard.plugins.governance.context_usage import snapshot_from_view
|
|
360
|
+
except Exception:
|
|
361
|
+
return
|
|
362
|
+
try:
|
|
363
|
+
client = await attach(FileSessionDirectory(_session_dir()), str(ref.session))
|
|
364
|
+
except Exception:
|
|
365
|
+
return
|
|
366
|
+
try:
|
|
367
|
+
view = await client.call("inspect_context", str(ref.session))
|
|
368
|
+
except Exception:
|
|
369
|
+
logger.debug("inspect_context skipped", exc_info=True)
|
|
370
|
+
return
|
|
371
|
+
finally:
|
|
372
|
+
try:
|
|
373
|
+
await client.close()
|
|
374
|
+
except Exception:
|
|
375
|
+
pass
|
|
376
|
+
payload = snapshot_from_view(view if isinstance(view, dict) else {})
|
|
377
|
+
if not payload:
|
|
378
|
+
return
|
|
379
|
+
payload["kind"] = "context_usage"
|
|
380
|
+
stats = state.governance.apply(ref, payload)
|
|
381
|
+
stats.pop("_updated", None)
|
|
382
|
+
state.bus.publish(envelope("governance.context", ref, "governance", stats))
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _stamp_ui(ref: DataSourceRef) -> DataSourceRef:
|
|
386
|
+
return ref.merge({"origin": UI_ORIGIN})
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _streams() -> dict[str, str]:
|
|
390
|
+
return dict(STREAMS)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
class CommandBody(BaseModel):
|
|
394
|
+
verb: str
|
|
395
|
+
ref: dict[str, Any] = Field(default_factory=dict)
|
|
396
|
+
text: str = ""
|
|
397
|
+
mode: str = "inject_now"
|
|
398
|
+
reason: str = "demo-ui"
|
|
399
|
+
to: str = ""
|
|
400
|
+
query: str = ""
|
|
401
|
+
overlays: list[str] = Field(default_factory=list)
|
|
402
|
+
infra_refs: list[str] = Field(default_factory=list)
|
|
403
|
+
timeout: int = 0
|
|
404
|
+
targets: list[dict[str, Any]] = Field(default_factory=list)
|
|
405
|
+
pre: list[dict[str, Any]] = Field(default_factory=list)
|
|
406
|
+
experiment: str = ""
|
|
407
|
+
batch: str = ""
|
|
408
|
+
speed: float = 0.0
|
|
409
|
+
nonce: str = ""
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
class IngestBody(BaseModel):
|
|
413
|
+
ref: dict[str, Any] = Field(default_factory=dict)
|
|
414
|
+
record: dict[str, Any] | None = None
|
|
415
|
+
records: list[dict[str, Any]] = Field(default_factory=list)
|
|
416
|
+
replay: bool = False
|
|
417
|
+
speed: float = 0.0
|
|
418
|
+
nonce: str = ""
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
class AlignedLane(BaseModel):
|
|
422
|
+
ref: dict[str, Any] = Field(default_factory=dict)
|
|
423
|
+
records: list[dict[str, Any]] = Field(default_factory=list)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class AlignedIngestBody(BaseModel):
|
|
427
|
+
lanes: list[AlignedLane] = Field(default_factory=list)
|
|
428
|
+
speed: float = 1.0
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
class EnvelopeLane(BaseModel):
|
|
432
|
+
tree: str = "runtime"
|
|
433
|
+
envelopes: list[dict[str, Any]] = Field(default_factory=list)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class EnvelopeIngestBody(BaseModel):
|
|
437
|
+
lanes: list[EnvelopeLane] = Field(default_factory=list)
|
|
438
|
+
speed: float = 1.0
|
|
439
|
+
recompute: bool = False
|
|
440
|
+
trees: list[str] = Field(default_factory=list)
|
|
441
|
+
cached_artefacts: list[str] = Field(default_factory=list)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class StreamFrameBody(BaseModel):
|
|
445
|
+
topic: str = ""
|
|
446
|
+
ref: dict[str, Any] = Field(default_factory=dict)
|
|
447
|
+
source: str = "artifact"
|
|
448
|
+
payload: Any = None
|
|
449
|
+
replay: bool = False
|
|
450
|
+
id: str | None = None
|
|
451
|
+
verb: str = ""
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@router.get("/api/catalog/libraries")
|
|
455
|
+
async def catalog_libraries() -> dict[str, Any]:
|
|
456
|
+
from mas.lab.controller.deps import get_manifest_store
|
|
457
|
+
|
|
458
|
+
return {"libraries": get_manifest_store().libraries()}
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
@router.get("/api/catalog/libraries/{library}/experiments")
|
|
462
|
+
async def catalog_experiments(library: str) -> dict[str, Any]:
|
|
463
|
+
from mas.lab.controller.deps import get_manifest_store
|
|
464
|
+
|
|
465
|
+
return {"experiments": get_manifest_store().list_experiments(library)}
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
@router.get("/api/catalog/libraries/{library}/overlays")
|
|
469
|
+
async def catalog_overlays(library: str) -> Any:
|
|
470
|
+
from mas.lab.controller.deps import get_manifest_store
|
|
471
|
+
|
|
472
|
+
return {"overlays": get_manifest_store().list_overlays(library)}
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
@router.get("/api/sessions")
|
|
476
|
+
async def sessions() -> dict[str, Any]:
|
|
477
|
+
try:
|
|
478
|
+
from mas.library.standard.plugins.control.directory import FileSessionDirectory
|
|
479
|
+
|
|
480
|
+
directory = FileSessionDirectory(_session_dir())
|
|
481
|
+
rows = []
|
|
482
|
+
for ad in directory.list():
|
|
483
|
+
rows.append({**ad.as_dict(), "live": directory.is_live(ad.session_id), "token": ""})
|
|
484
|
+
return {"sessions": rows}
|
|
485
|
+
except Exception:
|
|
486
|
+
return {"sessions": []}
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
@router.get("/api/runs")
|
|
490
|
+
async def list_runs() -> dict[str, Any]:
|
|
491
|
+
return {"runs": _runs_payload()}
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _artefact_catalog() -> list[dict[str, Any]]:
|
|
495
|
+
from mas.lab.lab.config.artifact_types import list_artifact_types
|
|
496
|
+
|
|
497
|
+
rows: list[dict[str, Any]] = []
|
|
498
|
+
for name, info in list_artifact_types().items():
|
|
499
|
+
if not (info.get("realtime") or info.get("stream") or info.get("plot")):
|
|
500
|
+
continue
|
|
501
|
+
rows.append({"name": name, **info})
|
|
502
|
+
return rows
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _artefact_info(name: str) -> dict[str, Any]:
|
|
506
|
+
from mas.lab.controller.ui_stream.artefact_bind import LIVE_GRAPH, SHORTCUTS, canonical_artefact, link_for
|
|
507
|
+
from mas.lab.lab.config.artifact_types import type_info
|
|
508
|
+
|
|
509
|
+
key = str(name or "").strip()
|
|
510
|
+
info = type_info(key)
|
|
511
|
+
if not info:
|
|
512
|
+
try:
|
|
513
|
+
info = type_info(canonical_artefact(key))
|
|
514
|
+
except Exception:
|
|
515
|
+
info = {}
|
|
516
|
+
if info:
|
|
517
|
+
return {"name": key, **info}
|
|
518
|
+
for row in _artefact_catalog():
|
|
519
|
+
if row.get("name") == key:
|
|
520
|
+
return row
|
|
521
|
+
canon = canonical_artefact(key)
|
|
522
|
+
if canon in LIVE_GRAPH or key in SHORTCUTS:
|
|
523
|
+
link = link_for(canon)
|
|
524
|
+
return {
|
|
525
|
+
"name": key,
|
|
526
|
+
"stream": link.stream,
|
|
527
|
+
"plot": link.plot,
|
|
528
|
+
"realtime": True,
|
|
529
|
+
"address": link.address,
|
|
530
|
+
}
|
|
531
|
+
if key.startswith("series.") or ("." not in key and key):
|
|
532
|
+
stream = key if key.startswith("series.") else f"series.{key}"
|
|
533
|
+
return {
|
|
534
|
+
"name": key[7:] if key.startswith("series.") else key,
|
|
535
|
+
"stream": stream,
|
|
536
|
+
"plot": "series",
|
|
537
|
+
"realtime": True,
|
|
538
|
+
}
|
|
539
|
+
raise KeyError(key)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _plot_info(kind: str) -> dict[str, str]:
|
|
543
|
+
"""Kind plus ``stream_for(kind)`` — the artefact owner the plot consumes."""
|
|
544
|
+
name = str(kind or "").strip()
|
|
545
|
+
try:
|
|
546
|
+
from mas.lab.plots.providers import plot_info
|
|
547
|
+
|
|
548
|
+
return plot_info(name)
|
|
549
|
+
except (ImportError, KeyError):
|
|
550
|
+
pass
|
|
551
|
+
try:
|
|
552
|
+
row = _artefact_info(name)
|
|
553
|
+
stream = str(row.get("stream") or "")
|
|
554
|
+
plot = str(row.get("plot") or "")
|
|
555
|
+
if stream and plot and plot != "series":
|
|
556
|
+
return {
|
|
557
|
+
"kind": plot,
|
|
558
|
+
"stream": stream,
|
|
559
|
+
"artefact": str(row.get("name") or name),
|
|
560
|
+
}
|
|
561
|
+
except KeyError:
|
|
562
|
+
pass
|
|
563
|
+
raise KeyError(name)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _latest_artefact_payload(path: Path) -> Any:
|
|
567
|
+
text = path.read_text(encoding="utf-8")
|
|
568
|
+
if path.suffix == ".jsonl" or path.name.endswith(".jsonl"):
|
|
569
|
+
last = None
|
|
570
|
+
for line in text.splitlines():
|
|
571
|
+
line = line.strip()
|
|
572
|
+
if not line:
|
|
573
|
+
continue
|
|
574
|
+
last = json.loads(line)
|
|
575
|
+
return last
|
|
576
|
+
if path.suffix in {".json", ".yaml", ".yml"}:
|
|
577
|
+
if path.suffix == ".json":
|
|
578
|
+
return json.loads(text)
|
|
579
|
+
import yaml
|
|
580
|
+
|
|
581
|
+
return yaml.safe_load(text)
|
|
582
|
+
return None
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _artefact_file(record: RunRecord, name: str) -> Path | None:
|
|
586
|
+
artefacts = record.artefacts or {}
|
|
587
|
+
token = name.replace("-", "_")
|
|
588
|
+
for key in (name, token, f"custom_{name}", f"custom_{token}"):
|
|
589
|
+
path = artefacts.get(key)
|
|
590
|
+
if path:
|
|
591
|
+
return Path(path)
|
|
592
|
+
return None
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _render_plot_html(kind: str, fmt: str = "html") -> str | bytes:
|
|
596
|
+
name = str(kind or "").strip()
|
|
597
|
+
fmt = str(fmt or "html").lower()
|
|
598
|
+
try:
|
|
599
|
+
from mas.lab.plots.providers import render_plot
|
|
600
|
+
|
|
601
|
+
return render_plot(name, {}, fmt=fmt)
|
|
602
|
+
except (ImportError, KeyError):
|
|
603
|
+
pass
|
|
604
|
+
raise KeyError(name)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
@router.get("/api/artefacts")
|
|
608
|
+
async def list_artefacts() -> dict[str, Any]:
|
|
609
|
+
return {"artefacts": _artefact_catalog()}
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _artefact_body(name: str, ref: DataSourceRef, info: dict[str, Any], payload: Any) -> dict[str, Any]:
|
|
613
|
+
from mas.lab.controller.ui_stream.artefact_bind import is_sealed_topic, link_for
|
|
614
|
+
|
|
615
|
+
link = link_for(name, ref.as_dict())
|
|
616
|
+
stream = str(info.get("stream") or link.stream or "")
|
|
617
|
+
ticks = state.bus.snapshot([stream, f"{stream}.*"], ref) if stream else []
|
|
618
|
+
complete = any(
|
|
619
|
+
(getattr(item, "complete", None) is True) or is_sealed_topic(str(getattr(item, "topic", "") or ""))
|
|
620
|
+
for item in ticks
|
|
621
|
+
)
|
|
622
|
+
return {
|
|
623
|
+
**link.as_dict(),
|
|
624
|
+
"name": name,
|
|
625
|
+
"artefact": link.name,
|
|
626
|
+
"stream": stream or link.stream,
|
|
627
|
+
"plot": info.get("plot") or link.plot,
|
|
628
|
+
"ref": ref.as_dict(),
|
|
629
|
+
"payload": payload,
|
|
630
|
+
"complete": complete,
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _bus_latest_artefact(stream: str, ref: DataSourceRef) -> Any:
|
|
635
|
+
if not stream:
|
|
636
|
+
return None
|
|
637
|
+
globs = [stream, f"{stream}.*"]
|
|
638
|
+
events = state.bus.snapshot(globs, ref)
|
|
639
|
+
if not events and ref.scenario:
|
|
640
|
+
events = state.bus.snapshot(globs, DataSourceRef(scenario=ref.scenario))
|
|
641
|
+
if not events:
|
|
642
|
+
events = state.bus.snapshot(globs, DataSourceRef())
|
|
643
|
+
return events[-1].payload if events else None
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
@router.get("/api/artefacts/{name}")
|
|
647
|
+
async def get_artefact_latest(
|
|
648
|
+
name: str,
|
|
649
|
+
library: str | None = None,
|
|
650
|
+
experiment: str | None = None,
|
|
651
|
+
scenario: str | None = None,
|
|
652
|
+
test: str | None = None,
|
|
653
|
+
run: str | None = None,
|
|
654
|
+
) -> dict[str, Any]:
|
|
655
|
+
if not name or "/" in name or name.startswith("."):
|
|
656
|
+
raise HTTPException(status_code=404, detail="unknown artefact")
|
|
657
|
+
try:
|
|
658
|
+
info = _artefact_info(name)
|
|
659
|
+
except KeyError as exc:
|
|
660
|
+
raise HTTPException(status_code=404, detail=f"unknown artefact {name}") from exc
|
|
661
|
+
ref = ref_from_mapping(
|
|
662
|
+
{
|
|
663
|
+
"library": library,
|
|
664
|
+
"experiment": experiment,
|
|
665
|
+
"scenario": scenario,
|
|
666
|
+
"test": test,
|
|
667
|
+
"run": run,
|
|
668
|
+
}
|
|
669
|
+
)
|
|
670
|
+
payload = _bus_latest_artefact(str(info.get("stream") or ""), ref)
|
|
671
|
+
return _artefact_body(str(info.get("name") or name), ref, info, payload)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
@router.get("/api/artefacts/{library}/{experiment}/{scenario}/{test}/{run}/{name}")
|
|
675
|
+
async def get_artefact(
|
|
676
|
+
library: str,
|
|
677
|
+
experiment: str,
|
|
678
|
+
scenario: str,
|
|
679
|
+
test: str,
|
|
680
|
+
run: str,
|
|
681
|
+
name: str,
|
|
682
|
+
) -> dict[str, Any]:
|
|
683
|
+
from mas.lab.lab.config.artifact_types import ref_from_artefact_path
|
|
684
|
+
|
|
685
|
+
if not name or "/" in name or name.startswith("."):
|
|
686
|
+
raise HTTPException(status_code=404, detail="unknown artefact")
|
|
687
|
+
try:
|
|
688
|
+
info = _artefact_info(name)
|
|
689
|
+
except KeyError as exc:
|
|
690
|
+
raise HTTPException(status_code=404, detail=f"unknown artefact {name}") from exc
|
|
691
|
+
ref_map = ref_from_artefact_path(library, experiment, scenario, test, run)
|
|
692
|
+
ref = ref_from_mapping(ref_map)
|
|
693
|
+
payload = None
|
|
694
|
+
if ref.library:
|
|
695
|
+
record = resolve_run(_lib_dir(ref.library), ref, indexed=state._indexed)
|
|
696
|
+
path = _artefact_file(record, str(info.get("name") or name))
|
|
697
|
+
payload = _latest_artefact_payload(path) if path and path.is_file() else None
|
|
698
|
+
if payload is None:
|
|
699
|
+
payload = _bus_latest_artefact(str(info.get("stream") or ""), ref)
|
|
700
|
+
return _artefact_body(str(info.get("name") or name), ref, info, payload)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
@router.get("/api/plots/assets/d3.min.js", response_model=None)
|
|
704
|
+
async def plot_d3_asset(_: None = Depends(authorize_http)):
|
|
705
|
+
from mas.lab.plots.live_html import d3_inline
|
|
706
|
+
|
|
707
|
+
return Response(
|
|
708
|
+
content=d3_inline(),
|
|
709
|
+
media_type="application/javascript",
|
|
710
|
+
headers={"Cache-Control": "public, max-age=86400"},
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
@router.get("/api/plots/{kind}/draw.js", response_model=None)
|
|
715
|
+
async def plot_draw_js(kind: str, _: None = Depends(authorize_http)):
|
|
716
|
+
if not kind or "/" in kind or kind.startswith("."):
|
|
717
|
+
raise HTTPException(status_code=404, detail="unknown plot")
|
|
718
|
+
try:
|
|
719
|
+
from mas.lab.plots.providers import render_draw_module
|
|
720
|
+
|
|
721
|
+
return Response(
|
|
722
|
+
content=render_draw_module(kind),
|
|
723
|
+
media_type="text/javascript",
|
|
724
|
+
headers={
|
|
725
|
+
"Cache-Control": "no-store, no-cache, must-revalidate",
|
|
726
|
+
"Pragma": "no-cache",
|
|
727
|
+
},
|
|
728
|
+
)
|
|
729
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
730
|
+
raise HTTPException(status_code=404, detail=f"unknown plot {kind}") from exc
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
@router.get("/api/plots/{kind}", response_model=None)
|
|
734
|
+
async def plot_html(
|
|
735
|
+
kind: str,
|
|
736
|
+
request: Request,
|
|
737
|
+
format: str = "embedded",
|
|
738
|
+
mode: str = "",
|
|
739
|
+
_: None = Depends(authorize_http),
|
|
740
|
+
):
|
|
741
|
+
if not kind or "/" in kind or kind.startswith("."):
|
|
742
|
+
raise HTTPException(status_code=404, detail="unknown plot")
|
|
743
|
+
try:
|
|
744
|
+
info = _plot_info(kind)
|
|
745
|
+
requested = str(mode or format or "embedded").lower()
|
|
746
|
+
standalone = requested in {"html", "htm", "svg", "png"}
|
|
747
|
+
if not standalone:
|
|
748
|
+
from mas.lab.plots.providers import render_embedded
|
|
749
|
+
|
|
750
|
+
body = render_embedded(str(info.get("kind") or kind))
|
|
751
|
+
return JSONResponse(
|
|
752
|
+
{**info, **body, "mode": "embedded"},
|
|
753
|
+
headers={
|
|
754
|
+
"X-Mas-Plot-Stream": info["stream"],
|
|
755
|
+
"Cache-Control": "no-store, no-cache, must-revalidate",
|
|
756
|
+
"Pragma": "no-cache",
|
|
757
|
+
},
|
|
758
|
+
)
|
|
759
|
+
fmt = requested
|
|
760
|
+
body = _render_plot_html(str(info.get("kind") or kind), fmt=fmt)
|
|
761
|
+
headers = {
|
|
762
|
+
"X-Mas-Plot-Stream": info["stream"],
|
|
763
|
+
"Cache-Control": "no-store, no-cache, must-revalidate",
|
|
764
|
+
"Pragma": "no-cache",
|
|
765
|
+
}
|
|
766
|
+
if fmt == "svg":
|
|
767
|
+
return Response(content=body, media_type="image/svg+xml", headers=headers)
|
|
768
|
+
if fmt == "png":
|
|
769
|
+
return Response(content=body, media_type="image/png", headers=headers)
|
|
770
|
+
return HTMLResponse(body, headers=headers)
|
|
771
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
772
|
+
raise HTTPException(status_code=404, detail=f"unknown plot {kind}") from exc
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
@router.get("/api/plugins")
|
|
776
|
+
async def list_ui_plugins(
|
|
777
|
+
kind: str | None = None,
|
|
778
|
+
format: str | None = None,
|
|
779
|
+
realtime: bool | None = None,
|
|
780
|
+
) -> dict[str, Any]:
|
|
781
|
+
from mas.lab.controller.ui_stream.plugin_registry import list_plugins
|
|
782
|
+
|
|
783
|
+
return {"plugins": list_plugins(kind=kind, format=format, realtime=realtime)}
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
@router.get("/api/layouts")
|
|
787
|
+
async def list_layouts() -> dict[str, Any]:
|
|
788
|
+
from mas.lab.controller.ui_stream.layouts import list_layout_catalog
|
|
789
|
+
|
|
790
|
+
catalog = list_layout_catalog(layout_roots())
|
|
791
|
+
return {"layouts": [row["slug"] for row in catalog], "catalog": catalog}
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
@router.get("/api/layouts/{name}")
|
|
795
|
+
async def get_layout(name: str) -> dict[str, Any]:
|
|
796
|
+
try:
|
|
797
|
+
doc = load_layout(layout_roots(), name)
|
|
798
|
+
except FileNotFoundError as exc:
|
|
799
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
800
|
+
except Exception as exc:
|
|
801
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
802
|
+
try:
|
|
803
|
+
await _hydrate_specs(doc)
|
|
804
|
+
except Exception:
|
|
805
|
+
logger.exception("layout spec hydrate skipped")
|
|
806
|
+
return doc
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
@router.get("/api/jobs/{job_id}")
|
|
810
|
+
@router.get("/api/ui/jobs/{job_id}")
|
|
811
|
+
async def get_job(job_id: str) -> dict[str, Any]:
|
|
812
|
+
found = state.lookup_job(job_id)
|
|
813
|
+
if not found:
|
|
814
|
+
raise HTTPException(status_code=404, detail="unknown job")
|
|
815
|
+
return found
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
@router.get("/api/trace")
|
|
819
|
+
async def get_trace(
|
|
820
|
+
scenario: str | None = None,
|
|
821
|
+
batch: str | None = None,
|
|
822
|
+
run: str | None = None,
|
|
823
|
+
) -> dict[str, Any]:
|
|
824
|
+
ref = ref_from_mapping({"scenario": scenario, "batch": batch, "run": run})
|
|
825
|
+
rows = [item.as_dict() for item in state.bus.snapshot(("*",), ref)]
|
|
826
|
+
buckets = split_envelopes(rows)
|
|
827
|
+
return {
|
|
828
|
+
"kind": "mas-lab.ui-trace",
|
|
829
|
+
"version": 1,
|
|
830
|
+
"ref": ref.as_dict(),
|
|
831
|
+
"runtime": buckets.get("runtime") or [],
|
|
832
|
+
"pipeline": buckets.get("pipeline") or [],
|
|
833
|
+
"n": len(rows),
|
|
834
|
+
"files": [
|
|
835
|
+
{"path": "runtime.jsonl", "tree": "runtime", "n": len(buckets.get("runtime") or [])},
|
|
836
|
+
{"path": "pipeline.jsonl", "tree": "pipeline", "n": len(buckets.get("pipeline") or [])},
|
|
837
|
+
],
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
@router.get("/api/layouts/{name}/snapshot")
|
|
842
|
+
async def layout_snapshot(
|
|
843
|
+
name: str,
|
|
844
|
+
session: str | None = None,
|
|
845
|
+
run: str | None = None,
|
|
846
|
+
batch: str | None = None,
|
|
847
|
+
experiment: str | None = None,
|
|
848
|
+
scenario: str | None = None,
|
|
849
|
+
test: str | None = None,
|
|
850
|
+
) -> dict[str, Any]:
|
|
851
|
+
try:
|
|
852
|
+
layout = load_layout(layout_roots(), name)
|
|
853
|
+
except FileNotFoundError as exc:
|
|
854
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
855
|
+
ref = ref_from_mapping(layout.get("dataSource")).merge(
|
|
856
|
+
{
|
|
857
|
+
"session": session,
|
|
858
|
+
"run": run,
|
|
859
|
+
"batch": batch,
|
|
860
|
+
"experiment": experiment,
|
|
861
|
+
"scenario": scenario,
|
|
862
|
+
"test": test,
|
|
863
|
+
}
|
|
864
|
+
)
|
|
865
|
+
await _hydrate_specs(layout)
|
|
866
|
+
events = [item.as_dict() | {"replay": True} for item in state.bus.snapshot(("*",), ref)]
|
|
867
|
+
return {
|
|
868
|
+
"ref": ref.as_dict(),
|
|
869
|
+
"layout": layout,
|
|
870
|
+
"events": events,
|
|
871
|
+
"jobs": _local_jobs(),
|
|
872
|
+
"sessions": (await sessions())["sessions"],
|
|
873
|
+
"runs": _runs_payload(),
|
|
874
|
+
"metrics": state.metrics.snapshot(ref),
|
|
875
|
+
"spec": {},
|
|
876
|
+
"catalog": _safe_catalog(ref),
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _arm_replay(nonce: str | None, *, replay: bool) -> str | None:
|
|
881
|
+
"""Stamp a replay with a unique nonce. A new nonce clears the live view."""
|
|
882
|
+
tagged = str(nonce or "").strip() or None
|
|
883
|
+
if not replay:
|
|
884
|
+
return tagged
|
|
885
|
+
tagged = tagged or str(uuid.uuid4())
|
|
886
|
+
if tagged == state._replay_nonce:
|
|
887
|
+
return tagged
|
|
888
|
+
for task in list(state._replay_tasks.values()):
|
|
889
|
+
task.cancel()
|
|
890
|
+
state._replay_tasks.clear()
|
|
891
|
+
state.bus.clear()
|
|
892
|
+
state.metrics.clear()
|
|
893
|
+
state.governance.clear()
|
|
894
|
+
state.ingest.clear()
|
|
895
|
+
state._replay_nonce = tagged
|
|
896
|
+
token = use_nonce(tagged)
|
|
897
|
+
try:
|
|
898
|
+
state.bus.publish(
|
|
899
|
+
envelope(
|
|
900
|
+
"view.reset",
|
|
901
|
+
DataSourceRef(),
|
|
902
|
+
"view",
|
|
903
|
+
{"phase": "reset", "nonce": tagged},
|
|
904
|
+
replay=True,
|
|
905
|
+
)
|
|
906
|
+
)
|
|
907
|
+
finally:
|
|
908
|
+
reset_nonce(token)
|
|
909
|
+
return tagged
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
@router.post("/api/ingest")
|
|
913
|
+
async def ingest(body: IngestBody, _: None = Depends(authorize_http)) -> dict[str, Any]:
|
|
914
|
+
ref = ref_from_mapping(body.ref)
|
|
915
|
+
if _drop_ingest(ref, replay=body.replay):
|
|
916
|
+
return {"ok": True, "n": 0, "dropped": True}
|
|
917
|
+
state._closed_until_run = False
|
|
918
|
+
records = list(body.records)
|
|
919
|
+
if body.record:
|
|
920
|
+
records.append(body.record)
|
|
921
|
+
_guard_ingest(len(records))
|
|
922
|
+
nonce = _arm_replay(str(body.nonce or "").strip() or None, replay=body.replay)
|
|
923
|
+
speed = float(body.speed or 0.0)
|
|
924
|
+
if speed > 0 and len(records) > 1:
|
|
925
|
+
job_id = str(uuid.uuid4())
|
|
926
|
+
job_ref = _stamp_ui(ref.merge({"run": ref.run or job_id}))
|
|
927
|
+
_arm_job(job_id, job_ref)
|
|
928
|
+
task = asyncio.create_task(
|
|
929
|
+
_pump_events(job_id, job_ref, records, speed=speed, nonce=nonce, replay=body.replay),
|
|
930
|
+
name=f"ingest-{job_id[:8]}",
|
|
931
|
+
)
|
|
932
|
+
_register_replay_task(job_id, task)
|
|
933
|
+
return {"ok": True, "n": len(records), "job_id": job_id, "speed": speed, "nonce": nonce}
|
|
934
|
+
token = use_nonce(nonce) if nonce else None
|
|
935
|
+
try:
|
|
936
|
+
_seed_spec_from_records(ref, records)
|
|
937
|
+
for record in records:
|
|
938
|
+
rec_ref = ref
|
|
939
|
+
if not ref.scenario and isinstance(record, dict) and record.get("scenario"):
|
|
940
|
+
rec_ref = ref.merge({"scenario": record.get("scenario")})
|
|
941
|
+
state.ingest.ingest(rec_ref, record, replay=body.replay)
|
|
942
|
+
_index_native(rec_ref, record)
|
|
943
|
+
return {"ok": True, "n": len(records), "nonce": nonce}
|
|
944
|
+
finally:
|
|
945
|
+
if token is not None:
|
|
946
|
+
reset_nonce(token)
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
@router.post("/api/ingest/aligned")
|
|
950
|
+
async def ingest_aligned(body: AlignedIngestBody, _: None = Depends(authorize_http)) -> dict[str, Any]:
|
|
951
|
+
"""Replay several traces on one clock, aligned by event timestamp."""
|
|
952
|
+
lanes: list[tuple[DataSourceRef, list[dict[str, Any]]]] = []
|
|
953
|
+
total = 0
|
|
954
|
+
for lane in body.lanes:
|
|
955
|
+
ref = _stamp_ui(ref_from_mapping(lane.ref))
|
|
956
|
+
records = list(lane.records or [])
|
|
957
|
+
if not records:
|
|
958
|
+
continue
|
|
959
|
+
lanes.append((ref, records))
|
|
960
|
+
total += len(records)
|
|
961
|
+
if not lanes:
|
|
962
|
+
return {"ok": True, "n": 0}
|
|
963
|
+
_guard_ingest(total)
|
|
964
|
+
job_id = str(uuid.uuid4())
|
|
965
|
+
speed = float(body.speed)
|
|
966
|
+
_arm_job(job_id, lanes[0][0])
|
|
967
|
+
task = asyncio.create_task(
|
|
968
|
+
_pump_aligned(job_id, lanes, speed=speed),
|
|
969
|
+
name=f"align-{job_id[:8]}",
|
|
970
|
+
)
|
|
971
|
+
_register_replay_task(job_id, task)
|
|
972
|
+
return {"ok": True, "n": total, "job_id": job_id, "speed": speed, "lanes": len(lanes)}
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
@router.post("/api/ingest/envelopes")
|
|
976
|
+
async def ingest_envelopes(body: EnvelopeIngestBody, _: None = Depends(authorize_http)) -> dict[str, Any]:
|
|
977
|
+
"""Replay a recorded WS/pipeline trace (runtime and/or pipeline trees)."""
|
|
978
|
+
wanted = {name.strip() for name in (body.trees or []) if str(name).strip()}
|
|
979
|
+
lanes: list[list] = []
|
|
980
|
+
total = 0
|
|
981
|
+
for lane in body.lanes:
|
|
982
|
+
tree = str(lane.tree or "runtime")
|
|
983
|
+
if wanted and tree not in wanted:
|
|
984
|
+
continue
|
|
985
|
+
rows = list(lane.envelopes or [])
|
|
986
|
+
if not rows:
|
|
987
|
+
continue
|
|
988
|
+
lanes.append(envelopes_to_scheduled(rows, tree=tree))
|
|
989
|
+
total += len(rows)
|
|
990
|
+
if not lanes:
|
|
991
|
+
return {"ok": True, "n": 0}
|
|
992
|
+
_guard_ingest(total)
|
|
993
|
+
job_id = str(uuid.uuid4())
|
|
994
|
+
first = lanes[0][0]
|
|
995
|
+
ref = ref_from_mapping((first.envelope or {}).get("ref"))
|
|
996
|
+
_arm_job(job_id, _stamp_ui(ref))
|
|
997
|
+
task = asyncio.create_task(
|
|
998
|
+
_pump_envelopes(
|
|
999
|
+
job_id,
|
|
1000
|
+
merge_timeline(lanes),
|
|
1001
|
+
speed=float(body.speed),
|
|
1002
|
+
recompute=body.recompute,
|
|
1003
|
+
cached_artefacts=tuple(body.cached_artefacts or ()),
|
|
1004
|
+
),
|
|
1005
|
+
name=f"env-{job_id[:8]}",
|
|
1006
|
+
)
|
|
1007
|
+
_register_replay_task(job_id, task)
|
|
1008
|
+
return {"ok": True, "n": total, "job_id": job_id, "speed": body.speed, "recompute": body.recompute}
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
@router.post("/api/stream")
|
|
1012
|
+
async def post_stream(body: StreamFrameBody, _: None = Depends(authorize_http)) -> dict[str, Any]:
|
|
1013
|
+
await _accept_stream_frame(body.model_dump())
|
|
1014
|
+
return {"ok": True}
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
@router.post("/api/commands")
|
|
1018
|
+
async def commands(body: CommandBody, _: None = Depends(authorize_http)) -> dict[str, Any]:
|
|
1019
|
+
nonce = str(body.nonce or "").strip() or None
|
|
1020
|
+
token = use_nonce(nonce) if nonce else None
|
|
1021
|
+
try:
|
|
1022
|
+
return await _commands(body)
|
|
1023
|
+
finally:
|
|
1024
|
+
if token is not None:
|
|
1025
|
+
reset_nonce(token)
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
async def _commands(body: CommandBody) -> dict[str, Any]:
|
|
1029
|
+
ref = ref_from_mapping(body.ref)
|
|
1030
|
+
if body.batch:
|
|
1031
|
+
ref = ref.merge({"batch": body.batch})
|
|
1032
|
+
state.bus.publish(envelope(f"command.{body.verb.strip()}", ref, "command", body.model_dump()))
|
|
1033
|
+
verb = body.verb.strip()
|
|
1034
|
+
if verb in {"delete-run", "delete-session", "delete-ui-runs", "delete-all", "reset-view"}:
|
|
1035
|
+
if verb == "reset-view":
|
|
1036
|
+
result = await _delete_all_ui_runs()
|
|
1037
|
+
tombstones = set(state._deleted)
|
|
1038
|
+
state.reset()
|
|
1039
|
+
state._deleted.update(tombstones)
|
|
1040
|
+
result = {**result, "verb": "reset-view"}
|
|
1041
|
+
elif verb in {"delete-ui-runs", "delete-all"}:
|
|
1042
|
+
result = await _delete_all_ui_runs()
|
|
1043
|
+
else:
|
|
1044
|
+
result = await _delete_ui_run(ref)
|
|
1045
|
+
state.bus.publish(envelope(f"result.{verb}", ref, "result", {"results": [result]}))
|
|
1046
|
+
return {"results": [result]}
|
|
1047
|
+
if verb in {"run-experiment", "experiment"} and (ref.mode or "live") != "replay":
|
|
1048
|
+
await _publish_spec_snapshots(ref, body)
|
|
1049
|
+
try:
|
|
1050
|
+
result = await _start_live_experiment(body, ref)
|
|
1051
|
+
except HTTPException as exc:
|
|
1052
|
+
results = [{"ok": False, "detail": exc.detail}]
|
|
1053
|
+
else:
|
|
1054
|
+
results = [result]
|
|
1055
|
+
state.bus.publish(envelope(f"result.{verb}", ref, "result", {"results": results}))
|
|
1056
|
+
return {"results": results}
|
|
1057
|
+
raw_targets = list(body.targets) if body.targets else [body.ref]
|
|
1058
|
+
unique: list[DataSourceRef] = []
|
|
1059
|
+
seen: set[str] = set()
|
|
1060
|
+
for item in raw_targets:
|
|
1061
|
+
ref_item = ref.merge(ref_from_mapping(item if isinstance(item, dict) else {}))
|
|
1062
|
+
key = ref_item.coordinate() + "|" + (ref_item.overlay or "")
|
|
1063
|
+
if key in seen:
|
|
1064
|
+
continue
|
|
1065
|
+
seen.add(key)
|
|
1066
|
+
unique.append(ref_item)
|
|
1067
|
+
for item_ref in unique:
|
|
1068
|
+
await _publish_spec_snapshot(item_ref)
|
|
1069
|
+
gathered = await asyncio.gather(
|
|
1070
|
+
*[_dispatch_command(body, item_ref) for item_ref in unique],
|
|
1071
|
+
return_exceptions=True,
|
|
1072
|
+
)
|
|
1073
|
+
results: list[Any] = []
|
|
1074
|
+
for item in gathered:
|
|
1075
|
+
if isinstance(item, HTTPException):
|
|
1076
|
+
results.append({"ok": False, "detail": item.detail})
|
|
1077
|
+
elif isinstance(item, Exception):
|
|
1078
|
+
results.append({"ok": False, "detail": str(item)})
|
|
1079
|
+
else:
|
|
1080
|
+
results.append(item)
|
|
1081
|
+
state.bus.publish(envelope(f"result.{body.verb.strip()}", ref, "result", {"results": results}))
|
|
1082
|
+
return {"results": results}
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
@router.websocket("/ws")
|
|
1086
|
+
async def websocket_endpoint(ws: WebSocket) -> None:
|
|
1087
|
+
await ws.accept()
|
|
1088
|
+
sub = None
|
|
1089
|
+
try:
|
|
1090
|
+
hello = json.loads(await ws.receive_text())
|
|
1091
|
+
if not isinstance(hello, dict):
|
|
1092
|
+
await ws.send_json({"error": "bad hello"})
|
|
1093
|
+
await ws.close(code=4400)
|
|
1094
|
+
return
|
|
1095
|
+
try:
|
|
1096
|
+
authorize_ws(ws, hello)
|
|
1097
|
+
except HTTPException:
|
|
1098
|
+
await ws.send_json({"error": "unauthorized"})
|
|
1099
|
+
await ws.close(code=4401)
|
|
1100
|
+
return
|
|
1101
|
+
raw_topics = hello.get("topics")
|
|
1102
|
+
from mas.lab.controller.ui_stream.streams import expand_stream_names
|
|
1103
|
+
|
|
1104
|
+
get_topics = expand_stream_names(hello.get("get") or [])
|
|
1105
|
+
sub_topics = expand_stream_names(hello.get("subscribe") or raw_topics or [])
|
|
1106
|
+
if not sub_topics and raw_topics is None and not get_topics:
|
|
1107
|
+
sub_topics = ["*"]
|
|
1108
|
+
topics = list(dict.fromkeys([*get_topics, *sub_topics]))
|
|
1109
|
+
ref = ref_from_mapping(hello.get("ref"))
|
|
1110
|
+
raw_inspect = hello.get("inspect")
|
|
1111
|
+
inspect_ids = (
|
|
1112
|
+
tuple(raw_inspect) if isinstance(raw_inspect, list) else tuple(infer_inspect(topics))
|
|
1113
|
+
)
|
|
1114
|
+
nonce = str(hello.get("nonce") or "").strip() or None
|
|
1115
|
+
sub = state.bus.subscribe(sub_topics or topics, ref, nonce=nonce)
|
|
1116
|
+
state._inspect_ids[id(sub)] = inspect_ids
|
|
1117
|
+
catchup_topics = list(dict.fromkeys([*(get_topics or []), *(sub_topics or [])]))
|
|
1118
|
+
for item in state.bus.catchup(catchup_topics, ref, nonce=nonce):
|
|
1119
|
+
payload = item.as_dict()
|
|
1120
|
+
payload["replay"] = True
|
|
1121
|
+
await ws.send_json(payload)
|
|
1122
|
+
|
|
1123
|
+
async def outbound() -> None:
|
|
1124
|
+
assert sub is not None
|
|
1125
|
+
while True:
|
|
1126
|
+
envelope_item = await sub.queue.get()
|
|
1127
|
+
await ws.send_json(envelope_item.as_dict())
|
|
1128
|
+
|
|
1129
|
+
async def inbound() -> None:
|
|
1130
|
+
while True:
|
|
1131
|
+
data = await ws.receive_json()
|
|
1132
|
+
await _accept_stream_frame(data if isinstance(data, dict) else {})
|
|
1133
|
+
|
|
1134
|
+
await asyncio.gather(outbound(), inbound())
|
|
1135
|
+
except WebSocketDisconnect:
|
|
1136
|
+
return
|
|
1137
|
+
except Exception:
|
|
1138
|
+
logger.exception("websocket closed")
|
|
1139
|
+
finally:
|
|
1140
|
+
if sub is not None:
|
|
1141
|
+
state._inspect_ids.pop(id(sub), None)
|
|
1142
|
+
state.bus.unsubscribe(sub)
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
async def _accept_stream_frame(raw: dict[str, Any]) -> None:
|
|
1146
|
+
frame = parse_frame(raw)
|
|
1147
|
+
if is_command_frame(frame, raw):
|
|
1148
|
+
body = CommandBody.model_validate(command_payload(frame, raw))
|
|
1149
|
+
await commands(body)
|
|
1150
|
+
return
|
|
1151
|
+
if frame.source in {"native", "telemetry"} and isinstance(frame.payload, dict):
|
|
1152
|
+
if not _drop_ingest(frame.ref, replay=frame.replay):
|
|
1153
|
+
state.ingest.ingest(frame.ref, frame.payload, replay=frame.replay)
|
|
1154
|
+
return
|
|
1155
|
+
state.bus.publish(frame)
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def _job_payload(job_id: str, status: str, ref: DataSourceRef, extra: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
1159
|
+
row = {
|
|
1160
|
+
"id": job_id,
|
|
1161
|
+
"job_id": job_id,
|
|
1162
|
+
"status": status,
|
|
1163
|
+
"origin": UI_ORIGIN,
|
|
1164
|
+
"library": ref.library,
|
|
1165
|
+
"experiment": ref.experiment,
|
|
1166
|
+
"scenario": ref.scenario,
|
|
1167
|
+
"test": ref.test,
|
|
1168
|
+
"run": ref.run or job_id,
|
|
1169
|
+
"session": ref.session,
|
|
1170
|
+
"overlay": ref.overlay,
|
|
1171
|
+
"streams": _streams(),
|
|
1172
|
+
"ws": "/ws",
|
|
1173
|
+
"mode": ref.mode or "live",
|
|
1174
|
+
}
|
|
1175
|
+
if extra:
|
|
1176
|
+
row.update(extra)
|
|
1177
|
+
return row
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
async def _dispatch_command(body: CommandBody, ref: DataSourceRef) -> dict[str, Any]:
|
|
1181
|
+
verb = body.verb.strip()
|
|
1182
|
+
mode = ref.mode or "live"
|
|
1183
|
+
if verb in CONTROL_VERBS:
|
|
1184
|
+
if not ref.session:
|
|
1185
|
+
raise HTTPException(status_code=400, detail="needs: session")
|
|
1186
|
+
return await _control(verb, body, ref)
|
|
1187
|
+
if verb in {"replay"} or mode == "replay" or verb in {"run", "run-mas", "submit"} and mode == "replay":
|
|
1188
|
+
return await _start_replay(body, ref)
|
|
1189
|
+
if verb in {"run", "run-mas", "submit"}:
|
|
1190
|
+
return await _start_live_mas(body, ref)
|
|
1191
|
+
if verb in {"run-experiment", "experiment"}:
|
|
1192
|
+
return await _start_live_experiment(body, ref)
|
|
1193
|
+
raise HTTPException(status_code=400, detail=f"unknown verb {verb!r}")
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
async def _control(verb: str, body: CommandBody, ref: DataSourceRef) -> dict[str, Any]:
|
|
1197
|
+
from mas.library.standard.plugins.control.attach import attach
|
|
1198
|
+
from mas.library.standard.plugins.control.directory import FileSessionDirectory
|
|
1199
|
+
|
|
1200
|
+
directory = FileSessionDirectory(_session_dir())
|
|
1201
|
+
args: dict[str, Any] = {}
|
|
1202
|
+
if verb == "pause":
|
|
1203
|
+
args["reason"] = body.reason
|
|
1204
|
+
if verb in {"steer", "enqueue_input", "send_message"}:
|
|
1205
|
+
args["text"] = body.text or body.query
|
|
1206
|
+
if verb == "steer":
|
|
1207
|
+
args["mode"] = body.mode
|
|
1208
|
+
if verb == "navigate":
|
|
1209
|
+
args["to"] = body.to
|
|
1210
|
+
args["reason"] = body.reason
|
|
1211
|
+
client = await attach(directory, str(ref.session))
|
|
1212
|
+
try:
|
|
1213
|
+
result = await client.call(verb, str(ref.session), **args)
|
|
1214
|
+
finally:
|
|
1215
|
+
await client.close()
|
|
1216
|
+
state.bus.publish(envelope(f"control.{verb}", ref, "control", {"result": result, "args": args}))
|
|
1217
|
+
return {"ok": True, "verb": verb, "result": result}
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
async def _start_replay(body: CommandBody, ref: DataSourceRef) -> dict[str, Any]:
|
|
1221
|
+
if not ref.library:
|
|
1222
|
+
raise HTTPException(status_code=400, detail="needs: library")
|
|
1223
|
+
lib_dir = _lib_dir(ref.library)
|
|
1224
|
+
record = resolve_run(lib_dir, ref, indexed=state._indexed)
|
|
1225
|
+
if record.events is None or not record.events.is_file():
|
|
1226
|
+
raise HTTPException(
|
|
1227
|
+
status_code=404,
|
|
1228
|
+
detail=f"no events artefact for {ref.coordinate()}",
|
|
1229
|
+
)
|
|
1230
|
+
native = list(
|
|
1231
|
+
demo_records(
|
|
1232
|
+
parse_jsonl(record.events.read_text(encoding="utf-8")),
|
|
1233
|
+
extra_kinds=state.inspect_native_kinds(ref),
|
|
1234
|
+
)
|
|
1235
|
+
)
|
|
1236
|
+
records = merge_replay_records(
|
|
1237
|
+
native,
|
|
1238
|
+
trace_metric_records(
|
|
1239
|
+
metrics=record.artefacts.get("metrics"),
|
|
1240
|
+
progress=record.artefacts.get("progress"),
|
|
1241
|
+
events=native,
|
|
1242
|
+
),
|
|
1243
|
+
*[
|
|
1244
|
+
custom_panel_records(path)
|
|
1245
|
+
for key, path in record.artefacts.items()
|
|
1246
|
+
if key.startswith("custom_")
|
|
1247
|
+
],
|
|
1248
|
+
)
|
|
1249
|
+
if not records:
|
|
1250
|
+
raise HTTPException(status_code=400, detail="no demo events in run artefact")
|
|
1251
|
+
job_id = str(uuid.uuid4())
|
|
1252
|
+
job_ref = _stamp_ui(ref.merge({"run": ref.run or job_id, "mode": "replay"}))
|
|
1253
|
+
job = _job_payload(job_id, "running", job_ref, {"command": "replay"})
|
|
1254
|
+
state._closed_until_run = False
|
|
1255
|
+
state._job_refs[job_id] = job_ref
|
|
1256
|
+
state._job_seen[job_id] = "running"
|
|
1257
|
+
state._indexed[job_id] = record
|
|
1258
|
+
state._indexed[job_ref.coordinate()] = record
|
|
1259
|
+
_remember_run(job, job_ref)
|
|
1260
|
+
state.bus.publish(envelope("job.started", job_ref, "job", job))
|
|
1261
|
+
await _publish_spec_snapshot(job_ref)
|
|
1262
|
+
speed = float(body.speed)
|
|
1263
|
+
task = asyncio.create_task(
|
|
1264
|
+
_pump_events(
|
|
1265
|
+
job_id,
|
|
1266
|
+
job_ref,
|
|
1267
|
+
records,
|
|
1268
|
+
speed=speed,
|
|
1269
|
+
nonce=str(body.nonce or "").strip() or None,
|
|
1270
|
+
replay=True,
|
|
1271
|
+
),
|
|
1272
|
+
name=f"replay-{job_id[:8]}",
|
|
1273
|
+
)
|
|
1274
|
+
_register_replay_task(job_id, task)
|
|
1275
|
+
return {"ok": True, "verb": body.verb.strip(), "job": job, "streams": _streams(), "ws": "/ws"}
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def _seed_spec_from_records(
|
|
1279
|
+
ref: DataSourceRef,
|
|
1280
|
+
records: list[dict[str, Any]],
|
|
1281
|
+
*,
|
|
1282
|
+
base: dict[str, Any] | None = None,
|
|
1283
|
+
) -> None:
|
|
1284
|
+
"""Publish topology spec before native events so the graph is not grown from turns."""
|
|
1285
|
+
from mas.lab.controller.ui_stream.spec_snapshot import merge_spec_snapshots, snapshot_from_records
|
|
1286
|
+
|
|
1287
|
+
inferred = snapshot_from_records(records)
|
|
1288
|
+
snap = merge_spec_snapshots(base or {}, inferred)
|
|
1289
|
+
if not snap.get("agents"):
|
|
1290
|
+
return
|
|
1291
|
+
state.bus.publish(envelope("spec.snapshot", ref, "spec", snap))
|
|
1292
|
+
state.ingest.seed_spec(ref, snap)
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
async def _seed_replay_spec(ref: DataSourceRef, records: list[dict[str, Any]]) -> None:
|
|
1296
|
+
"""Always publish spec (and seed topology) before the first event of a replay."""
|
|
1297
|
+
base: dict[str, Any] = {}
|
|
1298
|
+
if ref.library:
|
|
1299
|
+
try:
|
|
1300
|
+
base = snapshot_for_ref(_lib_dir(ref.library), experiment=ref.experiment, overlay=ref.overlay) or {}
|
|
1301
|
+
except Exception:
|
|
1302
|
+
base = {}
|
|
1303
|
+
if base.get("agents"):
|
|
1304
|
+
state.bus.publish(envelope("spec.snapshot", ref, "spec", base))
|
|
1305
|
+
state.ingest.seed_spec(ref, base)
|
|
1306
|
+
_seed_spec_from_records(ref, records, base=base)
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
def _seal_live_artefacts(ref: DataSourceRef) -> None:
|
|
1310
|
+
"""Mark each produced live fold complete so replay can skip it."""
|
|
1311
|
+
state.ingest.seal_artefacts(ref)
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
async def _finish_job(job_id: str, ref: DataSourceRef, extra: dict[str, Any] | None = None, *, status: str = "completed") -> None:
|
|
1315
|
+
job = _job_payload(job_id, status, ref, extra or {"command": "replay"})
|
|
1316
|
+
state._job_seen[job_id] = status
|
|
1317
|
+
_remember_run(job, ref)
|
|
1318
|
+
if status == "completed":
|
|
1319
|
+
_seal_live_artefacts(ref)
|
|
1320
|
+
_dump_job_trace(job_id, ref)
|
|
1321
|
+
state.bus.publish(envelope("job.completed", ref, "job", job, clock="server", complete=True))
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
def _live_fold_lanes() -> list[dict[str, Any]]:
|
|
1325
|
+
return [
|
|
1326
|
+
{"name": "runtime", "source": "file", "tree": "runtime"},
|
|
1327
|
+
{"name": "chat", "source": "live", "fold": "LivePlotFolds"},
|
|
1328
|
+
{"name": "topology", "source": "live", "fold": "LivePlotFolds"},
|
|
1329
|
+
{"name": "series", "source": "live", "fold": "LivePlotFolds"},
|
|
1330
|
+
]
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
def _is_chat_snapshot(env: dict[str, Any] | None) -> bool:
|
|
1334
|
+
blob = env or {}
|
|
1335
|
+
topic = str(blob.get("topic") or blob.get("artefact") or "")
|
|
1336
|
+
if "custom.chat" in topic:
|
|
1337
|
+
return True
|
|
1338
|
+
payload = blob.get("payload") if isinstance(blob.get("payload"), dict) else {}
|
|
1339
|
+
kind = str(payload.get("kind") or blob.get("kind") or "")
|
|
1340
|
+
return kind.startswith("custom.chat") or kind == "chat"
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def _envelope_lanes(
|
|
1344
|
+
items,
|
|
1345
|
+
*,
|
|
1346
|
+
recompute: bool = False,
|
|
1347
|
+
cached_artefacts: tuple[str, ...] = (),
|
|
1348
|
+
) -> list[dict[str, Any]]:
|
|
1349
|
+
trees: list[str] = []
|
|
1350
|
+
seen: set[str] = set()
|
|
1351
|
+
for item in items or []:
|
|
1352
|
+
name = str(getattr(item, "tree", "") or "runtime")
|
|
1353
|
+
if name in seen:
|
|
1354
|
+
continue
|
|
1355
|
+
seen.add(name)
|
|
1356
|
+
trees.append(name)
|
|
1357
|
+
out: list[dict[str, Any]] = []
|
|
1358
|
+
held = {str(name) for name in cached_artefacts if str(name).strip()}
|
|
1359
|
+
held.discard("custom.chat")
|
|
1360
|
+
for tree in trees:
|
|
1361
|
+
out.append({"name": tree, "source": "file", "tree": tree})
|
|
1362
|
+
if tree == "runtime":
|
|
1363
|
+
out.append({"name": "chat", "source": "live", "fold": "LivePlotFolds"})
|
|
1364
|
+
if recompute and tree == "runtime":
|
|
1365
|
+
for name, artefact, fold in (
|
|
1366
|
+
("topology", "custom.topology", "LivePlotFolds"),
|
|
1367
|
+
("series", "series", "LivePlotFolds"),
|
|
1368
|
+
):
|
|
1369
|
+
cached = any(artefact in token for token in held)
|
|
1370
|
+
if cached:
|
|
1371
|
+
out.append({"name": name, "source": "file", "tree": "pipeline", "path": artefact})
|
|
1372
|
+
else:
|
|
1373
|
+
out.append({"name": name, "source": "live", "fold": fold})
|
|
1374
|
+
return out or _live_fold_lanes()
|
|
1375
|
+
|
|
1376
|
+
|
|
1377
|
+
def _seed_job_progress(job_id: str, items, lanes: list[dict[str, Any]] | None) -> None:
|
|
1378
|
+
if not items:
|
|
1379
|
+
state._job_progress[job_id] = {"t": 0, "t0": 0, "t1": 0, "n": 0, "n_total": 0, "lanes": lanes or []}
|
|
1380
|
+
return
|
|
1381
|
+
t0 = items[0].ts
|
|
1382
|
+
t1 = items[-1].ts
|
|
1383
|
+
trees: dict[str, dict[str, int]] = {}
|
|
1384
|
+
for item in items:
|
|
1385
|
+
slot = trees.setdefault(item.tree, {"n": 0, "n_total": 0})
|
|
1386
|
+
slot["n_total"] += 1
|
|
1387
|
+
state._job_progress[job_id] = {
|
|
1388
|
+
"t": t0,
|
|
1389
|
+
"t0": t0,
|
|
1390
|
+
"t1": t1,
|
|
1391
|
+
"n": 0,
|
|
1392
|
+
"n_total": len(items),
|
|
1393
|
+
"lanes": lanes or [],
|
|
1394
|
+
"trees": trees,
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
def _publish_job_clock(job_id: str, ref: DataSourceRef, *, speed: float) -> None:
|
|
1399
|
+
payload = dict(state._job_progress.get(job_id) or {})
|
|
1400
|
+
payload["speed"] = float(speed)
|
|
1401
|
+
state._job_progress[job_id] = payload
|
|
1402
|
+
state.bus.publish(envelope("job.progress", ref, "job", payload, replay=True, ts=payload.get("t")))
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def _on_job_progress(
|
|
1406
|
+
job_id: str,
|
|
1407
|
+
lanes: list[dict[str, Any]] | None,
|
|
1408
|
+
*,
|
|
1409
|
+
ref: DataSourceRef | None = None,
|
|
1410
|
+
speed: float = 1.0,
|
|
1411
|
+
):
|
|
1412
|
+
last_pub = {"n": -1, "wall": 0.0}
|
|
1413
|
+
|
|
1414
|
+
def on_progress(prog: dict[str, Any]) -> None:
|
|
1415
|
+
payload = {**prog, "lanes": lanes or prog.get("lanes") or [], "speed": float(speed)}
|
|
1416
|
+
state._job_progress[job_id] = payload
|
|
1417
|
+
if ref is None:
|
|
1418
|
+
return
|
|
1419
|
+
n = int(prog.get("n") or 0)
|
|
1420
|
+
n_total = int(prog.get("n_total") or 0)
|
|
1421
|
+
wall = datetime.now(UTC).timestamp()
|
|
1422
|
+
if n != n_total and n - int(last_pub["n"]) < 8 and wall - float(last_pub["wall"]) < 0.12:
|
|
1423
|
+
return
|
|
1424
|
+
last_pub["n"] = n
|
|
1425
|
+
last_pub["wall"] = wall
|
|
1426
|
+
state.bus.publish(
|
|
1427
|
+
envelope("job.progress", ref, "job", payload, replay=True, ts=prog.get("t"))
|
|
1428
|
+
)
|
|
1429
|
+
|
|
1430
|
+
return on_progress
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
async def _pump_events(
|
|
1434
|
+
job_id: str,
|
|
1435
|
+
ref: DataSourceRef,
|
|
1436
|
+
records: list[dict[str, Any]],
|
|
1437
|
+
*,
|
|
1438
|
+
speed: float = 1.0,
|
|
1439
|
+
nonce: str | None = None,
|
|
1440
|
+
replay: bool = False,
|
|
1441
|
+
) -> None:
|
|
1442
|
+
token = use_clock("server")
|
|
1443
|
+
nonce_token = use_nonce(nonce) if nonce else None
|
|
1444
|
+
try:
|
|
1445
|
+
await _seed_replay_spec(ref, records)
|
|
1446
|
+
items = records_to_scheduled(records, ref=ref, tree="runtime")
|
|
1447
|
+
lanes = _live_fold_lanes()
|
|
1448
|
+
_seed_job_progress(job_id, items, lanes)
|
|
1449
|
+
_publish_job_clock(job_id, ref, speed=speed)
|
|
1450
|
+
|
|
1451
|
+
def emit(item):
|
|
1452
|
+
state.ingest.ingest(ref, item.record, replay=replay)
|
|
1453
|
+
|
|
1454
|
+
await pump_timeline(
|
|
1455
|
+
items,
|
|
1456
|
+
speed=speed,
|
|
1457
|
+
emit=emit,
|
|
1458
|
+
cancelled=lambda: job_id in state._deleted,
|
|
1459
|
+
on_progress=_on_job_progress(job_id, lanes, ref=ref, speed=speed),
|
|
1460
|
+
)
|
|
1461
|
+
state.ingest.flush_snapshot(ref, replay=replay)
|
|
1462
|
+
await _finish_job(job_id, ref)
|
|
1463
|
+
except asyncio.CancelledError:
|
|
1464
|
+
return
|
|
1465
|
+
except Exception as exc:
|
|
1466
|
+
logger.exception("events replay failed")
|
|
1467
|
+
await _finish_job(job_id, ref, {"error": str(exc)}, status="failed")
|
|
1468
|
+
finally:
|
|
1469
|
+
reset_clock(token)
|
|
1470
|
+
if nonce_token is not None:
|
|
1471
|
+
reset_nonce(nonce_token)
|
|
1472
|
+
state._replay_tasks.pop(job_id, None)
|
|
1473
|
+
|
|
1474
|
+
|
|
1475
|
+
async def _pump_aligned(
|
|
1476
|
+
job_id: str,
|
|
1477
|
+
lanes: list[tuple[DataSourceRef, list[dict[str, Any]]]],
|
|
1478
|
+
*,
|
|
1479
|
+
speed: float = 1.0,
|
|
1480
|
+
) -> None:
|
|
1481
|
+
first_ref = lanes[0][0]
|
|
1482
|
+
token = use_clock("server")
|
|
1483
|
+
try:
|
|
1484
|
+
scheduled = []
|
|
1485
|
+
for ref, records in lanes:
|
|
1486
|
+
await _seed_replay_spec(ref, records)
|
|
1487
|
+
scheduled.append(records_to_scheduled(records, ref=ref, tree="runtime"))
|
|
1488
|
+
items = merge_timeline(scheduled)
|
|
1489
|
+
lanes = _live_fold_lanes()
|
|
1490
|
+
_seed_job_progress(job_id, items, lanes)
|
|
1491
|
+
_publish_job_clock(job_id, first_ref, speed=speed)
|
|
1492
|
+
|
|
1493
|
+
def emit(item):
|
|
1494
|
+
state.ingest.ingest(item.ref, item.record, replay=False)
|
|
1495
|
+
|
|
1496
|
+
await pump_timeline(
|
|
1497
|
+
items,
|
|
1498
|
+
speed=speed,
|
|
1499
|
+
emit=emit,
|
|
1500
|
+
cancelled=lambda: job_id in state._deleted,
|
|
1501
|
+
on_progress=_on_job_progress(job_id, lanes, ref=first_ref, speed=speed),
|
|
1502
|
+
)
|
|
1503
|
+
for ref, _records in lanes:
|
|
1504
|
+
state.ingest.flush_snapshot(ref, replay=False)
|
|
1505
|
+
await _finish_job(job_id, first_ref, {"command": "replay", "align": "time"})
|
|
1506
|
+
except asyncio.CancelledError:
|
|
1507
|
+
return
|
|
1508
|
+
except Exception as exc:
|
|
1509
|
+
logger.exception("aligned replay failed")
|
|
1510
|
+
await _finish_job(job_id, first_ref, {"error": str(exc)}, status="failed")
|
|
1511
|
+
finally:
|
|
1512
|
+
reset_clock(token)
|
|
1513
|
+
state._replay_tasks.pop(job_id, None)
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
async def _pump_envelopes(
|
|
1517
|
+
job_id: str,
|
|
1518
|
+
items,
|
|
1519
|
+
*,
|
|
1520
|
+
speed: float = 1.0,
|
|
1521
|
+
recompute: bool = False,
|
|
1522
|
+
cached_artefacts: tuple[str, ...] = (),
|
|
1523
|
+
) -> None:
|
|
1524
|
+
first_ref = DataSourceRef()
|
|
1525
|
+
if items:
|
|
1526
|
+
env = items[0].envelope or {}
|
|
1527
|
+
first_ref = ref_from_mapping(env.get("ref") if isinstance(env.get("ref"), dict) else {})
|
|
1528
|
+
token = use_clock("server")
|
|
1529
|
+
held = {name.strip() for name in cached_artefacts if str(name).strip()}
|
|
1530
|
+
held.discard("custom.chat")
|
|
1531
|
+
has_runtime = any(str(getattr(item, "tree", "") or "") == "runtime" for item in items or [])
|
|
1532
|
+
state.ingest._skip_plots = set(held)
|
|
1533
|
+
lanes = _envelope_lanes(items, recompute=recompute, cached_artefacts=tuple(held))
|
|
1534
|
+
_seed_job_progress(job_id, items, lanes)
|
|
1535
|
+
_publish_job_clock(job_id, first_ref, speed=speed)
|
|
1536
|
+
try:
|
|
1537
|
+
def emit(item):
|
|
1538
|
+
env = item.envelope or {}
|
|
1539
|
+
if has_runtime and str(getattr(item, "tree", "") or "") == "pipeline" and _is_chat_snapshot(env):
|
|
1540
|
+
return
|
|
1541
|
+
ref = ref_from_mapping(env.get("ref") if isinstance(env.get("ref"), dict) else {})
|
|
1542
|
+
payload = env.get("payload")
|
|
1543
|
+
if recompute and item.tree == "runtime" and isinstance(payload, dict) and payload.get("kind"):
|
|
1544
|
+
state.ingest.ingest(ref, payload, replay=False)
|
|
1545
|
+
return
|
|
1546
|
+
state.bus.publish(envelope_from_mapping(env))
|
|
1547
|
+
if (
|
|
1548
|
+
has_runtime
|
|
1549
|
+
and item.tree == "runtime"
|
|
1550
|
+
and isinstance(payload, dict)
|
|
1551
|
+
and payload.get("kind")
|
|
1552
|
+
):
|
|
1553
|
+
def _emit_chat(target: DataSourceRef, rec: dict[str, Any]) -> None:
|
|
1554
|
+
plot_kind = str(rec.get("kind") or "")
|
|
1555
|
+
if not plot_kind.startswith("custom.chat"):
|
|
1556
|
+
return
|
|
1557
|
+
state.bus.publish(
|
|
1558
|
+
envelope(plot_kind, target, "custom", rec, replay=False, complete=False)
|
|
1559
|
+
)
|
|
1560
|
+
|
|
1561
|
+
state.ingest._plots.apply(
|
|
1562
|
+
_emit_chat, ref, payload, skip={"custom.topology", "series"}
|
|
1563
|
+
)
|
|
1564
|
+
|
|
1565
|
+
await pump_timeline(
|
|
1566
|
+
items,
|
|
1567
|
+
speed=speed,
|
|
1568
|
+
emit=emit,
|
|
1569
|
+
cancelled=lambda: job_id in state._deleted,
|
|
1570
|
+
on_progress=_on_job_progress(job_id, lanes, ref=first_ref, speed=speed),
|
|
1571
|
+
)
|
|
1572
|
+
if recompute:
|
|
1573
|
+
state.ingest.flush_snapshot(first_ref, replay=False)
|
|
1574
|
+
await _finish_job(
|
|
1575
|
+
job_id,
|
|
1576
|
+
first_ref,
|
|
1577
|
+
{"command": "replay", "envelopes": True, "recompute": recompute, "cached": sorted(held)},
|
|
1578
|
+
)
|
|
1579
|
+
except asyncio.CancelledError:
|
|
1580
|
+
return
|
|
1581
|
+
except Exception as exc:
|
|
1582
|
+
logger.exception("envelope replay failed")
|
|
1583
|
+
await _finish_job(job_id, first_ref, {"error": str(exc)}, status="failed")
|
|
1584
|
+
finally:
|
|
1585
|
+
state.ingest._skip_plots = set()
|
|
1586
|
+
reset_clock(token)
|
|
1587
|
+
state._replay_tasks.pop(job_id, None)
|
|
1588
|
+
|
|
1589
|
+
|
|
1590
|
+
async def _start_live_experiment(body: CommandBody, ref: DataSourceRef) -> dict[str, Any]:
|
|
1591
|
+
from mas.lab.controller.api import get_controller_api
|
|
1592
|
+
from mas.lab.controller.routes.experiments import resolve_experiment_path
|
|
1593
|
+
from mas.lab.controller.routes.run import _run_pre_steps
|
|
1594
|
+
|
|
1595
|
+
if not ref.library:
|
|
1596
|
+
raise HTTPException(status_code=400, detail="needs: library")
|
|
1597
|
+
experiment = body.experiment or ref.experiment
|
|
1598
|
+
if not experiment:
|
|
1599
|
+
raise HTTPException(status_code=400, detail="needs: experiment")
|
|
1600
|
+
lib_dir = _lib_dir(ref.library)
|
|
1601
|
+
pre = list(body.pre or [])
|
|
1602
|
+
for item in body.targets or []:
|
|
1603
|
+
if isinstance(item, dict):
|
|
1604
|
+
pre.extend(item.get("pre") or [])
|
|
1605
|
+
_run_pre_steps(lib_dir, pre)
|
|
1606
|
+
experiment_path = resolve_experiment_path(lib_dir, experiment)
|
|
1607
|
+
ingest = os.environ.get("MAS_DEMO_UI_INGEST_URL") or _controller_ingest_url()
|
|
1608
|
+
target_refs = [ref.merge(item) if isinstance(item, dict) else ref for item in (body.targets or [])]
|
|
1609
|
+
if not target_refs:
|
|
1610
|
+
target_refs = [ref]
|
|
1611
|
+
infra_refs, scenario_infra = _live_infra(lib_dir, target_refs)
|
|
1612
|
+
scenario_overlays = _live_overlays(target_refs)
|
|
1613
|
+
spec = {
|
|
1614
|
+
"experiment_yaml": str(experiment_path),
|
|
1615
|
+
"progress": False,
|
|
1616
|
+
"live": True,
|
|
1617
|
+
"force": True,
|
|
1618
|
+
"query": body.query or body.text,
|
|
1619
|
+
"cwd": str(lib_dir),
|
|
1620
|
+
"infra_refs": infra_refs,
|
|
1621
|
+
"scenario_infra_refs": scenario_infra,
|
|
1622
|
+
"scenario_overlays": scenario_overlays,
|
|
1623
|
+
"demo_ui_ingest_url": ingest,
|
|
1624
|
+
"demo_ui_ref": {k: v for k, v in _stamp_ui(ref).as_dict().items() if k != "scenario"},
|
|
1625
|
+
"pre": pre,
|
|
1626
|
+
"endpoint": f"/api/libraries/{ref.library}/experiment/run",
|
|
1627
|
+
"command": f"mas-lab experiment run {experiment_path.name}",
|
|
1628
|
+
}
|
|
1629
|
+
os.environ.setdefault("MAS_DEMO_UI_INGEST_KINDS", "all")
|
|
1630
|
+
state._closed_until_run = False
|
|
1631
|
+
result = get_controller_api().submit_experiment(spec)
|
|
1632
|
+
job_id = str(result.get("job_id") or result.get("worker_id") or "")
|
|
1633
|
+
job_ref = _stamp_ui(ref.merge({"run": job_id, "experiment": experiment, "mode": "live"}))
|
|
1634
|
+
job = _job_payload(job_id, str(result.get("status") or "running"), job_ref, {"command": spec["command"]})
|
|
1635
|
+
state._closed_until_run = False
|
|
1636
|
+
if job_id:
|
|
1637
|
+
state._job_refs[job_id] = job_ref
|
|
1638
|
+
state._job_seen[job_id] = job["status"]
|
|
1639
|
+
_remember_run(job, job_ref)
|
|
1640
|
+
state.bus.publish(envelope("job.started", job_ref, "job", job))
|
|
1641
|
+
await _publish_spec_snapshots(job_ref, body)
|
|
1642
|
+
return {"ok": True, "verb": body.verb.strip(), "job": job, "streams": _streams(), "ws": "/ws"}
|
|
1643
|
+
|
|
1644
|
+
|
|
1645
|
+
async def _start_live_mas(body: CommandBody, ref: DataSourceRef) -> dict[str, Any]:
|
|
1646
|
+
from mas.lab.controller.models import RunMASRequest
|
|
1647
|
+
from mas.lab.controller.routes.run import run_mas
|
|
1648
|
+
|
|
1649
|
+
if not ref.library:
|
|
1650
|
+
raise HTTPException(status_code=400, detail="needs: library")
|
|
1651
|
+
lib_dir = _lib_dir(ref.library)
|
|
1652
|
+
mas_rel = infer_mas_rel(lib_dir, ref.experiment)
|
|
1653
|
+
if not mas_rel:
|
|
1654
|
+
raise HTTPException(status_code=400, detail="needs: experiment (to infer MAS)")
|
|
1655
|
+
overlays = body.overlays or ([ref.overlay] if ref.overlay else [])
|
|
1656
|
+
ingest = os.environ.get("MAS_DEMO_UI_INGEST_URL") or _controller_ingest_url()
|
|
1657
|
+
os.environ.setdefault("MAS_DEMO_UI_INGEST_KINDS", "all")
|
|
1658
|
+
state._closed_until_run = False
|
|
1659
|
+
req = RunMASRequest(
|
|
1660
|
+
query=body.query or body.text,
|
|
1661
|
+
mas_path=mas_rel,
|
|
1662
|
+
overlays=overlays,
|
|
1663
|
+
demo_ui_ingest_url=ingest,
|
|
1664
|
+
demo_ui_ref=_stamp_ui(ref).as_dict(),
|
|
1665
|
+
timeout=body.timeout or 90,
|
|
1666
|
+
)
|
|
1667
|
+
job = await run_mas(ref.library, req)
|
|
1668
|
+
job_id = str(job.get("job_id") or "")
|
|
1669
|
+
job_ref = _stamp_ui(ref.merge({"run": job_id, "session": job.get("session_id"), "mode": "live"}))
|
|
1670
|
+
payload = _job_payload(job_id, str(job.get("status") or "running"), job_ref)
|
|
1671
|
+
payload.update(job)
|
|
1672
|
+
state._closed_until_run = False
|
|
1673
|
+
if job_id:
|
|
1674
|
+
state._job_refs[job_id] = job_ref
|
|
1675
|
+
state._job_seen[job_id] = payload["status"]
|
|
1676
|
+
_remember_run(payload, job_ref)
|
|
1677
|
+
state.bus.publish(envelope("job.started", job_ref, "job", payload))
|
|
1678
|
+
await _publish_spec_snapshot(job_ref)
|
|
1679
|
+
return {"ok": True, "verb": body.verb.strip(), "job": payload, "streams": _streams(), "ws": "/ws"}
|
|
1680
|
+
|
|
1681
|
+
|
|
1682
|
+
async def _publish_spec_snapshots(ref: DataSourceRef, body: CommandBody | None = None) -> None:
|
|
1683
|
+
targets: list[DataSourceRef] = []
|
|
1684
|
+
if body is not None:
|
|
1685
|
+
for item in body.targets or []:
|
|
1686
|
+
if isinstance(item, dict):
|
|
1687
|
+
targets.append(ref.merge(item))
|
|
1688
|
+
for overlay in body.overlays or []:
|
|
1689
|
+
targets.append(ref.merge({"overlay": str(overlay)}))
|
|
1690
|
+
if ref.overlay or ref.scenario:
|
|
1691
|
+
targets.append(ref)
|
|
1692
|
+
uniq: list[DataSourceRef] = []
|
|
1693
|
+
seen: set[tuple[str | None, str | None, str | None, str | None]] = set()
|
|
1694
|
+
for item in targets:
|
|
1695
|
+
key = (item.library, item.experiment, item.scenario, item.overlay)
|
|
1696
|
+
if key in seen:
|
|
1697
|
+
continue
|
|
1698
|
+
seen.add(key)
|
|
1699
|
+
uniq.append(item)
|
|
1700
|
+
if not uniq:
|
|
1701
|
+
await _publish_spec_snapshot(ref)
|
|
1702
|
+
return
|
|
1703
|
+
for item in uniq:
|
|
1704
|
+
await _publish_spec_snapshot(item)
|
|
1705
|
+
|
|
1706
|
+
|
|
1707
|
+
async def _publish_spec_snapshot(ref: DataSourceRef) -> None:
|
|
1708
|
+
if not ref.library:
|
|
1709
|
+
return
|
|
1710
|
+
try:
|
|
1711
|
+
lib_dir = _lib_dir(ref.library)
|
|
1712
|
+
snap = snapshot_for_ref(lib_dir, experiment=ref.experiment, overlay=ref.overlay)
|
|
1713
|
+
state.bus.publish(envelope("spec.snapshot", ref, "spec", snap))
|
|
1714
|
+
state.ingest.seed_spec(ref, snap)
|
|
1715
|
+
except HTTPException as exc:
|
|
1716
|
+
logger.warning("spec snapshot skipped: %s", exc.detail)
|
|
1717
|
+
state.bus.publish(envelope("spec.error", ref, "spec", {"error": str(exc.detail)}))
|
|
1718
|
+
except FileNotFoundError as exc:
|
|
1719
|
+
logger.warning("spec snapshot skipped: %s", exc)
|
|
1720
|
+
state.bus.publish(envelope("spec.error", ref, "spec", {"error": str(exc)}))
|
|
1721
|
+
except Exception as exc:
|
|
1722
|
+
logger.exception("spec snapshot failed")
|
|
1723
|
+
state.bus.publish(envelope("spec.error", ref, "spec", {"error": str(exc)}))
|
|
1724
|
+
|
|
1725
|
+
|
|
1726
|
+
async def _hydrate_specs(layout: dict[str, Any]) -> None:
|
|
1727
|
+
found: list[DataSourceRef] = []
|
|
1728
|
+
|
|
1729
|
+
def walk(node: Any, inherited: DataSourceRef) -> None:
|
|
1730
|
+
if isinstance(node, list):
|
|
1731
|
+
for item in node:
|
|
1732
|
+
walk(item, inherited)
|
|
1733
|
+
return
|
|
1734
|
+
if not isinstance(node, dict):
|
|
1735
|
+
return
|
|
1736
|
+
current = inherited
|
|
1737
|
+
ds = node.get("dataSource")
|
|
1738
|
+
if isinstance(ds, dict):
|
|
1739
|
+
current = inherited.merge(ds)
|
|
1740
|
+
found.append(current)
|
|
1741
|
+
walk(node.get("children"), current)
|
|
1742
|
+
walk(node.get("content"), current)
|
|
1743
|
+
walk(node.get("layout"), current)
|
|
1744
|
+
walk(node.get("leftPanel"), current)
|
|
1745
|
+
walk(node.get("rightPanel"), current)
|
|
1746
|
+
|
|
1747
|
+
walk(layout, DataSourceRef())
|
|
1748
|
+
seen: set[tuple[str | None, str | None, str | None, str | None]] = set()
|
|
1749
|
+
for item in found:
|
|
1750
|
+
key = (item.library, item.experiment, item.scenario, item.overlay)
|
|
1751
|
+
if key in seen or not item.library:
|
|
1752
|
+
continue
|
|
1753
|
+
seen.add(key)
|
|
1754
|
+
await _publish_spec_snapshot(item)
|
|
1755
|
+
|
|
1756
|
+
|
|
1757
|
+
def _index_native(ref: DataSourceRef, record: dict[str, Any]) -> None:
|
|
1758
|
+
if ref.run:
|
|
1759
|
+
state._indexed.setdefault(
|
|
1760
|
+
ref.coordinate() or ref.run,
|
|
1761
|
+
RunRecord(ref=ref),
|
|
1762
|
+
)
|
|
1763
|
+
|
|
1764
|
+
|
|
1765
|
+
def _drop_ingest(ref: DataSourceRef, *, replay: bool) -> bool:
|
|
1766
|
+
if replay:
|
|
1767
|
+
return False
|
|
1768
|
+
if state._closed_until_run:
|
|
1769
|
+
return True
|
|
1770
|
+
return bool(ref.run and ref.run in state._deleted)
|
|
1771
|
+
|
|
1772
|
+
|
|
1773
|
+
def _fold_run_artefact_metrics(ref: DataSourceRef) -> None:
|
|
1774
|
+
"""Phase 1: after a v1 post pipeline, publish cached metrics.json onto the bus."""
|
|
1775
|
+
if not ref.library:
|
|
1776
|
+
return
|
|
1777
|
+
try:
|
|
1778
|
+
lib_dir = _lib_dir(ref.library)
|
|
1779
|
+
except Exception:
|
|
1780
|
+
return
|
|
1781
|
+
scenarios = [ref.scenario] if ref.scenario else ["baseline"]
|
|
1782
|
+
for scenario in scenarios:
|
|
1783
|
+
if not scenario:
|
|
1784
|
+
continue
|
|
1785
|
+
scoped = ref.merge({"scenario": scenario})
|
|
1786
|
+
try:
|
|
1787
|
+
record = resolve_run(lib_dir, scoped, indexed=state._indexed)
|
|
1788
|
+
except Exception:
|
|
1789
|
+
continue
|
|
1790
|
+
from mas.lab.controller.ui_stream.replay import (
|
|
1791
|
+
metric_record_from_artefact,
|
|
1792
|
+
metric_records_from_progress,
|
|
1793
|
+
parse_jsonl,
|
|
1794
|
+
stamp_artefact_metrics,
|
|
1795
|
+
)
|
|
1796
|
+
|
|
1797
|
+
extras: list[dict[str, Any]] = []
|
|
1798
|
+
progress = record.artefacts.get("progress")
|
|
1799
|
+
if progress is not None:
|
|
1800
|
+
extras.extend(metric_records_from_progress(progress))
|
|
1801
|
+
metrics_path = record.artefacts.get("metrics")
|
|
1802
|
+
artefact = metric_record_from_artefact(metrics_path) if metrics_path is not None else None
|
|
1803
|
+
if artefact is not None and record.events is not None and record.events.is_file():
|
|
1804
|
+
events = parse_jsonl(record.events.read_text(encoding="utf-8"))
|
|
1805
|
+
artefact = stamp_artefact_metrics(artefact, events)
|
|
1806
|
+
if artefact is not None:
|
|
1807
|
+
extras.append(artefact)
|
|
1808
|
+
for item in extras:
|
|
1809
|
+
state.ingest.ingest(scoped, item, replay=False)
|
|
1810
|
+
|
|
1811
|
+
|
|
1812
|
+
def _remember_run(job: dict[str, Any], ref: DataSourceRef) -> None:
|
|
1813
|
+
job_id = str(job.get("id") or job.get("job_id") or ref.run or "")
|
|
1814
|
+
if not job_id or job_id in state._deleted:
|
|
1815
|
+
return
|
|
1816
|
+
row = {
|
|
1817
|
+
"id": job_id,
|
|
1818
|
+
"run": ref.run or job_id,
|
|
1819
|
+
"batch": ref.batch,
|
|
1820
|
+
"session": str(job.get("session_id") or ref.session or "") or None,
|
|
1821
|
+
"status": str(job.get("status") or ""),
|
|
1822
|
+
"library": ref.library,
|
|
1823
|
+
"overlay": ref.overlay,
|
|
1824
|
+
"experiment": ref.experiment,
|
|
1825
|
+
"scenario": ref.scenario,
|
|
1826
|
+
"test": ref.test,
|
|
1827
|
+
"mode": ref.mode,
|
|
1828
|
+
"ts": datetime.now(UTC).isoformat(),
|
|
1829
|
+
"origin": ref.origin or UI_ORIGIN,
|
|
1830
|
+
"label": " · ".join(part for part in (ref.overlay or ref.scenario or ref.library or "run", job_id[:8]) if part),
|
|
1831
|
+
"streams": _streams(),
|
|
1832
|
+
}
|
|
1833
|
+
state._runs = [item for item in state._runs if item.get("id") != job_id]
|
|
1834
|
+
state._runs.append(row)
|
|
1835
|
+
if len(state._runs) > 40:
|
|
1836
|
+
state._runs = state._runs[-40:]
|
|
1837
|
+
|
|
1838
|
+
|
|
1839
|
+
def _runs_payload() -> list[dict[str, Any]]:
|
|
1840
|
+
by_id = {
|
|
1841
|
+
item["id"]: item
|
|
1842
|
+
for item in state._runs
|
|
1843
|
+
if item.get("id") and item.get("origin", UI_ORIGIN) == UI_ORIGIN and item.get("id") not in state._deleted
|
|
1844
|
+
}
|
|
1845
|
+
grouped: dict[str, dict[str, Any]] = {}
|
|
1846
|
+
for row in reversed(list(by_id.values())):
|
|
1847
|
+
gid = str(row.get("batch") or row["id"])
|
|
1848
|
+
group = grouped.get(gid)
|
|
1849
|
+
if group is None:
|
|
1850
|
+
group = {
|
|
1851
|
+
"id": gid,
|
|
1852
|
+
"batch": row.get("batch") or None,
|
|
1853
|
+
"jobs": [],
|
|
1854
|
+
"library": row.get("library"),
|
|
1855
|
+
"session": row.get("session"),
|
|
1856
|
+
"status": "",
|
|
1857
|
+
"label": gid[:8],
|
|
1858
|
+
}
|
|
1859
|
+
grouped[gid] = group
|
|
1860
|
+
group["jobs"].append(row)
|
|
1861
|
+
out: list[dict[str, Any]] = []
|
|
1862
|
+
for group in grouped.values():
|
|
1863
|
+
overlays = [str(job.get("overlay") or "") for job in group["jobs"] if job.get("overlay")]
|
|
1864
|
+
scenarios = [str(job.get("scenario") or "") for job in group["jobs"] if job.get("scenario")]
|
|
1865
|
+
stamp = str((group["jobs"][0] or {}).get("ts") or "")
|
|
1866
|
+
time = stamp.split("T", 1)[1][:5] if "T" in stamp else stamp[:16]
|
|
1867
|
+
names = scenarios or overlays
|
|
1868
|
+
short: list[str] = []
|
|
1869
|
+
for name in names:
|
|
1870
|
+
item = name.replace("-round-cap", "").removeprefix("with-").removesuffix("-entry")
|
|
1871
|
+
if item and item not in short:
|
|
1872
|
+
short.append(item)
|
|
1873
|
+
group["time"] = time
|
|
1874
|
+
group["overlays"] = overlays
|
|
1875
|
+
names = " / ".join(short) or group.get("library")
|
|
1876
|
+
group["label"] = " · ".join(part for part in (time, names) if part) or group["id"][:8]
|
|
1877
|
+
statuses = [str(job.get("status") or "") for job in group["jobs"]]
|
|
1878
|
+
if any(item in {"running", "pending"} for item in statuses):
|
|
1879
|
+
group["status"] = "running"
|
|
1880
|
+
elif any(item in {"failed", "cancelled", "timeout"} for item in statuses):
|
|
1881
|
+
group["status"] = "failed"
|
|
1882
|
+
elif statuses:
|
|
1883
|
+
group["status"] = statuses[0]
|
|
1884
|
+
out.append(group)
|
|
1885
|
+
return out
|
|
1886
|
+
|
|
1887
|
+
|
|
1888
|
+
def _arm_job(job_id: str, ref: DataSourceRef, *, command: str = "ingest") -> None:
|
|
1889
|
+
state._job_seen[job_id] = "running"
|
|
1890
|
+
state._job_refs[job_id] = ref
|
|
1891
|
+
job = _job_payload(job_id, "running", ref, {"command": command})
|
|
1892
|
+
state.bus.publish(envelope("job.started", ref, "job", job, replay=True))
|
|
1893
|
+
|
|
1894
|
+
|
|
1895
|
+
def _dump_job_trace(job_id: str, ref: DataSourceRef) -> None:
|
|
1896
|
+
dest_root = os.environ.get("MAS_DEMO_UI_TRACE_DIR")
|
|
1897
|
+
if not dest_root:
|
|
1898
|
+
return
|
|
1899
|
+
dest = Path(dest_root) / job_id
|
|
1900
|
+
rows = [item.as_dict() for item in state.bus.snapshot(("*",), DataSourceRef())]
|
|
1901
|
+
write_ui_trace(dest, rows, extra={"job_id": job_id, "ref": ref.as_dict()})
|
|
1902
|
+
|
|
1903
|
+
|
|
1904
|
+
def _local_jobs() -> list[dict[str, Any]]:
|
|
1905
|
+
return [
|
|
1906
|
+
{
|
|
1907
|
+
"id": job_id,
|
|
1908
|
+
"status": state._job_seen.get(job_id, ""),
|
|
1909
|
+
"origin": UI_ORIGIN,
|
|
1910
|
+
**(state._job_refs[job_id].as_dict() if job_id in state._job_refs else {}),
|
|
1911
|
+
}
|
|
1912
|
+
for job_id in state._job_refs
|
|
1913
|
+
if job_id not in state._deleted
|
|
1914
|
+
]
|
|
1915
|
+
|
|
1916
|
+
|
|
1917
|
+
def _safe_catalog(ref: DataSourceRef) -> dict[str, Any]:
|
|
1918
|
+
from mas.lab.controller.deps import get_manifest_store
|
|
1919
|
+
|
|
1920
|
+
store = get_manifest_store()
|
|
1921
|
+
out: dict[str, Any] = {"libraries": store.libraries()}
|
|
1922
|
+
if ref.library:
|
|
1923
|
+
try:
|
|
1924
|
+
out["experiments"] = store.list_experiments(ref.library)
|
|
1925
|
+
except Exception:
|
|
1926
|
+
out["experiments"] = []
|
|
1927
|
+
try:
|
|
1928
|
+
out["overlays"] = store.list_overlays(ref.library)
|
|
1929
|
+
except Exception:
|
|
1930
|
+
out["overlays"] = []
|
|
1931
|
+
return out
|
|
1932
|
+
|
|
1933
|
+
|
|
1934
|
+
def _forget_run(job_id: str, *, tombstone: bool = True) -> None:
|
|
1935
|
+
task = state._replay_tasks.pop(job_id, None)
|
|
1936
|
+
if task is not None:
|
|
1937
|
+
task.cancel()
|
|
1938
|
+
state._job_refs.pop(job_id, None)
|
|
1939
|
+
state._job_seen.pop(job_id, None)
|
|
1940
|
+
state._job_progress.pop(job_id, None)
|
|
1941
|
+
state._runs = [item for item in state._runs if item.get("id") != job_id]
|
|
1942
|
+
if tombstone:
|
|
1943
|
+
state._deleted.add(job_id)
|
|
1944
|
+
|
|
1945
|
+
|
|
1946
|
+
async def _delete_ui_run(ref: DataSourceRef) -> dict[str, Any]:
|
|
1947
|
+
from mas.lab.controller.api import peek_controller_api
|
|
1948
|
+
|
|
1949
|
+
keys = {part for part in (ref.batch, ref.run, ref.session) if part}
|
|
1950
|
+
ids = [
|
|
1951
|
+
str(item["id"])
|
|
1952
|
+
for item in state._runs
|
|
1953
|
+
if item.get("id") in keys or item.get("batch") in keys
|
|
1954
|
+
]
|
|
1955
|
+
for job_id, pinned in list(state._job_refs.items()):
|
|
1956
|
+
if job_id in keys or pinned.batch in keys or pinned.run in keys:
|
|
1957
|
+
ids.append(job_id)
|
|
1958
|
+
api = peek_controller_api()
|
|
1959
|
+
for job_id in dict.fromkeys(ids):
|
|
1960
|
+
if api is not None:
|
|
1961
|
+
api.drop_worker(job_id)
|
|
1962
|
+
_forget_run(job_id)
|
|
1963
|
+
state.bus.drop(run=job_id, batch=ref.batch)
|
|
1964
|
+
state.bus.publish(envelope("job.deleted", ref.merge({"run": job_id}), "job", {"id": job_id}))
|
|
1965
|
+
return {"ok": True, "verb": "delete-run", "removed": list(dict.fromkeys(ids))}
|
|
1966
|
+
|
|
1967
|
+
|
|
1968
|
+
async def _delete_all_ui_runs() -> dict[str, Any]:
|
|
1969
|
+
from mas.lab.controller.api import peek_controller_api
|
|
1970
|
+
|
|
1971
|
+
ids = list(dict.fromkeys([str(item.get("id") or "") for item in state._runs] + list(state._job_refs)))
|
|
1972
|
+
api = peek_controller_api()
|
|
1973
|
+
if api is not None:
|
|
1974
|
+
api.drop_workers(origin=UI_ORIGIN, terminal_only=False)
|
|
1975
|
+
removed: list[str] = []
|
|
1976
|
+
for job_id in ids:
|
|
1977
|
+
if not job_id:
|
|
1978
|
+
continue
|
|
1979
|
+
_forget_run(job_id)
|
|
1980
|
+
removed.append(job_id)
|
|
1981
|
+
state._runs = []
|
|
1982
|
+
state._job_refs.clear()
|
|
1983
|
+
state._job_seen.clear()
|
|
1984
|
+
state._job_progress.clear()
|
|
1985
|
+
state.bus.drop(origin=UI_ORIGIN)
|
|
1986
|
+
state.bus.publish(envelope("job.deleted", DataSourceRef(origin=UI_ORIGIN), "job", {"removed": removed}))
|
|
1987
|
+
return {"ok": True, "verb": "delete-ui-runs", "removed": list(dict.fromkeys(removed))}
|
|
1988
|
+
|
|
1989
|
+
|
|
1990
|
+
async def _poll_loop() -> None:
|
|
1991
|
+
from mas.lab.controller.api import peek_controller_api
|
|
1992
|
+
|
|
1993
|
+
while True:
|
|
1994
|
+
try:
|
|
1995
|
+
api = peek_controller_api()
|
|
1996
|
+
jobs = api.list_jobs() if api is not None else []
|
|
1997
|
+
for job in jobs:
|
|
1998
|
+
job_id = str(job.get("id") or "")
|
|
1999
|
+
if not job_id or job_id in state._deleted:
|
|
2000
|
+
continue
|
|
2001
|
+
pinned = state._job_refs.get(job_id)
|
|
2002
|
+
if pinned is None and job.get("origin") != UI_ORIGIN:
|
|
2003
|
+
continue
|
|
2004
|
+
status = str(job.get("status") or "")
|
|
2005
|
+
prev = state._job_seen.get(job_id)
|
|
2006
|
+
if prev == status:
|
|
2007
|
+
continue
|
|
2008
|
+
state._job_seen[job_id] = status
|
|
2009
|
+
ref = pinned or DataSourceRef(run=job_id, origin=UI_ORIGIN)
|
|
2010
|
+
_remember_run(job, ref)
|
|
2011
|
+
if prev is None:
|
|
2012
|
+
topic = "job.started"
|
|
2013
|
+
elif status in {"completed", "failed", "cancelled", "timeout"}:
|
|
2014
|
+
topic = "job.completed"
|
|
2015
|
+
else:
|
|
2016
|
+
topic = "job.status_changed"
|
|
2017
|
+
state.bus.publish(envelope(topic, ref, "job", job))
|
|
2018
|
+
if topic == "job.completed" and status == "completed":
|
|
2019
|
+
_fold_run_artefact_metrics(ref)
|
|
2020
|
+
except asyncio.CancelledError:
|
|
2021
|
+
raise
|
|
2022
|
+
except Exception:
|
|
2023
|
+
logger.exception("ui stream poll failed")
|
|
2024
|
+
await asyncio.sleep(1.5)
|
|
2025
|
+
|
|
2026
|
+
|
|
2027
|
+
@asynccontextmanager
|
|
2028
|
+
async def ui_stream_lifespan(_app: FastAPI):
|
|
2029
|
+
state._poll_task = asyncio.create_task(_poll_loop(), name="ui-stream-poll")
|
|
2030
|
+
try:
|
|
2031
|
+
yield
|
|
2032
|
+
finally:
|
|
2033
|
+
await state.close()
|
|
2034
|
+
|
|
2035
|
+
|
|
2036
|
+
_DEMO_UI_SKIP = frozenset({"api", "health", "ws", "docs", "redoc", "openapi.json", "json"})
|
|
2037
|
+
|
|
2038
|
+
|
|
2039
|
+
def _demo_ui_dev_origin() -> str:
|
|
2040
|
+
return str(os.environ.get("MAS_DEMO_UI_DEV_ORIGIN") or "").rstrip("/")
|
|
2041
|
+
|
|
2042
|
+
|
|
2043
|
+
def _esc_html(value: Any) -> str:
|
|
2044
|
+
return (
|
|
2045
|
+
str(value or "")
|
|
2046
|
+
.replace("&", "&")
|
|
2047
|
+
.replace("<", "<")
|
|
2048
|
+
.replace(">", ">")
|
|
2049
|
+
.replace('"', """)
|
|
2050
|
+
)
|
|
2051
|
+
|
|
2052
|
+
|
|
2053
|
+
def _gallery_html(catalog: list[dict[str, Any]]) -> str:
|
|
2054
|
+
cards: list[str] = []
|
|
2055
|
+
for row in catalog:
|
|
2056
|
+
slug = _esc_html(row.get("slug") or "")
|
|
2057
|
+
title = _esc_html(row.get("title") or slug)
|
|
2058
|
+
group = _esc_html(row.get("group") or "")
|
|
2059
|
+
cards.append(
|
|
2060
|
+
'<a class="card" href="#'
|
|
2061
|
+
+ slug
|
|
2062
|
+
+ '"><span class="group">'
|
|
2063
|
+
+ group
|
|
2064
|
+
+ "</span><strong>"
|
|
2065
|
+
+ title
|
|
2066
|
+
+ "</strong><span class=\"slug\">"
|
|
2067
|
+
+ slug
|
|
2068
|
+
+ "</span></a>"
|
|
2069
|
+
)
|
|
2070
|
+
body = "\n".join(cards) or "<p>No layouts found.</p>"
|
|
2071
|
+
return f"""<!DOCTYPE html>
|
|
2072
|
+
<html lang="en">
|
|
2073
|
+
<head>
|
|
2074
|
+
<meta charset="utf-8"/>
|
|
2075
|
+
<title>Demo layouts</title>
|
|
2076
|
+
<style>
|
|
2077
|
+
:root {{ color-scheme: dark; }}
|
|
2078
|
+
body {{ margin: 0; font: 15px/1.45 ui-sans-serif, system-ui, sans-serif; background: #0b1220; color: #e5e7eb; }}
|
|
2079
|
+
main {{ max-width: 960px; margin: 0 auto; padding: 2.5rem 1.25rem; }}
|
|
2080
|
+
h1 {{ font-size: 1.4rem; font-weight: 600; margin: 0 0 0.35rem; }}
|
|
2081
|
+
p {{ color: #94a3b8; margin: 0 0 1.5rem; }}
|
|
2082
|
+
.grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0.85rem; }}
|
|
2083
|
+
.card {{ display: flex; flex-direction: column; gap: 0.3rem; padding: 0.9rem 1rem; border: 1px solid #1f2937; border-radius: 10px; background: #111827; color: inherit; text-decoration: none; }}
|
|
2084
|
+
.card:hover {{ border-color: #38bdf8; }}
|
|
2085
|
+
.card strong {{ font-size: 1rem; }}
|
|
2086
|
+
.group {{ font-size: 0.7rem; letter-spacing: 0.04em; text-transform: uppercase; color: #64748b; }}
|
|
2087
|
+
.slug {{ font: 0.75rem/1.3 ui-monospace, monospace; color: #94a3b8; }}
|
|
2088
|
+
</style>
|
|
2089
|
+
</head>
|
|
2090
|
+
<body>
|
|
2091
|
+
<main>
|
|
2092
|
+
<h1>Demo layouts</h1>
|
|
2093
|
+
<p>Pick an interface. Replay and live telemetry attach after it opens.</p>
|
|
2094
|
+
<div class="grid">{body}</div>
|
|
2095
|
+
</main>
|
|
2096
|
+
</body>
|
|
2097
|
+
</html>
|
|
2098
|
+
"""
|
|
2099
|
+
|
|
2100
|
+
|
|
2101
|
+
async def serve_demo_ui(request: Request, path: str) -> Response:
|
|
2102
|
+
origin = _demo_ui_dev_origin()
|
|
2103
|
+
if origin:
|
|
2104
|
+
import httpx
|
|
2105
|
+
|
|
2106
|
+
url = f"{origin}/{path}" if path else f"{origin}/"
|
|
2107
|
+
if request.url.query:
|
|
2108
|
+
url = f"{url}?{request.url.query}"
|
|
2109
|
+
headers = {key: value for key, value in request.headers.items() if key.lower() not in {"host", "connection"}}
|
|
2110
|
+
try:
|
|
2111
|
+
async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
|
|
2112
|
+
upstream = await client.request(request.method, url, headers=headers)
|
|
2113
|
+
except httpx.HTTPError:
|
|
2114
|
+
upstream = None
|
|
2115
|
+
if upstream is not None:
|
|
2116
|
+
skip = {"content-encoding", "transfer-encoding", "connection", "content-length"}
|
|
2117
|
+
return Response(
|
|
2118
|
+
content=upstream.content,
|
|
2119
|
+
status_code=upstream.status_code,
|
|
2120
|
+
headers={key: value for key, value in upstream.headers.items() if key.lower() not in skip},
|
|
2121
|
+
media_type=upstream.headers.get("content-type"),
|
|
2122
|
+
)
|
|
2123
|
+
from mas.lab.controller.ui_stream.layouts import list_layout_catalog
|
|
2124
|
+
|
|
2125
|
+
return HTMLResponse(_gallery_html(list_layout_catalog(layout_roots())))
|
|
2126
|
+
|
|
2127
|
+
|
|
2128
|
+
def mount_ui_stream(app: FastAPI) -> None:
|
|
2129
|
+
app.include_router(router)
|
|
2130
|
+
|
|
2131
|
+
@app.middleware("http")
|
|
2132
|
+
async def demo_ui_security_headers(request: Request, call_next):
|
|
2133
|
+
response = await call_next(request)
|
|
2134
|
+
for key, value in security_headers().items():
|
|
2135
|
+
response.headers.setdefault(key, value)
|
|
2136
|
+
return response
|
|
2137
|
+
|
|
2138
|
+
@app.get("/", include_in_schema=False)
|
|
2139
|
+
async def demo_ui_index(request: Request) -> Response:
|
|
2140
|
+
return await serve_demo_ui(request, "")
|
|
2141
|
+
|
|
2142
|
+
@app.get("/{path:path}", include_in_schema=False)
|
|
2143
|
+
async def demo_ui_asset(path: str, request: Request) -> Response:
|
|
2144
|
+
head = path.split("/", 1)[0]
|
|
2145
|
+
if head in _DEMO_UI_SKIP:
|
|
2146
|
+
raise HTTPException(status_code=404, detail="not found")
|
|
2147
|
+
return await serve_demo_ui(request, path)
|