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,707 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Replay native events.jsonl onto the live demo-ui ingest plane.
4
+
5
+ Default input is ``events.jsonl`` (a file, or a lab dir of cached traces).
6
+ A ui-trace directory is the other input: recorded envelopes, not a MAS
7
+ re-run. Chat / topology / series are live-folded unless those artefacts
8
+ are already in the ui-trace. Experiment pipelines are not run.
9
+
10
+ ``mas-lab replay TRACE`` posts one run. ``mas-lab replay LAB`` posts cached
11
+ traces (``--only`` to pick lanes, ``--align time`` for a shared clock,
12
+ ``--extra SCENARIO=PATH`` for JSON sidecars).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import sys
20
+ import time
21
+ import urllib.error
22
+ import urllib.request
23
+ import uuid
24
+ from collections import Counter
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ DEFAULT_REPLAY_TIME = 15.0
29
+
30
+ from mas.lab.controller.ui_stream.ui_trace import (
31
+ cached_pipeline_files,
32
+ discover_ui_traces,
33
+ envelopes_digest,
34
+ load_jsonl,
35
+ read_ui_trace,
36
+ write_ui_trace,
37
+ )
38
+
39
+
40
+ def ingest_url(url: str | None = None) -> str:
41
+ raw = (
42
+ url
43
+ or os.environ.get("MAS_DEMO_UI_INGEST_URL")
44
+ or os.environ.get("MAS_CONTROLLER_URL")
45
+ or "http://127.0.0.1:8090"
46
+ )
47
+ base = raw.rstrip("/")
48
+ if base.endswith("/api/ingest"):
49
+ return base
50
+ return base + "/api/ingest"
51
+
52
+
53
+ def load_records(path: Path) -> list[dict[str, Any]]:
54
+ records: list[dict[str, Any]] = []
55
+ for line in path.read_text(encoding="utf-8").splitlines():
56
+ line = line.strip()
57
+ if not line:
58
+ continue
59
+ row = json.loads(line)
60
+ if isinstance(row, dict):
61
+ records.append(row)
62
+ return records
63
+
64
+
65
+ def _scenario_of(path: Path) -> str:
66
+ stem = path.stem
67
+ if stem.endswith(".events"):
68
+ stem = stem[: -len(".events")]
69
+ if stem == "events":
70
+ # output/<scenario>/<test>/<run>/traces/events.jsonl
71
+ parts = path.parts
72
+ if "output" in parts:
73
+ idx = parts.index("output")
74
+ if idx + 1 < len(parts):
75
+ return parts[idx + 1]
76
+ if "trace" in parts:
77
+ idx = parts.index("trace")
78
+ if idx > 0:
79
+ return parts[idx - 1]
80
+ if "infra" in parts and ".cache" in parts:
81
+ return path.parent.name
82
+ return path.parent.name
83
+ return stem
84
+
85
+
86
+ def discover_traces(target: Path) -> list[tuple[str, Path]]:
87
+ """Return (scenario, events.jsonl) pairs for a file or a lab directory."""
88
+ target = target.expanduser().resolve()
89
+ if target.is_file():
90
+ return [(_scenario_of(target), target)]
91
+ if not target.is_dir():
92
+ raise FileNotFoundError(target)
93
+ hits: list[tuple[str, Path]] = []
94
+ seen: set[Path] = set()
95
+
96
+ def add(path: Path) -> None:
97
+ resolved = path.resolve()
98
+ if resolved in seen or not resolved.is_file():
99
+ return
100
+ seen.add(resolved)
101
+ hits.append((_scenario_of(resolved), resolved))
102
+
103
+ cache = target / "infra" / ".cache"
104
+ if cache.is_dir():
105
+ for path in sorted(cache.glob("*.events.jsonl")):
106
+ add(path)
107
+ for path in sorted(cache.glob("*.jsonl")):
108
+ if path.name.endswith(".events.jsonl"):
109
+ continue
110
+ add(path)
111
+ if not hits:
112
+ for path in sorted(target.rglob("events.jsonl")):
113
+ add(path)
114
+ if not hits:
115
+ for path in sorted(target.glob("*.events.jsonl")):
116
+ add(path)
117
+ return hits
118
+
119
+
120
+ def post_json(url: str, payload: dict[str, Any], *, timeout: float = 120.0) -> dict[str, Any]:
121
+ body = json.dumps(payload).encode("utf-8")
122
+ from mas.lab.controller.ui_stream.auth import ingest_headers
123
+
124
+ req = urllib.request.Request(
125
+ url,
126
+ data=body,
127
+ headers=ingest_headers(),
128
+ method="POST",
129
+ )
130
+ try:
131
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
132
+ raw = resp.read().decode("utf-8")
133
+ except urllib.error.URLError as exc:
134
+ raise RuntimeError(f"ingest failed at {url}: {exc}") from exc
135
+ try:
136
+ parsed = json.loads(raw)
137
+ except json.JSONDecodeError:
138
+ return {"ok": True, "raw": raw}
139
+ return parsed if isinstance(parsed, dict) else {"ok": True, "raw": parsed}
140
+
141
+
142
+ def post_ingest(
143
+ url: str,
144
+ records: list[dict[str, Any]],
145
+ *,
146
+ ref: dict[str, Any],
147
+ speed: float,
148
+ timeout: float = 120.0,
149
+ ) -> dict[str, Any]:
150
+ return post_json(
151
+ url,
152
+ {
153
+ "ref": ref,
154
+ "records": records,
155
+ "speed": speed,
156
+ "replay": True,
157
+ "nonce": str(uuid.uuid4()),
158
+ },
159
+ timeout=timeout,
160
+ )
161
+
162
+
163
+ def aligned_url(url: str | None = None) -> str:
164
+ dest = ingest_url(url)
165
+ if dest.endswith("/api/ingest"):
166
+ return dest + "/aligned"
167
+ return dest.rstrip("/") + "/api/ingest/aligned"
168
+
169
+
170
+ def api_base(url: str | None = None) -> str:
171
+ dest = ingest_url(url)
172
+ if dest.endswith("/api/ingest"):
173
+ return dest[: -len("/api/ingest")]
174
+ return dest.rstrip("/")
175
+
176
+
177
+ def envelopes_url(url: str | None = None) -> str:
178
+ return api_base(url) + "/api/ingest/envelopes"
179
+
180
+
181
+ def _bar(frac: float, width: int = 20) -> str:
182
+ frac = 0.0 if frac < 0 else 1.0 if frac > 1 else frac
183
+ filled = int(round(frac * width))
184
+ return "#" * filled + "." * (width - filled)
185
+
186
+
187
+ def format_replay_progress_line(job: dict[str, Any], *, wall_s: float | None = None) -> str:
188
+ t0 = float(job.get("t0") or 0.0)
189
+ t1 = float(job.get("t1") or 0.0)
190
+ t = float(job.get("t") or t0)
191
+ span = max(t1 - t0, 0.0)
192
+ elapsed_t = max(0.0, t - t0) if span else 0.0
193
+ frac_t = (elapsed_t / span) if span > 0 else 0.0
194
+ n = int(job.get("n") or 0)
195
+ n_total = int(job.get("n_total") or 0)
196
+ frac_n = (n / n_total) if n_total else 0.0
197
+ bits = [
198
+ f"time [{_bar(frac_t)}] {elapsed_t:.1f}/{span:.1f}s",
199
+ f"events [{_bar(frac_n, 16)}] {n}/{n_total}",
200
+ ]
201
+ trees = job.get("trees") or {}
202
+ if isinstance(trees, dict):
203
+ for name, prog in trees.items():
204
+ if not isinstance(prog, dict):
205
+ continue
206
+ tn = int(prog.get("n") or 0)
207
+ tt = int(prog.get("n_total") or 0)
208
+ frac = (tn / tt) if tt else 0.0
209
+ bits.append(f"{name} [{_bar(frac, 10)}] {tn}/{tt}")
210
+ if wall_s is not None:
211
+ bits.append(f"wall {wall_s:.1f}s")
212
+ return " ".join(bits)
213
+
214
+
215
+ def print_replay_sources(lanes: list[dict[str, Any]] | None) -> None:
216
+ if not lanes:
217
+ return
218
+ print("demo-ui: sources", file=sys.stderr)
219
+ for lane in lanes:
220
+ name = str(lane.get("name") or lane.get("tree") or "?")
221
+ source = str(lane.get("source") or "live")
222
+ loc = str(lane.get("path") or lane.get("fold") or lane.get("tree") or "")
223
+ print(f" {name:<10} {source:<4} {loc}", file=sys.stderr)
224
+
225
+
226
+ def live_fold_lanes(path: Path | str | None = None) -> list[dict[str, Any]]:
227
+ runtime = {"name": "runtime", "source": "file", "tree": "runtime"}
228
+ if path:
229
+ runtime["path"] = str(path)
230
+ return [
231
+ runtime,
232
+ {"name": "chat", "source": "live", "fold": "LivePlotFolds"},
233
+ {"name": "topology", "source": "live", "fold": "LivePlotFolds"},
234
+ {"name": "series", "source": "live", "fold": "LivePlotFolds"},
235
+ ]
236
+
237
+
238
+ def wait_job(
239
+ url: str | None,
240
+ job_id: str,
241
+ *,
242
+ timeout: float = 3600.0,
243
+ expected_s: float | None = None,
244
+ lanes: list[dict[str, Any]] | None = None,
245
+ ) -> dict[str, Any]:
246
+ deadline = time.time() + timeout
247
+ started = time.time()
248
+ base = api_base(url)
249
+ hrefs = (f"{base}/api/ui/jobs/{job_id}", f"{base}/api/jobs/{job_id}")
250
+ last: dict[str, Any] = {"id": job_id, "status": "running"}
251
+ printed_sources = False
252
+ if lanes:
253
+ print_replay_sources(lanes)
254
+ printed_sources = True
255
+ while time.time() < deadline:
256
+ for href in hrefs:
257
+ try:
258
+ with urllib.request.urlopen(href, timeout=10) as resp:
259
+ parsed = json.loads(resp.read().decode("utf-8"))
260
+ if isinstance(parsed, dict):
261
+ last = parsed
262
+ if not printed_sources and last.get("lanes"):
263
+ print_replay_sources(list(last.get("lanes") or []))
264
+ printed_sources = True
265
+ if parsed.get("status") in {"completed", "failed", "cancelled"}:
266
+ elapsed = time.time() - started
267
+ print(
268
+ f"\r{format_replay_progress_line(last, wall_s=elapsed)}",
269
+ file=sys.stderr,
270
+ )
271
+ print(f"demo-ui: {parsed.get('status')} in {elapsed:.1f}s", file=sys.stderr)
272
+ return parsed
273
+ break
274
+ except urllib.error.HTTPError as exc:
275
+ if exc.code != 404:
276
+ pass
277
+ except urllib.error.URLError:
278
+ pass
279
+ now = time.time()
280
+ elapsed = now - started
281
+ display = dict(last)
282
+ if not display.get("t1") and expected_s and expected_s > 0:
283
+ display["t0"] = 0.0
284
+ display["t1"] = expected_s
285
+ display["t"] = min(elapsed, expected_s)
286
+ print(f"\r{format_replay_progress_line(display, wall_s=elapsed)}", end="", file=sys.stderr, flush=True)
287
+ time.sleep(0.25)
288
+ print("", file=sys.stderr)
289
+ last["status"] = "timeout"
290
+ return last
291
+
292
+
293
+ def save_trace(url: str | None, dest: Path, *, scenario: str | None = None) -> Path:
294
+ qs = f"?scenario={scenario}" if scenario else ""
295
+ req = urllib.request.Request(f"{api_base(url)}/api/trace{qs}", method="GET")
296
+ with urllib.request.urlopen(req, timeout=60) as resp:
297
+ body = json.loads(resp.read().decode("utf-8"))
298
+ runtime = body.get("runtime") or []
299
+ pipeline = body.get("pipeline") or []
300
+ return write_ui_trace(dest, runtime + pipeline, extra={"ref": body.get("ref") or {}})
301
+
302
+
303
+ def _record_ts(record: dict[str, Any]) -> float | None:
304
+ raw = record.get("timestamp", record.get("ts", record.get("t")))
305
+ if isinstance(raw, (int, float)):
306
+ value = float(raw)
307
+ return value / 1000.0 if value > 1e12 else value
308
+ if isinstance(raw, str) and raw.strip():
309
+ try:
310
+ return float(raw)
311
+ except ValueError:
312
+ return None
313
+ return None
314
+
315
+
316
+ def record_span(records: list[dict[str, Any]]) -> float:
317
+ times = [ts for ts in (_record_ts(row) for row in records) if ts is not None]
318
+ if not times:
319
+ return 0.0
320
+ return max(0.0, max(times) - min(times))
321
+
322
+
323
+ def replay_speed(
324
+ records: list[dict[str, Any]],
325
+ *,
326
+ speed: float | None = None,
327
+ replay_time: float | None = None,
328
+ ) -> float:
329
+ """Ingest clock. ``speed`` is an explicit factor (1 = recorded gaps, 0 = dump).
330
+
331
+ ``replay_time`` is target wall seconds; the factor is duration / time.
332
+ With neither set, replay_time is 15s.
333
+ """
334
+ if speed is not None:
335
+ return 0.0 if float(speed) <= 0 else float(speed)
336
+ wall = DEFAULT_REPLAY_TIME if replay_time is None else float(replay_time)
337
+ if wall <= 0:
338
+ return 0.0
339
+ span = record_span(records)
340
+ if span <= 0:
341
+ return 1.0
342
+ return span / wall
343
+
344
+
345
+ def trace_info(
346
+ records: list[dict[str, Any]],
347
+ *,
348
+ path: Path | str | None = None,
349
+ replay_time: float | None = None,
350
+ ) -> dict[str, Any]:
351
+ span = record_span(records)
352
+ wall = DEFAULT_REPLAY_TIME if replay_time is None else float(replay_time)
353
+ used = replay_speed(records, replay_time=wall)
354
+ kinds = Counter(str(row.get("kind") or row.get("topic") or "") for row in records)
355
+ agents = sorted(
356
+ {
357
+ str(row.get("agent_id") or (row.get("payload") or {}).get("agent_id") or "")
358
+ for row in records
359
+ if row.get("agent_id") or (isinstance(row.get("payload"), dict) and row["payload"].get("agent_id"))
360
+ }
361
+ )
362
+ agents = [name for name in agents if name]
363
+ return {
364
+ "path": str(path) if path else "",
365
+ "events": len(records),
366
+ "duration_s": span,
367
+ "agents": len(agents),
368
+ "kinds": dict(kinds.most_common()),
369
+ "replay_time_s": wall if wall > 0 else 0.0,
370
+ "speed": used,
371
+ }
372
+
373
+
374
+ def format_trace_info(info: dict[str, Any]) -> str:
375
+ wall = float(info.get("replay_time_s") or 0.0)
376
+ used = float(info.get("speed") or 0.0)
377
+ lines: list[str] = []
378
+ if info.get("path"):
379
+ lines.append(str(info["path"]))
380
+ lines.append(f"events {info.get('events') or 0}")
381
+ lines.append(f"duration {float(info.get('duration_s') or 0):.2f}s")
382
+ lines.append(f"agents {info.get('agents') or 0}")
383
+ if wall > 0 and used > 0:
384
+ lines.append(f"speed {used:.2f}× (--speed {used:.2f} or --replay-time {wall:g})")
385
+ elif used == 0:
386
+ lines.append("speed dump (--speed 0)")
387
+ kinds = info.get("kinds") or {}
388
+ if isinstance(kinds, dict) and kinds:
389
+ top = ", ".join(f"{name} {count}" for name, count in list(kinds.items())[:8])
390
+ lines.append(f"kinds {top}")
391
+ return "\n".join(lines)
392
+
393
+
394
+ def describe_target(
395
+ target: Path,
396
+ *,
397
+ only: tuple[str, ...] | list[str] | None = None,
398
+ replay_time: float | None = None,
399
+ ) -> list[dict[str, Any]]:
400
+ wanted = {name.strip() for name in (only or ()) if str(name).strip()}
401
+ ui_hits = discover_ui_traces(target)
402
+ if ui_hits:
403
+ if wanted:
404
+ ui_hits = [(name, path) for name, path in ui_hits if name in wanted]
405
+ out: list[dict[str, Any]] = []
406
+ for name, path in ui_hits:
407
+ buckets = read_ui_trace(path)
408
+ rows = [row for tree in buckets.values() for row in (tree or [])]
409
+ info = trace_info(rows, path=path, replay_time=replay_time)
410
+ info["scenario"] = name
411
+ out.append(info)
412
+ if out:
413
+ return out
414
+ traces = discover_traces(target)
415
+ if wanted:
416
+ traces = [(name, path) for name, path in traces if name in wanted]
417
+ if not traces:
418
+ raise FileNotFoundError(f"no events.jsonl under {target}")
419
+ out = []
420
+ for name, path in traces:
421
+ info = trace_info(load_records(path), path=path, replay_time=replay_time)
422
+ info["scenario"] = name
423
+ out.append(info)
424
+ return out
425
+
426
+
427
+ def align_sidecar(records: list[dict[str, Any]], peer: list[dict[str, Any]]) -> list[dict[str, Any]]:
428
+ """Map round-index sidecar timestamps onto a peer trace's wall clock."""
429
+ peer_ts = [ts for ts in (_record_ts(row) for row in peer) if ts is not None]
430
+ if not records or not peer_ts:
431
+ return records
432
+ t0, t1 = min(peer_ts), max(peer_ts)
433
+ span = max(t1 - t0, 1e-6)
434
+ own = [ts for ts in (_record_ts(row) for row in records) if ts is not None]
435
+ if own and max(own) >= 1e6:
436
+ return records
437
+ n = max(len(records) - 1, 1)
438
+ out: list[dict[str, Any]] = []
439
+ for i, row in enumerate(records):
440
+ rec = dict(row)
441
+ frac = 0.7 if len(records) == 1 else i / n
442
+ rec["timestamp"] = t0 + span * frac
443
+ out.append(rec)
444
+ return out
445
+
446
+
447
+ def records_from_sidecar(path: Path, *, kind: str = "") -> list[dict[str, Any]]:
448
+ """Turn a JSON or JSONL sidecar into timed records."""
449
+ text = path.read_text(encoding="utf-8")
450
+ stripped = text.lstrip()
451
+ if stripped.startswith("["):
452
+ rows = json.loads(text)
453
+ return [row for row in rows if isinstance(row, dict)]
454
+ if stripped.startswith("{") and "\n{" not in text:
455
+ doc = json.loads(text)
456
+ if not isinstance(doc, dict):
457
+ return []
458
+ topic = kind or str(doc.get("kind") or "custom.snapshot")
459
+ log = doc.get("log")
460
+ if isinstance(log, list) and log:
461
+ out: list[dict[str, Any]] = []
462
+ for i, row in enumerate(log):
463
+ snap = dict(doc)
464
+ snap["kind"] = topic
465
+ snap["log"] = log[: i + 1]
466
+ if isinstance(row, dict):
467
+ snap["t"] = row.get("t", i)
468
+ snap["timestamp"] = row.get("t", i)
469
+ if row.get("x_star"):
470
+ snap["x_star"] = row.get("x_star")
471
+ else:
472
+ snap["timestamp"] = i
473
+ out.append(snap)
474
+ return out
475
+ doc = dict(doc)
476
+ doc.setdefault("kind", topic)
477
+ doc.setdefault("timestamp", doc.get("t") or 0)
478
+ return [doc]
479
+ return load_records(path)
480
+
481
+
482
+ def replay_ui_traces(
483
+ hits: list[tuple[str, Path]],
484
+ *,
485
+ speed: float | None = None,
486
+ replay_time: float | None = None,
487
+ url: str | None = None,
488
+ trees: tuple[str, ...] | None = None,
489
+ recompute: bool = False,
490
+ wait: bool = True,
491
+ ) -> list[dict[str, Any]]:
492
+ lanes = []
493
+ results: list[dict[str, Any]] = []
494
+ wanted = set(trees) if trees else {"runtime", "pipeline"}
495
+ cached_artefacts: list[str] = []
496
+ for name, path in hits:
497
+ if recompute:
498
+ runtime = read_ui_trace(path, trees=("runtime",)).get("runtime") or []
499
+ if runtime:
500
+ lanes.append({"tree": "runtime", "envelopes": runtime, "scenario": name, "path": str(path)})
501
+ digest = envelopes_digest(runtime)
502
+ for spec in cached_pipeline_files(path, digest):
503
+ rows = load_jsonl(path / spec["path"]) if (path / spec["path"]).is_file() else []
504
+ if not rows:
505
+ continue
506
+ owner = str(spec.get("artefact") or "")
507
+ cached_artefacts.append(owner)
508
+ lanes.append({"tree": "pipeline", "envelopes": rows, "scenario": name, "path": str(path)})
509
+ continue
510
+ buckets = read_ui_trace(path, trees=tuple(wanted))
511
+ for tree, rows in buckets.items():
512
+ if rows:
513
+ lanes.append({"tree": tree, "envelopes": rows, "scenario": name, "path": str(path)})
514
+ if not lanes:
515
+ raise FileNotFoundError("ui-trace has no envelopes for the requested trees")
516
+ all_rows = [row for lane in lanes for row in (lane.get("envelopes") or [])]
517
+ used = replay_speed(all_rows, speed=speed, replay_time=replay_time)
518
+ span = record_span(all_rows)
519
+ expected = (span / used) if used > 0 else 0.0
520
+ print(
521
+ f"demo-ui: pacing {len(all_rows)} events, trace={span:.1f}s, "
522
+ f"speedup={used:.2f}×, wall~{expected:.0f}s",
523
+ file=sys.stderr,
524
+ )
525
+ source_lanes: list[dict[str, Any]] = []
526
+ for lane in lanes:
527
+ source_lanes.append(
528
+ {
529
+ "name": lane["tree"],
530
+ "source": "file",
531
+ "tree": lane["tree"],
532
+ "path": lane.get("path") or "",
533
+ }
534
+ )
535
+ has_runtime = any(str(lane.get("tree") or "") == "runtime" for lane in lanes)
536
+ if has_runtime:
537
+ source_lanes.append({"name": "chat", "source": "live", "fold": "LivePlotFolds"})
538
+ if recompute:
539
+ held = {str(name) for name in cached_artefacts if "custom.chat" not in str(name)}
540
+ cached_artefacts = [name for name in cached_artefacts if "custom.chat" not in str(name)]
541
+ for name, artefact, fold in (
542
+ ("topology", "custom.topology", "LivePlotFolds"),
543
+ ("series", "series", "LivePlotFolds"),
544
+ ):
545
+ cached = any(artefact in token for token in held)
546
+ if cached:
547
+ source_lanes.append({"name": name, "source": "file", "path": artefact})
548
+ else:
549
+ source_lanes.append({"name": name, "source": "live", "fold": fold})
550
+ posted = post_json(
551
+ envelopes_url(url),
552
+ {
553
+ "speed": used,
554
+ "recompute": recompute,
555
+ "trees": sorted({lane["tree"] for lane in lanes}),
556
+ "cached_artefacts": cached_artefacts,
557
+ "replay": True,
558
+ "lanes": [{"tree": lane["tree"], "envelopes": lane["envelopes"]} for lane in lanes],
559
+ },
560
+ )
561
+ job_id = str(posted.get("job_id") or "")
562
+ if wait and job_id and used > 0:
563
+ posted.update(wait_job(url, job_id, expected_s=expected or None, lanes=source_lanes))
564
+ elif source_lanes:
565
+ print_replay_sources(source_lanes)
566
+ posted["speed"] = used
567
+ for lane in lanes:
568
+ row = dict(posted)
569
+ row["scenario"] = lane["scenario"]
570
+ row["path"] = lane["path"]
571
+ row["tree"] = lane["tree"]
572
+ row["n"] = len(lane["envelopes"])
573
+ results.append(row)
574
+ return results
575
+
576
+
577
+ def replay_target(
578
+ target: Path,
579
+ *,
580
+ speed: float | None = None,
581
+ replay_time: float | None = None,
582
+ url: str | None = None,
583
+ scenario: str | None = None,
584
+ library: str | None = None,
585
+ experiment: str | None = None,
586
+ overlay: str | None = None,
587
+ extra_ref: dict[str, Any] | None = None,
588
+ only: tuple[str, ...] | list[str] | None = None,
589
+ extras: list[tuple[str, Path]] | None = None,
590
+ align: str = "time",
591
+ wait: bool = True,
592
+ recompute: bool = False,
593
+ trees: tuple[str, ...] | list[str] | None = None,
594
+ record: Path | None = None,
595
+ ) -> list[dict[str, Any]]:
596
+ ui_hits = discover_ui_traces(target)
597
+ wanted_names = {name.strip() for name in (only or ()) if str(name).strip()}
598
+ if ui_hits and not extras:
599
+ if wanted_names:
600
+ ui_hits = [(name, path) for name, path in ui_hits if name in wanted_names]
601
+ if ui_hits:
602
+ results = replay_ui_traces(
603
+ ui_hits,
604
+ speed=speed,
605
+ replay_time=replay_time,
606
+ url=url,
607
+ trees=tuple(trees) if trees else None,
608
+ recompute=recompute,
609
+ wait=wait,
610
+ )
611
+ if record:
612
+ save_trace(url, Path(record), scenario=scenario)
613
+ return results
614
+ traces = discover_traces(target)
615
+ if not traces:
616
+ raise FileNotFoundError(f"no events.jsonl under {target}")
617
+ wanted = {name.strip() for name in (only or ()) if str(name).strip()}
618
+ if wanted:
619
+ traces = [(name, path) for name, path in traces if name in wanted]
620
+ if not traces:
621
+ raise FileNotFoundError(f"no traces matching {sorted(wanted)}")
622
+ lanes: list[dict[str, Any]] = []
623
+ results: list[dict[str, Any]] = []
624
+ for name, path in traces:
625
+ records = load_records(path)
626
+ ref: dict[str, Any] = dict(extra_ref or {})
627
+ ref["scenario"] = scenario if (scenario and len(traces) == 1 and not wanted) else name
628
+ if library:
629
+ ref["library"] = library
630
+ if experiment:
631
+ ref["experiment"] = experiment
632
+ if overlay:
633
+ ref["overlay"] = overlay
634
+ lanes.append({"ref": ref, "records": records, "path": str(path), "scenario": ref["scenario"]})
635
+ by_name = {lane["scenario"]: lane["records"] for lane in lanes}
636
+ for scenario_name, extra_path in extras or []:
637
+ records = records_from_sidecar(Path(extra_path))
638
+ peer = by_name.get(scenario_name) or (lanes[0]["records"] if lanes else [])
639
+ records = align_sidecar(records, peer)
640
+ ref = dict(extra_ref or {})
641
+ ref["scenario"] = scenario_name
642
+ if library:
643
+ ref["library"] = library
644
+ if experiment:
645
+ ref["experiment"] = experiment
646
+ if overlay:
647
+ ref["overlay"] = overlay
648
+ lanes.append({"ref": ref, "records": records, "path": str(extra_path), "scenario": scenario_name})
649
+ all_rows = [row for lane in lanes for row in (lane.get("records") or [])]
650
+ used = replay_speed(all_rows, speed=speed, replay_time=replay_time)
651
+ span = record_span(all_rows)
652
+ expected = (span / used) if used > 0 else 0.0
653
+ print(
654
+ f"demo-ui: pacing {len(all_rows)} events, trace={span:.1f}s, "
655
+ f"speedup={used:.2f}×, wall~{expected:.0f}s",
656
+ file=sys.stderr,
657
+ )
658
+ use_align = align == "time" and len(lanes) > 1
659
+ if use_align:
660
+ posted = post_json(
661
+ aligned_url(url),
662
+ {
663
+ "speed": used,
664
+ "replay": True,
665
+ "lanes": [{"ref": lane["ref"], "records": lane["records"]} for lane in lanes],
666
+ },
667
+ )
668
+ job_id = str(posted.get("job_id") or "")
669
+ source_lanes = live_fold_lanes(lanes[0]["path"] if lanes else None)
670
+ for extra in lanes[1:]:
671
+ source_lanes.append(
672
+ {"name": extra["scenario"], "source": "file", "path": extra["path"], "tree": "runtime"}
673
+ )
674
+ if wait and job_id and used > 0:
675
+ posted.update(wait_job(url, job_id, expected_s=expected or None, lanes=source_lanes))
676
+ else:
677
+ print_replay_sources(source_lanes)
678
+ posted["speed"] = used
679
+ for lane in lanes:
680
+ row = dict(posted)
681
+ row["scenario"] = lane["scenario"]
682
+ row["path"] = lane["path"]
683
+ row["n"] = len(lane["records"])
684
+ row["align"] = "time"
685
+ results.append(row)
686
+ if record:
687
+ save_trace(url, Path(record), scenario=scenario)
688
+ return results
689
+ dest = ingest_url(url)
690
+ for lane in lanes:
691
+ posted = post_ingest(dest, lane["records"], ref=lane["ref"], speed=used)
692
+ job_id = str(posted.get("job_id") or "")
693
+ source_lanes = live_fold_lanes(lane["path"])
694
+ if wait and job_id and used > 0:
695
+ posted.update(wait_job(url, job_id, expected_s=expected or None, lanes=source_lanes))
696
+ else:
697
+ print_replay_sources(source_lanes)
698
+ posted["scenario"] = lane["scenario"]
699
+ posted["path"] = lane["path"]
700
+ posted["speed"] = used
701
+ if posted.get("n") is None:
702
+ posted["n"] = len(lane["records"])
703
+ posted["align"] = "gap"
704
+ results.append(posted)
705
+ if record:
706
+ save_trace(url, Path(record), scenario=scenario)
707
+ return results