mas-lab-component-controller 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mas/lab/controller/__init__.py +7 -0
- mas/lab/controller/__main__.py +5 -0
- mas/lab/controller/agent_chat.py +226 -0
- mas/lab/controller/api.py +364 -0
- mas/lab/controller/artifact_discovery.py +107 -0
- mas/lab/controller/artifacts.py +112 -0
- mas/lab/controller/cli.py +493 -0
- mas/lab/controller/client.py +271 -0
- mas/lab/controller/config.py +75 -0
- mas/lab/controller/constants.py +30 -0
- mas/lab/controller/daemon.py +190 -0
- mas/lab/controller/deps.py +172 -0
- mas/lab/controller/fastapi_app.py +166 -0
- mas/lab/controller/http_server.py +243 -0
- mas/lab/controller/io_capture.py +55 -0
- mas/lab/controller/item_service.py +625 -0
- mas/lab/controller/job_store.py +209 -0
- mas/lab/controller/jobs.py +375 -0
- mas/lab/controller/kinds.py +149 -0
- mas/lab/controller/lab_registry.py +1076 -0
- mas/lab/controller/manifest_layout.py +832 -0
- mas/lab/controller/manifest_store.py +214 -0
- mas/lab/controller/manifest_validation.py +90 -0
- mas/lab/controller/models.py +215 -0
- mas/lab/controller/path_safety.py +68 -0
- mas/lab/controller/pipeline_validation.py +79 -0
- mas/lab/controller/registry.py +83 -0
- mas/lab/controller/routes/__init__.py +82 -0
- mas/lab/controller/routes/_api.py +15 -0
- mas/lab/controller/routes/apps.py +190 -0
- mas/lab/controller/routes/artifacts.py +37 -0
- mas/lab/controller/routes/benchmark.py +307 -0
- mas/lab/controller/routes/choices.py +141 -0
- mas/lab/controller/routes/datasets.py +91 -0
- mas/lab/controller/routes/design_patterns.py +121 -0
- mas/lab/controller/routes/eval_output.py +31 -0
- mas/lab/controller/routes/experiments.py +194 -0
- mas/lab/controller/routes/health.py +178 -0
- mas/lab/controller/routes/items.py +187 -0
- mas/lab/controller/routes/jobs.py +107 -0
- mas/lab/controller/routes/libraries.py +144 -0
- mas/lab/controller/routes/overlays.py +164 -0
- mas/lab/controller/routes/pipelines.py +139 -0
- mas/lab/controller/routes/registry.py +40 -0
- mas/lab/controller/routes/run.py +215 -0
- mas/lab/controller/routes/runs.py +392 -0
- mas/lab/controller/routes/scopes.py +139 -0
- mas/lab/controller/routes/uploads.py +24 -0
- mas/lab/controller/routes/validate.py +36 -0
- mas/lab/controller/run_bridge.py +276 -0
- mas/lab/controller/run_store.py +190 -0
- mas/lab/controller/schema_registry.py +109 -0
- mas/lab/controller/schemas/pipeline-step-types-post.json +764 -0
- mas/lab/controller/schemas/pipeline-step-types-pre.json +35 -0
- mas/lab/controller/scope_catalog.py +266 -0
- mas/lab/controller/scope_service.py +134 -0
- mas/lab/controller/sessions.py +63 -0
- mas/lab/controller/settings.py +65 -0
- mas/lab/controller/ui_stream/__init__.py +13 -0
- mas/lab/controller/ui_stream/app.py +2147 -0
- mas/lab/controller/ui_stream/artefact_bind.py +308 -0
- mas/lab/controller/ui_stream/auth.py +136 -0
- mas/lab/controller/ui_stream/bus.py +156 -0
- mas/lab/controller/ui_stream/export.py +400 -0
- mas/lab/controller/ui_stream/governance.py +141 -0
- mas/lab/controller/ui_stream/ingest.py +300 -0
- mas/lab/controller/ui_stream/inspect.py +55 -0
- mas/lab/controller/ui_stream/layouts.py +161 -0
- mas/lab/controller/ui_stream/metrics.py +274 -0
- mas/lab/controller/ui_stream/plot_folds.py +609 -0
- mas/lab/controller/ui_stream/plugin_registry.py +139 -0
- mas/lab/controller/ui_stream/refs.py +259 -0
- mas/lab/controller/ui_stream/replay.py +236 -0
- mas/lab/controller/ui_stream/replay_cli.py +707 -0
- mas/lab/controller/ui_stream/run_index.py +337 -0
- mas/lab/controller/ui_stream/spec_load.py +48 -0
- mas/lab/controller/ui_stream/spec_snapshot.py +313 -0
- mas/lab/controller/ui_stream/stream.py +95 -0
- mas/lab/controller/ui_stream/streams.py +42 -0
- mas/lab/controller/ui_stream/timeline.py +183 -0
- mas/lab/controller/ui_stream/ui_trace.py +258 -0
- mas/lab/controller/uploads.py +61 -0
- mas/lab/controller/worker_model.py +137 -0
- mas/lab/controller/workers.py +296 -0
- mas/lab/controller/workspace_library.py +70 -0
- mas_lab_component_controller-0.2.0.dist-info/METADATA +21 -0
- mas_lab_component_controller-0.2.0.dist-info/RECORD +89 -0
- mas_lab_component_controller-0.2.0.dist-info/WHEEL +4 -0
- mas_lab_component_controller-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,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
|