mas-lab-component-controller 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. mas/lab/controller/__init__.py +7 -0
  2. mas/lab/controller/__main__.py +5 -0
  3. mas/lab/controller/agent_chat.py +226 -0
  4. mas/lab/controller/api.py +364 -0
  5. mas/lab/controller/artifact_discovery.py +107 -0
  6. mas/lab/controller/artifacts.py +112 -0
  7. mas/lab/controller/cli.py +493 -0
  8. mas/lab/controller/client.py +271 -0
  9. mas/lab/controller/config.py +75 -0
  10. mas/lab/controller/constants.py +30 -0
  11. mas/lab/controller/daemon.py +190 -0
  12. mas/lab/controller/deps.py +172 -0
  13. mas/lab/controller/fastapi_app.py +166 -0
  14. mas/lab/controller/http_server.py +243 -0
  15. mas/lab/controller/io_capture.py +55 -0
  16. mas/lab/controller/item_service.py +625 -0
  17. mas/lab/controller/job_store.py +209 -0
  18. mas/lab/controller/jobs.py +375 -0
  19. mas/lab/controller/kinds.py +149 -0
  20. mas/lab/controller/lab_registry.py +1076 -0
  21. mas/lab/controller/manifest_layout.py +832 -0
  22. mas/lab/controller/manifest_store.py +214 -0
  23. mas/lab/controller/manifest_validation.py +90 -0
  24. mas/lab/controller/models.py +215 -0
  25. mas/lab/controller/path_safety.py +68 -0
  26. mas/lab/controller/pipeline_validation.py +79 -0
  27. mas/lab/controller/registry.py +83 -0
  28. mas/lab/controller/routes/__init__.py +82 -0
  29. mas/lab/controller/routes/_api.py +15 -0
  30. mas/lab/controller/routes/apps.py +190 -0
  31. mas/lab/controller/routes/artifacts.py +37 -0
  32. mas/lab/controller/routes/benchmark.py +307 -0
  33. mas/lab/controller/routes/choices.py +141 -0
  34. mas/lab/controller/routes/datasets.py +91 -0
  35. mas/lab/controller/routes/design_patterns.py +121 -0
  36. mas/lab/controller/routes/eval_output.py +31 -0
  37. mas/lab/controller/routes/experiments.py +194 -0
  38. mas/lab/controller/routes/health.py +178 -0
  39. mas/lab/controller/routes/items.py +187 -0
  40. mas/lab/controller/routes/jobs.py +107 -0
  41. mas/lab/controller/routes/libraries.py +144 -0
  42. mas/lab/controller/routes/overlays.py +164 -0
  43. mas/lab/controller/routes/pipelines.py +139 -0
  44. mas/lab/controller/routes/registry.py +40 -0
  45. mas/lab/controller/routes/run.py +215 -0
  46. mas/lab/controller/routes/runs.py +392 -0
  47. mas/lab/controller/routes/scopes.py +139 -0
  48. mas/lab/controller/routes/uploads.py +24 -0
  49. mas/lab/controller/routes/validate.py +36 -0
  50. mas/lab/controller/run_bridge.py +276 -0
  51. mas/lab/controller/run_store.py +190 -0
  52. mas/lab/controller/schema_registry.py +109 -0
  53. mas/lab/controller/schemas/pipeline-step-types-post.json +764 -0
  54. mas/lab/controller/schemas/pipeline-step-types-pre.json +35 -0
  55. mas/lab/controller/scope_catalog.py +266 -0
  56. mas/lab/controller/scope_service.py +134 -0
  57. mas/lab/controller/sessions.py +63 -0
  58. mas/lab/controller/settings.py +65 -0
  59. mas/lab/controller/ui_stream/__init__.py +13 -0
  60. mas/lab/controller/ui_stream/app.py +2147 -0
  61. mas/lab/controller/ui_stream/artefact_bind.py +308 -0
  62. mas/lab/controller/ui_stream/auth.py +136 -0
  63. mas/lab/controller/ui_stream/bus.py +156 -0
  64. mas/lab/controller/ui_stream/export.py +400 -0
  65. mas/lab/controller/ui_stream/governance.py +141 -0
  66. mas/lab/controller/ui_stream/ingest.py +300 -0
  67. mas/lab/controller/ui_stream/inspect.py +55 -0
  68. mas/lab/controller/ui_stream/layouts.py +161 -0
  69. mas/lab/controller/ui_stream/metrics.py +274 -0
  70. mas/lab/controller/ui_stream/plot_folds.py +609 -0
  71. mas/lab/controller/ui_stream/plugin_registry.py +139 -0
  72. mas/lab/controller/ui_stream/refs.py +259 -0
  73. mas/lab/controller/ui_stream/replay.py +236 -0
  74. mas/lab/controller/ui_stream/replay_cli.py +707 -0
  75. mas/lab/controller/ui_stream/run_index.py +337 -0
  76. mas/lab/controller/ui_stream/spec_load.py +48 -0
  77. mas/lab/controller/ui_stream/spec_snapshot.py +313 -0
  78. mas/lab/controller/ui_stream/stream.py +95 -0
  79. mas/lab/controller/ui_stream/streams.py +42 -0
  80. mas/lab/controller/ui_stream/timeline.py +183 -0
  81. mas/lab/controller/ui_stream/ui_trace.py +258 -0
  82. mas/lab/controller/uploads.py +61 -0
  83. mas/lab/controller/worker_model.py +137 -0
  84. mas/lab/controller/workers.py +296 -0
  85. mas/lab/controller/workspace_library.py +70 -0
  86. mas_lab_component_controller-0.2.0.dist-info/METADATA +21 -0
  87. mas_lab_component_controller-0.2.0.dist-info/RECORD +89 -0
  88. mas_lab_component_controller-0.2.0.dist-info/WHEEL +4 -0
  89. mas_lab_component_controller-0.2.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,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("&", "&amp;")
2047
+ .replace("<", "&lt;")
2048
+ .replace(">", "&gt;")
2049
+ .replace('"', "&quot;")
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)