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,493 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """CLI extension — ``mas-lab control`` and ``mas-lab worker`` command groups."""
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+
12
+ import click
13
+
14
+
15
+ @click.group("control")
16
+ def control_group() -> None:
17
+ """Controller daemon lifecycle (Unix socket + optional HTTP)."""
18
+
19
+
20
+ @control_group.command("start")
21
+ @click.option("--port", "-p", type=int, default=9000, show_default=True, help="HTTP port for UI.")
22
+ @click.option("--no-http", is_flag=True, help="IPC only — no HTTP server.")
23
+ @click.option("--foreground", "-f", is_flag=True, help="Run daemon in foreground (dev).")
24
+ def control_start(port: int, no_http: bool, foreground: bool) -> None:
25
+ """Start the MAS Lab controller daemon."""
26
+ from mas.lab.controller.client import ControllerClient, drift_message, start_daemon
27
+
28
+ client = ControllerClient()
29
+ if client.is_running():
30
+ click.echo("Controller is already running.")
31
+ drift = client.drift()
32
+ if drift:
33
+ click.echo(f"Warning: {drift_message(drift)}", err=True)
34
+ return
35
+ if foreground:
36
+ import sys
37
+
38
+ argv = ["--port", str(port)]
39
+ if no_http:
40
+ argv.append("--no-http")
41
+ from mas.lab.controller.daemon import main
42
+
43
+ raise SystemExit(main(argv))
44
+ start_daemon(port=port)
45
+ for _ in range(40):
46
+ if client.is_running():
47
+ click.echo(f"Controller started (HTTP http://127.0.0.1:{port}).")
48
+ return
49
+ time.sleep(0.25)
50
+ click.echo("Controller start initiated — verify with: mas-lab control status")
51
+
52
+
53
+ @control_group.command("stop")
54
+ def control_stop() -> None:
55
+ """Stop the controller daemon."""
56
+ from mas.lab.controller.client import stop_daemon
57
+
58
+ stop_daemon()
59
+ click.echo("Controller stopped.")
60
+
61
+
62
+ @control_group.command("status")
63
+ def control_status() -> None:
64
+ """Show controller daemon status."""
65
+ from mas.lab.controller.client import ControllerClient, drift_message
66
+ from mas.lab.controller import config as cfg
67
+
68
+ client = ControllerClient()
69
+ if not client.is_running():
70
+ click.echo(f"Controller: not running (socket {cfg.socket_path()})")
71
+ raise SystemExit(1)
72
+ info = client.call("status")
73
+ pid_path = cfg.pid_path()
74
+ pid = pid_path.read_text(encoding="utf-8").strip() if pid_path.exists() else "?"
75
+ click.echo(f"Controller: running (pid {pid})")
76
+ click.echo(json.dumps(info, indent=2))
77
+ drift = client.drift()
78
+ if drift:
79
+ click.echo(f"Warning: {drift_message(drift)}", err=True)
80
+
81
+
82
+ @click.group("worker")
83
+ def worker_group() -> None:
84
+ """List and control background workers."""
85
+
86
+
87
+ @worker_group.command("list")
88
+ @click.option("--kind", type=click.Choice(["application", "benchmark", "pipeline"]), default=None)
89
+ @click.option("--status", default=None)
90
+ def worker_list(kind: str | None, status: str | None) -> None:
91
+ """List workers."""
92
+ from mas.lab.controller.client import ControllerClient
93
+
94
+ client = ControllerClient()
95
+ client.ensure_running()
96
+ workers = client.call("list_workers", {"kind": kind, "status": status})
97
+ if not workers:
98
+ click.echo("No workers.")
99
+ return
100
+ for w in workers:
101
+ click.echo(f"{w['id']}\t{w['status']}\t{w.get('command', '')}")
102
+
103
+
104
+ @worker_group.command("show")
105
+ @click.argument("worker_id")
106
+ def worker_show(worker_id: str) -> None:
107
+ """Show worker details."""
108
+ from mas.lab.controller.client import ControllerClient
109
+
110
+ client = ControllerClient()
111
+ client.ensure_running()
112
+ detail = client.call("get_worker", {"worker_id": worker_id})
113
+ if detail is None:
114
+ click.secho(f"Worker not found: {worker_id}", fg="red")
115
+ raise SystemExit(1)
116
+ click.echo(json.dumps(detail, indent=2))
117
+
118
+
119
+ @worker_group.command("cancel")
120
+ @click.argument("worker_id")
121
+ def worker_cancel(worker_id: str) -> None:
122
+ """Cancel a running worker."""
123
+ from mas.lab.controller.client import ControllerClient
124
+
125
+ client = ControllerClient()
126
+ client.ensure_running()
127
+ client.call("cancel_worker", {"worker_id": worker_id})
128
+ click.echo(f"Cancel requested for {worker_id}")
129
+
130
+
131
+ @worker_group.command("follow")
132
+ @click.argument("worker_id")
133
+ @click.option("--interval", type=float, default=0.5, show_default=True,
134
+ help="Poll interval in seconds (REST poll, same as blocking benchmark run).")
135
+ def worker_follow(worker_id: str, interval: float) -> None:
136
+ """Follow worker output until completion (polls daemon, streams stdout/stderr)."""
137
+ from mas.lab.controller.client import follow_worker
138
+
139
+ try:
140
+ detail = follow_worker(worker_id, poll=interval, stream=True)
141
+ except RuntimeError as exc:
142
+ click.secho(str(exc), fg="red")
143
+ raise SystemExit(1) from exc
144
+
145
+ status = detail.get("status")
146
+ click.echo(f"\nWorker {worker_id}: {status} (exit {detail.get('exit_code')})")
147
+ ok = status == "completed" and (detail.get("exit_code") in (0, None))
148
+ raise SystemExit(0 if ok else 1)
149
+
150
+
151
+ @click.group("demo-ui")
152
+ def demo_ui_group() -> None:
153
+ """Offline demo-ui export (same plugins, no backend)."""
154
+
155
+
156
+ @demo_ui_group.command("export")
157
+ @click.argument("trace", type=click.Path(exists=True, path_type=Path), required=False)
158
+ @click.option("-o", "--output", type=click.Path(path_type=Path), required=True)
159
+ @click.option("--layout", default="run-view.yaml", show_default=True)
160
+ @click.option(
161
+ "--layouts",
162
+ "layouts_root",
163
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
164
+ default=None,
165
+ help=(
166
+ "Directory to resolve --layout from, e.g. "
167
+ "labs/<app>.lab/demo-ui/layouts for a library-specific layout that "
168
+ "isn't under the top-level demo-ui/layouts/. Defaults to the "
169
+ "top-level demo-ui/layouts/ (same as live app.py's layout_roots() "
170
+ "first entry) when omitted -- unlike the live server, this command "
171
+ "does not search every registered library's own demo-ui/layouts/ "
172
+ "automatically, so a library-specific layout needs this flag."
173
+ ),
174
+ )
175
+ @click.option(
176
+ "--scenario",
177
+ "scenarios",
178
+ multiple=True,
179
+ metavar="NAME=TRACE",
180
+ help=(
181
+ "Merge-bundle mode for a multi-panel compare layout: repeat once per "
182
+ "panel scenario, e.g. --scenario baseline=infra/.cache/baseline.events.jsonl "
183
+ "--scenario overlay-b=infra/.cache/overlay-b.events.jsonl ... Each panel's own "
184
+ "dataSource.scenario in --layout must match one of these NAMEs. Ignores "
185
+ "the positional TRACE argument when given."
186
+ ),
187
+ )
188
+ @click.option("--no-build", is_flag=True, help="Reuse demo-ui/dist if present.")
189
+ @click.option("--serve", is_flag=True, help="Serve the export and open a browser.")
190
+ @click.option("--port", type=int, default=8766, show_default=True)
191
+ def demo_ui_export(
192
+ trace: Path | None,
193
+ output: Path,
194
+ layout: str,
195
+ layouts_root: Path | None,
196
+ scenarios: tuple[str, ...],
197
+ no_build: bool,
198
+ serve: bool,
199
+ port: int,
200
+ ) -> None:
201
+ """Write a static site from events.jsonl (or a trace directory).
202
+
203
+ With one or more --scenario NAME=TRACE options, builds one merged bundle
204
+ covering all of them instead -- for a multi-panel compare layout where
205
+ each panel scopes to its own dataSource.scenario (see
206
+ export_merged_trace's docstring for why one merged bundle, rather than
207
+ one per panel, is what the frontend actually needs).
208
+ """
209
+ from mas.lab.controller.ui_stream.export import (
210
+ export_merged_trace,
211
+ export_trace,
212
+ main as export_main,
213
+ )
214
+
215
+ if scenarios:
216
+ scenario_traces: dict[str, Path] = {}
217
+ for item in scenarios:
218
+ if "=" not in item:
219
+ raise click.BadParameter(f"expected NAME=TRACE, got {item!r}", param_hint="--scenario")
220
+ name, _, raw_path = item.partition("=")
221
+ name = name.strip()
222
+ trace_path = Path(raw_path.strip())
223
+ if not trace_path.exists():
224
+ raise click.BadParameter(f"no such path: {trace_path}", param_hint="--scenario")
225
+ scenario_traces[name] = trace_path
226
+ dest = export_merged_trace(
227
+ scenario_traces, output, layout_name=layout, layouts_root=layouts_root, build_ui=not no_build
228
+ )
229
+ click.echo(f"Wrote {dest} ({len(scenario_traces)} scenarios merged: {', '.join(scenario_traces)})")
230
+ if serve:
231
+ subprocess.run(
232
+ [sys.executable, "-m", "http.server", str(port), "--bind", "127.0.0.1", "--directory", str(dest)],
233
+ check=False,
234
+ )
235
+ else:
236
+ click.echo(f"python3 -m http.server {port} --bind 127.0.0.1 --directory {dest}")
237
+ return
238
+
239
+ if trace is None:
240
+ raise click.UsageError("TRACE is required unless --scenario is given")
241
+
242
+ if serve:
243
+ argv = [str(trace), "-o", str(output), "--layout", layout, "--serve", "--port", str(port)]
244
+ if no_build:
245
+ argv.append("--no-build")
246
+ raise SystemExit(export_main(argv))
247
+ dest = export_trace(trace, output, layout_name=layout, layouts_root=layouts_root, build_ui=not no_build)
248
+ click.echo(f"Wrote {dest}")
249
+ click.echo(f"python3 -m http.server {port} --bind 127.0.0.1 --directory {dest}")
250
+
251
+
252
+ @click.command("replay")
253
+ @click.argument("target", type=click.Path(exists=True, path_type=Path))
254
+ @click.option(
255
+ "--speed",
256
+ type=float,
257
+ default=None,
258
+ help="Speedup: 1 = recorded gaps, 8 = 8×, 0 = dump. Default is --replay-time 15.",
259
+ )
260
+ @click.option(
261
+ "--replay-time",
262
+ type=float,
263
+ default=None,
264
+ help="Target wall seconds; sets --speed to duration/time (default 15).",
265
+ )
266
+ @click.option(
267
+ "--info",
268
+ "show_info",
269
+ is_flag=True,
270
+ help="Print duration, event count, and speedup; do not ingest.",
271
+ )
272
+ @click.option("--json", "as_json", is_flag=True, help="JSON for --info.")
273
+ @click.option(
274
+ "--url",
275
+ default=None,
276
+ help="Ingest URL. Default MAS_DEMO_UI_INGEST_URL or http://127.0.0.1:8090/api/ingest.",
277
+ )
278
+ @click.option("--scenario", default=None, help="Override the scenario stamp on a single-run replay.")
279
+ @click.option("--library", default=None)
280
+ @click.option("--experiment", default=None)
281
+ @click.option("--overlay", default=None)
282
+ @click.option(
283
+ "--ref",
284
+ "ref_json",
285
+ default=None,
286
+ help="Extra JSON object merged into each ingest ref.",
287
+ )
288
+ @click.option(
289
+ "--only",
290
+ default=None,
291
+ help="Comma-separated scenario names to replay (e.g. baseline,overlay-b).",
292
+ )
293
+ @click.option(
294
+ "--align",
295
+ type=click.Choice(["time", "gap"]),
296
+ default="time",
297
+ show_default=True,
298
+ help="time = one shared clock across files; gap = each file on its own gaps.",
299
+ )
300
+ @click.option(
301
+ "--extra",
302
+ "extras",
303
+ multiple=True,
304
+ help="Sidecar file as SCENARIO=PATH (JSON snapshot or detector dump). Repeatable.",
305
+ )
306
+ @click.option("--wait/--detach", default=True, show_default=True, help="Wait until the replay job finishes.")
307
+ @click.option(
308
+ "--recompute",
309
+ is_flag=True,
310
+ help="ui-trace only: replay runtime and re-fold missing plot artefacts. Does not run the MAS or experiment pipelines.",
311
+ )
312
+ @click.option(
313
+ "--trees",
314
+ default=None,
315
+ help="ui-trace only: comma-separated trees to play (runtime,pipeline).",
316
+ )
317
+ @click.option(
318
+ "--record",
319
+ type=click.Path(path_type=Path),
320
+ default=None,
321
+ help="Write a ui-trace directory (runtime.jsonl + pipeline.jsonl) after the job.",
322
+ )
323
+ def replay_cmd(
324
+ target: Path,
325
+ speed: float | None,
326
+ replay_time: float | None,
327
+ show_info: bool,
328
+ as_json: bool,
329
+ url: str | None,
330
+ scenario: str | None,
331
+ library: str | None,
332
+ experiment: str | None,
333
+ overlay: str | None,
334
+ ref_json: str | None,
335
+ only: str | None,
336
+ align: str,
337
+ extras: tuple[str, ...],
338
+ wait: bool,
339
+ recompute: bool,
340
+ trees: str | None,
341
+ record: Path | None,
342
+ ) -> None:
343
+ """Replay native events.jsonl (default) or a recorded ui-trace directory.
344
+
345
+ Default input is native ``events.jsonl``. A file is that jsonl. A directory
346
+ uses ``infra/.cache/*.events.jsonl``, else ``**/events.jsonl``. A ui-trace
347
+ dir (manifest.json + runtime.jsonl) wins over nested events.jsonl.
348
+
349
+ Chat, topology, and series are live-folded from those records.
350
+ Experiment pipelines and the MAS are not executed. ``--recompute`` only
351
+ re-folds missing ui-trace plot artefacts.
352
+
353
+ ``--align time`` (default for several files) shares one clock.
354
+ """
355
+ extra: dict[str, object] = {}
356
+ if ref_json:
357
+ parsed = json.loads(ref_json)
358
+ if not isinstance(parsed, dict):
359
+ raise click.BadParameter("--ref must be a JSON object")
360
+ extra = parsed
361
+ only_names = tuple(part.strip() for part in (only or "").split(",") if part.strip())
362
+ extra_files: list[tuple[str, Path]] = []
363
+ for item in extras:
364
+ if "=" not in item:
365
+ raise click.BadParameter("--extra needs SCENARIO=PATH")
366
+ name, path = item.split("=", 1)
367
+ extra_files.append((name.strip(), Path(path)))
368
+ from mas.lab.controller.ui_stream.replay_cli import describe_target, format_trace_info, replay_target
369
+
370
+ if speed is not None and replay_time is not None:
371
+ raise click.UsageError("use --speed or --replay-time, not both")
372
+ if show_info:
373
+ try:
374
+ rows = describe_target(target, only=only_names, replay_time=replay_time)
375
+ except FileNotFoundError as exc:
376
+ raise click.ClickException(str(exc)) from exc
377
+ if as_json:
378
+ click.echo(json.dumps(rows, indent=2, default=str))
379
+ else:
380
+ for i, row in enumerate(rows):
381
+ if i:
382
+ click.echo("")
383
+ click.echo(format_trace_info(row))
384
+ return
385
+
386
+ try:
387
+ results = replay_target(
388
+ target,
389
+ speed=speed,
390
+ replay_time=replay_time,
391
+ url=url,
392
+ scenario=scenario,
393
+ library=library,
394
+ experiment=experiment,
395
+ overlay=overlay,
396
+ extra_ref=extra,
397
+ only=only_names,
398
+ extras=extra_files,
399
+ align=align,
400
+ wait=wait,
401
+ recompute=recompute,
402
+ trees=tuple(part.strip() for part in (trees or "").split(",") if part.strip()),
403
+ record=record,
404
+ )
405
+ except FileNotFoundError as exc:
406
+ raise click.ClickException(str(exc)) from exc
407
+ except RuntimeError as exc:
408
+ raise click.ClickException(str(exc)) from exc
409
+ dropped = False
410
+ for row in results:
411
+ if row.get("dropped"):
412
+ dropped = True
413
+ click.echo(
414
+ f"{row.get('scenario')}: dropped n=0 path={row.get('path')}",
415
+ err=True,
416
+ )
417
+ continue
418
+ n = row.get("n")
419
+ click.echo(
420
+ f"{row.get('scenario')}: n={0 if n is None else n} "
421
+ f"job={row.get('job_id') or 'ok'} path={row.get('path')}"
422
+ )
423
+ if dropped:
424
+ raise click.ClickException(
425
+ "ingest dropped — Reset had closed Passive ingest; retry this command"
426
+ )
427
+
428
+
429
+ @demo_ui_group.command("replay")
430
+ @click.argument("target", type=click.Path(exists=True, path_type=Path))
431
+ @click.option("--speed", type=float, default=None)
432
+ @click.option("--replay-time", type=float, default=None)
433
+ @click.option("--info", "show_info", is_flag=True)
434
+ @click.option("--json", "as_json", is_flag=True)
435
+ @click.option("--url", default=None)
436
+ @click.option("--scenario", default=None)
437
+ @click.option("--library", default=None)
438
+ @click.option("--experiment", default=None)
439
+ @click.option("--overlay", default=None)
440
+ @click.option("--ref", "ref_json", default=None)
441
+ @click.option("--only", default=None)
442
+ @click.option("--align", type=click.Choice(["time", "gap"]), default="time")
443
+ @click.option("--extra", "extras", multiple=True)
444
+ @click.pass_context
445
+ def demo_ui_replay(
446
+ ctx: click.Context,
447
+ target: Path,
448
+ speed: float | None,
449
+ replay_time: float | None,
450
+ show_info: bool,
451
+ as_json: bool,
452
+ url: str | None,
453
+ scenario: str | None,
454
+ library: str | None,
455
+ experiment: str | None,
456
+ overlay: str | None,
457
+ ref_json: str | None,
458
+ only: str | None,
459
+ align: str,
460
+ extras: tuple[str, ...],
461
+ ) -> None:
462
+ """Alias for ``mas-lab replay``."""
463
+ ctx.invoke(
464
+ replay_cmd,
465
+ target=target,
466
+ speed=speed,
467
+ replay_time=replay_time,
468
+ show_info=show_info,
469
+ as_json=as_json,
470
+ url=url,
471
+ scenario=scenario,
472
+ library=library,
473
+ experiment=experiment,
474
+ overlay=overlay,
475
+ ref_json=ref_json,
476
+ only=only,
477
+ align=align,
478
+ extras=extras,
479
+ wait=True,
480
+ recompute=False,
481
+ trees=None,
482
+ record=None,
483
+ )
484
+
485
+
486
+ class ControllerCliComponent:
487
+ """Entry-point hook for mas.lab.cli.components."""
488
+
489
+ def register(self, app: click.Group) -> None:
490
+ app.add_command(control_group, name="control")
491
+ app.add_command(worker_group, name="worker")
492
+ app.add_command(demo_ui_group, name="demo-ui")
493
+ app.add_command(replay_cmd, name="replay")