tracellm-cli 0.1.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.
tracellm/monitor.py ADDED
@@ -0,0 +1,381 @@
1
+ """Live monitor dashboard — htop for AI systems.
2
+
3
+ Connects to the backend WebSocket for real-time trace events, falls back to
4
+ polling MongoDB when the server is unavailable, and reconnects automatically
5
+ on disconnect.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import os
13
+ import queue
14
+ import threading
15
+ import time
16
+ from typing import Any
17
+
18
+ from rich.live import Live
19
+ from rich.panel import Panel
20
+ from rich.table import Table
21
+ from rich.text import Text
22
+
23
+ from tracellm.mascot import MascotState, message
24
+ from tracellm.utils import console, status_style, latency_style
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ _WS_DEFAULT_HOST = os.environ.get("TRACELLM_WS_HOST", "127.0.0.1")
29
+ _WS_DEFAULT_PORT = int(os.environ.get("TRACELLM_WS_PORT", "8000"))
30
+
31
+
32
+ def _discover_ws_endpoint(hint_host: str, hint_port: int) -> tuple[str, int]:
33
+ """Probe common ports if the hinted endpoint is unreachable.
34
+
35
+ Tries the hint first, then falls through to common alternatives.
36
+ Returns the first port that responds to a TCP connect.
37
+ """
38
+ import socket
39
+
40
+ def _probe(host: str, port: int) -> bool:
41
+ try:
42
+ with socket.create_connection((host, port), timeout=0.5):
43
+ return True
44
+ except (OSError, socket.error):
45
+ return False
46
+
47
+ if _probe(hint_host, hint_port):
48
+ return hint_host, hint_port
49
+
50
+ common_ports = [8000, 8001, 8080, 3000]
51
+ tried = {hint_port}
52
+ for p in common_ports:
53
+ if p not in tried:
54
+ tried.add(p)
55
+ if _probe(hint_host, p):
56
+ logger.info("Auto-discovered WS endpoint on port %d", p)
57
+ return hint_host, p
58
+
59
+ return hint_host, hint_port
60
+
61
+
62
+ # ── WebSocket background listener ──────────────────────────────────────
63
+
64
+
65
+ def _ws_listener(
66
+ host: str,
67
+ port: int,
68
+ event_queue: queue.Queue,
69
+ stop_event: threading.Event,
70
+ ) -> None:
71
+ """Background thread: connect to WebSocket and push events to the queue."""
72
+ import asyncio
73
+
74
+ import websockets
75
+
76
+ async def _listen() -> None:
77
+ nonlocal host, port
78
+ discovered_host, discovered_port = _discover_ws_endpoint(host, port)
79
+ host, port = discovered_host, discovered_port
80
+
81
+ uri = f"ws://{host}:{port}/ws"
82
+ retry_delay = 1.0
83
+
84
+ while not stop_event.is_set():
85
+ try:
86
+ async with websockets.connect(uri, ping_interval=20) as ws:
87
+ retry_delay = 1.0
88
+ event_queue.put(("ws_connected", None))
89
+ # Consume the welcome message
90
+ try:
91
+ welcome = await asyncio.wait_for(ws.recv(), timeout=2)
92
+ event_queue.put(("ws_welcome", welcome))
93
+ except asyncio.TimeoutError:
94
+ pass
95
+
96
+ while not stop_event.is_set():
97
+ try:
98
+ raw = await asyncio.wait_for(ws.recv(), timeout=1)
99
+ data = json.loads(raw)
100
+ if data.get("type") == "trace.created":
101
+ event_queue.put(("trace", data["trace"]))
102
+ except asyncio.TimeoutError:
103
+ continue
104
+ except websockets.ConnectionClosed:
105
+ event_queue.put(("ws_disconnected", None))
106
+ break
107
+ except asyncio.CancelledError:
108
+ break
109
+ except Exception as exc:
110
+ event_queue.put(("ws_error", f"WebSocket: {exc}"))
111
+ if retry_delay >= 2.0:
112
+ event_queue.put(("ws_retry", f"retrying in {retry_delay:.0f}s"))
113
+
114
+ if not stop_event.is_set():
115
+ await asyncio.sleep(retry_delay)
116
+ retry_delay = min(retry_delay * 2, 30.0)
117
+
118
+ asyncio.run(_listen())
119
+
120
+
121
+ # ── Stats computation ──────────────────────────────────────────────────
122
+
123
+
124
+ def _compute_stats(traces: list[Any]) -> dict[str, Any]:
125
+ total = len(traces)
126
+ completed = sum(1 for t in traces if getattr(t, "status", "") == "success")
127
+ errors = sum(1 for t in traces if getattr(t, "status", "") in ("failed", "warning"))
128
+ latencies = [float(t.latency) for t in traces if hasattr(t, "latency") and t.latency is not None]
129
+ avg_latency = sum(latencies) / len(latencies) if latencies else 0.0
130
+ sorted_lats = sorted(latencies)
131
+ p95 = sorted_lats[int(len(sorted_lats) * 0.95)] if sorted_lats else 0.0
132
+ total_tokens = sum(int(t.token_count) for t in traces if hasattr(t, "token_count") and t.token_count is not None)
133
+
134
+ return {
135
+ "total": total,
136
+ "completed": completed,
137
+ "errors": errors,
138
+ "avg_latency": avg_latency,
139
+ "p95_latency": p95,
140
+ "total_tokens": total_tokens,
141
+ }
142
+
143
+
144
+ # ── Dashboard rendering ────────────────────────────────────────────────
145
+
146
+
147
+ def _render_dashboard(
148
+ stats: dict[str, Any],
149
+ traces: list[Any],
150
+ refresh: float,
151
+ seen_count: int,
152
+ ws_status: str,
153
+ ws_detail: str,
154
+ polling: bool,
155
+ ) -> Panel:
156
+ status_line = message("Monitor active", MascotState.LOADING)
157
+
158
+ stats_table = Table.grid(padding=(0, 3))
159
+ stats_table.add_column(style="bright_black", width=20)
160
+ stats_table.add_column(style="white")
161
+
162
+ stats_table.add_row("Total Traces", str(stats["total"]))
163
+ stats_table.add_row("Completed", f"[green]{stats['completed']}[/green]")
164
+ stats_table.add_row("Errors", f"[red]{stats['errors']}[/red]")
165
+ stats_table.add_row("Avg Latency", f"{stats['avg_latency']:.0f} ms")
166
+ stats_table.add_row("P95 Latency", f"[yellow]{stats['p95_latency']:.0f} ms[/yellow]")
167
+ stats_table.add_row("Total Tokens", f"{stats['total_tokens']:,}")
168
+
169
+ if ws_status:
170
+ stats_table.add_row("WebSocket", ws_status)
171
+ if ws_detail:
172
+ stats_table.add_row("", f"[bright_black]{ws_detail}[/bright_black]")
173
+
174
+ traces_table = Table(box=None, padding=(0, 2), header_style="dim")
175
+ traces_table.add_column("Time", width=8)
176
+ traces_table.add_column("Status", width=8, justify="center")
177
+ traces_table.add_column("Model", width=16, no_wrap=True)
178
+ traces_table.add_column("Latency", justify="right", width=10)
179
+ traces_table.add_column("Tokens", justify="right", width=8)
180
+ traces_table.add_column("Steps", justify="right", width=5)
181
+ traces_table.add_column("Prompt", width=36)
182
+
183
+ for t in traces:
184
+ ts = t.created_at.strftime("%H:%M:%S") if hasattr(t.created_at, "strftime") else str(t.created_at)[11:19]
185
+ lat = float(t.latency) if hasattr(t, "latency") and t.latency is not None else 0.0
186
+ traces_table.add_row(
187
+ ts,
188
+ f"[{status_style(t.status)}]{t.status.upper()}[/]",
189
+ str(getattr(t, "model_name", "?") or "?")[:16],
190
+ f"[{latency_style(lat)}]{lat:.0f}ms[/]",
191
+ str(getattr(t, "token_count", 0) or 0),
192
+ str(len(getattr(t, "steps", []) or [])),
193
+ str(getattr(t, "prompt", "") or "")[:36],
194
+ )
195
+
196
+ stats_panel = Panel(
197
+ stats_table,
198
+ title="\U0001f4ca Overview",
199
+ border_style="bright_black",
200
+ padding=(1, 2),
201
+ )
202
+ traces_panel = Panel(
203
+ traces_table,
204
+ title=f"\U0001f4cb Recent Traces ({seen_count} unique)",
205
+ border_style="bright_black",
206
+ padding=(1, 2),
207
+ )
208
+
209
+ body = Table.grid(padding=(0, 1))
210
+ body.add_column()
211
+ body.add_row(status_line)
212
+ body.add_row(Text(""))
213
+ body.add_row(stats_panel)
214
+ body.add_row(Text(""))
215
+ body.add_row(traces_panel)
216
+
217
+ subtitle_parts = [f"Ctrl+C to stop"]
218
+ if polling:
219
+ subtitle_parts.append(f"polling every {refresh}s")
220
+ else:
221
+ subtitle_parts.append("live via WebSocket")
222
+ subtitle = " \u2014 ".join(subtitle_parts)
223
+
224
+ return Panel(
225
+ body,
226
+ title="TraceLLM Monitor",
227
+ subtitle=subtitle,
228
+ border_style="bright_black",
229
+ padding=(1, 2),
230
+ )
231
+
232
+
233
+ # ── Main entry point ───────────────────────────────────────────────────
234
+
235
+
236
+ def run_monitor(
237
+ refresh: float = 2.0,
238
+ limit: int = 10,
239
+ ws_host: str = _WS_DEFAULT_HOST,
240
+ ws_port: int = _WS_DEFAULT_PORT,
241
+ ) -> None:
242
+ """Run the live monitor dashboard.
243
+
244
+ Connects to the TraceLLM backend WebSocket for real-time trace events.
245
+ Falls back to polling MongoDB when the server is unavailable.
246
+ Reconnects automatically on disconnect.
247
+
248
+ When WebSocket is connected, polling is suspended and only live events
249
+ drive the display for minimal DB overhead.
250
+ """
251
+ from tracellm.db import fetch_recent_traces
252
+
253
+ seen: set[str] = set()
254
+ event_queue: queue.Queue = queue.Queue()
255
+ stop_event = threading.Event()
256
+ ws_status = ""
257
+ ws_detail = ""
258
+ ws_connected = False
259
+ has_ever_connected = False
260
+ needs_initial_fetch = True
261
+
262
+ console.print()
263
+ console.print(message("Monitor starting...", MascotState.LOADING))
264
+ console.print()
265
+
266
+ # Start WebSocket listener in background thread
267
+ ws_thread = threading.Thread(
268
+ target=_ws_listener,
269
+ args=(ws_host, ws_port, event_queue, stop_event),
270
+ daemon=True,
271
+ )
272
+ ws_thread.start()
273
+
274
+ # Local cache of traces seen via WebSocket events
275
+ ws_traces: list[Any] = []
276
+ db_traces: list[Any] = []
277
+
278
+ with Live(console=console, refresh_per_second=4, screen=True) as live:
279
+ try:
280
+ while True:
281
+ # Drain queued WebSocket events
282
+ while not event_queue.empty():
283
+ try:
284
+ evt_type, evt_data = event_queue.get_nowait()
285
+ if evt_type == "ws_connected":
286
+ ws_status = "[green]\u25cf Connected[/green]"
287
+ ws_detail = ""
288
+ ws_connected = True
289
+ has_ever_connected = True
290
+ elif evt_type == "ws_welcome":
291
+ pass
292
+ elif evt_type == "ws_disconnected":
293
+ ws_status = "[yellow]\u25cf Disconnected[/yellow]"
294
+ ws_detail = "reconnecting..."
295
+ ws_connected = False
296
+ elif evt_type == "ws_error":
297
+ if not has_ever_connected:
298
+ ws_status = "[yellow]\u25cf Unavailable[/yellow]"
299
+ ws_detail = "polling MongoDB"
300
+ else:
301
+ ws_status = "[yellow]\u25cf Error[/yellow]"
302
+ ws_detail = str(evt_data)[:40]
303
+ ws_connected = False
304
+ elif evt_type == "ws_retry":
305
+ ws_status = "[yellow]\u25cf Reconnecting[/yellow]"
306
+ ws_detail = str(evt_data)
307
+ ws_connected = False
308
+ elif evt_type == "trace":
309
+ # Build a lightweight object so _compute_stats can read it
310
+ t = _make_trace_obj(evt_data)
311
+ if t.trace_id not in seen:
312
+ seen.add(t.trace_id)
313
+ ws_traces.insert(0, t)
314
+ ws_traces = ws_traces[:limit]
315
+ except queue.Empty:
316
+ break
317
+
318
+ # Decide whether to poll MongoDB or use live data
319
+ if needs_initial_fetch or not ws_connected:
320
+ try:
321
+ db_traces = fetch_recent_traces(limit=limit)
322
+ for t in db_traces:
323
+ seen.add(t.trace_id)
324
+ needs_initial_fetch = False
325
+ except Exception as exc:
326
+ live.update(Panel(
327
+ f"[yellow]DB poll error: {exc}[/yellow]\n"
328
+ f"[bright_black]will retry in {refresh}s[/bright_black]",
329
+ title="Monitor",
330
+ border_style="bright_black",
331
+ ))
332
+ time.sleep(refresh)
333
+ continue
334
+
335
+ # Use WS traces when connected, DB traces otherwise
336
+ display_traces = ws_traces if (ws_connected and has_ever_connected and ws_traces) else db_traces
337
+ polling = not ws_connected or not has_ever_connected
338
+
339
+ try:
340
+ stats = _compute_stats(display_traces)
341
+ live.update(_render_dashboard(
342
+ stats, display_traces, refresh, len(seen),
343
+ ws_status, ws_detail, polling,
344
+ ))
345
+ except Exception as exc:
346
+ live.update(Panel(
347
+ f"[yellow]Render error: {exc}[/yellow]",
348
+ title="Monitor",
349
+ border_style="bright_black",
350
+ ))
351
+
352
+ # Sleep in smaller increments so we can respond to Ctrl+C quickly
353
+ for _ in range(int(refresh * 4)):
354
+ if stop_event.is_set():
355
+ break
356
+ time.sleep(0.25)
357
+
358
+ except KeyboardInterrupt:
359
+ stop_event.set()
360
+ raise
361
+
362
+ finally:
363
+ stop_event.set()
364
+
365
+
366
+ def _make_trace_obj(data: dict[str, Any]) -> Any:
367
+ """Convert a trace dict from WebSocket into a simple object compatible with _compute_stats."""
368
+
369
+ class _TraceObj:
370
+ def __init__(self, d: dict[str, Any]) -> None:
371
+ self.trace_id = d.get("trace_id", "")
372
+ self.status = d.get("status", "success")
373
+ self.latency = d.get("latency", 0.0)
374
+ self.token_count = d.get("token_count", 0)
375
+ self.model_name = d.get("model_name", "?")
376
+ self.prompt = d.get("prompt", "")
377
+ self.created_at = d.get("created_at", "")
378
+ steps = d.get("steps", []) or []
379
+ self.steps = steps if isinstance(steps, list) else []
380
+
381
+ return _TraceObj(data)
tracellm/palette.py ADDED
@@ -0,0 +1,186 @@
1
+ """Interactive command palette for TraceLLM CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Any
7
+
8
+ import typer
9
+ from rich.align import Align
10
+ from rich.live import Live
11
+ from rich.panel import Panel
12
+ from rich.table import Table
13
+ from rich.text import Text
14
+
15
+ from tracellm.mascot import render, MascotState
16
+
17
+ _OPTIONS: list[tuple[str, str, str]] = [
18
+ ("\U0001f996 Trace Request", "trace", "Run a traced prompt simulation"),
19
+ ("\u21bb Replay Trace", "replay", "Replay a saved trace from MongoDB"),
20
+ ("\u21e7 Export Traces", "export", "Export traces to JSON or CSV"),
21
+ ("\u25cb Monitor Live", "monitor", "Watch incoming traces in realtime"),
22
+ ("\u25b6 Start Stack", "start", "Start backend + dashboard"),
23
+ ("\u2699 Create Project", "create-project", "Create a project and API key"),
24
+ ]
25
+
26
+ _IS_WINDOWS = sys.platform == "win32"
27
+
28
+
29
+ def _get_key() -> str:
30
+ """Read a single keypress from the terminal (cross-platform)."""
31
+ if _IS_WINDOWS:
32
+ return _get_key_windows()
33
+ return _get_key_unix()
34
+
35
+
36
+ def _get_key_unix() -> str:
37
+ """Unix key reader using termios/tty."""
38
+ import termios
39
+ import tty
40
+ fd = sys.stdin.fileno()
41
+ old = termios.tcgetattr(fd)
42
+ try:
43
+ tty.setraw(fd)
44
+ ch = sys.stdin.read(1)
45
+ if ch == "\x1b":
46
+ seq = sys.stdin.read(2)
47
+ return {"[A": "up", "[B": "down", "[C": "right", "[D": "left"}.get(seq, "esc")
48
+ if ch == "\r":
49
+ return "enter"
50
+ if ch == "\x03":
51
+ return "ctrl_c"
52
+ return ch
53
+ finally:
54
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
55
+
56
+
57
+ def _get_key_windows() -> str:
58
+ """Windows key reader using msvcrt."""
59
+ import msvcrt
60
+ ch = msvcrt.getch()
61
+ if ch == b"\xe0":
62
+ seq = msvcrt.getch()
63
+ return {b"H": "up", b"P": "down", b"M": "right", b"K": "left"}.get(seq, "esc")
64
+ if ch in (b"\r", b"\n"):
65
+ return "enter"
66
+ if ch == b"\x03":
67
+ return "ctrl_c"
68
+ if ch == b"q":
69
+ return "q"
70
+ return ch.decode("utf-8", errors="replace")
71
+
72
+
73
+ def _render_palette(options: list[tuple[str, str, str]], selected: int) -> Panel:
74
+ """Render the interactive palette as a Rich Panel."""
75
+ title = Text("TraceLLM Command Palette", style="bold white")
76
+ subtitle = Text("Use arrow keys to navigate, Enter to select, q to quit", style="dim")
77
+
78
+ table = Table.grid(padding=(0, 2))
79
+ table.add_column(style="bright_black", width=2)
80
+ table.add_column(style="white", width=20)
81
+ table.add_column(style="dim")
82
+
83
+ for i, (label, _, desc) in enumerate(options):
84
+ indicator = "\u25b6" if i == selected else " "
85
+ style = "bold cyan" if i == selected else "white"
86
+ table.add_row(indicator, f"[{style}]{label}[/]", f"[bright_black]{desc}[/]")
87
+
88
+ body = Table.grid(padding=(0, 1))
89
+ body.add_column()
90
+ body.add_row(Align.center(title))
91
+ body.add_row(Align.center(subtitle))
92
+ body.add_row(Text(""))
93
+ body.add_row(Align.center(table))
94
+ body.add_row(Text(""))
95
+ body.add_row(Align.center(render(MascotState.IDLE)))
96
+
97
+ return Panel(body, border_style="bright_black", padding=(1, 3))
98
+
99
+
100
+ def _prompt_for_trace(console: Any) -> str | None:
101
+ """Prompt user for a trace prompt. Returns the prompt or None if empty."""
102
+ console.print()
103
+ console.print("[bold]Enter prompt:[/bold]")
104
+ try:
105
+ prompt = input().strip()
106
+ return prompt if prompt else None
107
+ except (EOFError, KeyboardInterrupt):
108
+ return None
109
+
110
+
111
+ def run_palette(app: typer.Typer) -> None:
112
+ """Show interactive command palette and let the user pick a command."""
113
+ from tracellm.utils import console
114
+
115
+ while True:
116
+ selected = 0
117
+ inline_input = False
118
+ restart = False
119
+
120
+ try:
121
+ with Live(console=console, refresh_per_second=20, screen=True) as live:
122
+ while True:
123
+ live.update(_render_palette(_OPTIONS, selected))
124
+ key = _get_key()
125
+
126
+ if key == "up":
127
+ selected = (selected - 1) % len(_OPTIONS)
128
+ elif key == "down":
129
+ selected = (selected + 1) % len(_OPTIONS)
130
+ elif key == "enter":
131
+ cmd = _OPTIONS[selected][1]
132
+ live.stop()
133
+ if cmd == "trace":
134
+ prompt = _prompt_for_trace(console)
135
+ if prompt:
136
+ app(args=["trace", prompt])
137
+ return
138
+ console.print("[yellow]Prompt cannot be empty. Returning to menu...[/yellow]")
139
+ restart = True
140
+ else:
141
+ app(args=[cmd])
142
+ return
143
+ break
144
+ elif key in ("q", "ctrl_c", "esc"):
145
+ live.stop()
146
+ return
147
+ elif key == "fallback":
148
+ inline_input = True
149
+ break
150
+ except Exception:
151
+ inline_input = True
152
+
153
+ if inline_input:
154
+ _run_fallback(app)
155
+ return
156
+
157
+ if not restart:
158
+ break
159
+
160
+
161
+ def _run_fallback(app: typer.Typer) -> None:
162
+ """Fallback numbered menu when raw terminal input is unavailable."""
163
+ from tracellm.utils import console
164
+
165
+ console.print()
166
+ console.print(Text("TraceLLM Command Palette", style="bold white"))
167
+ console.print()
168
+ for i, (label, _, desc) in enumerate(_OPTIONS, 1):
169
+ console.print(f" [bright_black]{i}.[/] {label} [dim]{desc}[/dim]")
170
+ console.print(f" [bright_black]0.[/] [dim]Quit[/dim]")
171
+ console.print()
172
+
173
+ try:
174
+ choice = input(" Select [0-6]: ").strip()
175
+ if choice.isdigit():
176
+ idx = int(choice) - 1
177
+ if 0 <= idx < len(_OPTIONS):
178
+ cmd = _OPTIONS[idx][1]
179
+ if cmd == "trace":
180
+ prompt = input(" Enter prompt: ").strip()
181
+ if prompt:
182
+ app(args=["trace", prompt])
183
+ else:
184
+ app(args=[cmd])
185
+ except (EOFError, KeyboardInterrupt):
186
+ pass
tracellm/replay.py ADDED
@@ -0,0 +1,80 @@
1
+ import time
2
+ from typing import Any
3
+
4
+ from rich.live import Live
5
+ from rich.panel import Panel
6
+ from rich.rule import Rule
7
+
8
+ from tracellm.db import fetch_trace
9
+ from tracellm.mascot import MascotState, header, message
10
+ from tracellm.tree_renderer import render_execution_panel
11
+ from tracellm.utils import (
12
+ console,
13
+ render_trace_report,
14
+ status_style,
15
+ )
16
+
17
+
18
+ def _replay_detail_panel(trace_data: dict[str, Any], step: dict[str, Any], index: int, total: int) -> Panel:
19
+ duration = float(step.get("duration", 0.0))
20
+ success = bool(step.get("success", True))
21
+ tool_name = step.get("tool_name", "unknown")
22
+ inp = str(step.get("input", {}))
23
+ out = str(step.get("output", {}))
24
+
25
+ lines = [
26
+ f"[bright_black]step[/bright_black] {index}/{total}",
27
+ f"[bright_black]tool[/bright_black] [white]{tool_name}[/white]",
28
+ f"[bright_black]duration[/bright_black] {duration:.0f} ms",
29
+ f"[bright_black]status[/bright_black] {'[green]OK[/]' if success else '[red]RETRY[/]'}",
30
+ ]
31
+ if inp:
32
+ clipped = inp[:200] + ("..." if len(inp) > 200 else "")
33
+ lines.append(f"[bright_black]input[/bright_black] [dim]{clipped}[/dim]")
34
+ if out:
35
+ clipped = out[:200] + ("..." if len(out) > 200 else "")
36
+ lines.append(f"[bright_black]output[/bright_black] [dim]{clipped}[/dim]")
37
+
38
+ body = "\n".join(lines)
39
+ return Panel.fit(body, title="Step Detail", border_style="bright_black", padding=(1, 2))
40
+
41
+
42
+ def replay_trace(trace_id: str, speed: float = 1.0, show_response: bool = False) -> None:
43
+ trace = fetch_trace(trace_id)
44
+ trace_data = trace.model_dump(mode="json")
45
+ steps = trace_data.get("steps", [])
46
+
47
+ if not steps:
48
+ console.print(f"[yellow]Trace {trace_id} has no steps to replay.[/yellow]")
49
+ return
50
+
51
+ console.print()
52
+ console.print(header("Replaying execution timeline...", MascotState.LOADING))
53
+ console.print()
54
+ meta_lines = [
55
+ f"[bright_black]trace_id[/bright_black] {trace_data['trace_id']}",
56
+ f"[bright_black]status[/bright_black] [{status_style(str(trace_data['status']))}]{str(trace_data['status']).upper()}[/]",
57
+ f"[bright_black]latency[/bright_black] {float(trace_data['latency']):.2f} ms",
58
+ f"[bright_black]retries[/bright_black] {trace_data['retry_count']}",
59
+ f"[bright_black]steps[/bright_black] {len(steps)}",
60
+ ]
61
+ console.print(Panel.fit("\n".join(meta_lines), title="Replay", border_style="bright_black", padding=(1, 2)))
62
+
63
+ with Live(console=console, refresh_per_second=12, transient=False) as live:
64
+ for index, step in enumerate(steps, start=1):
65
+ tree_panel = render_execution_panel(steps, active_index=index)
66
+ detail = _replay_detail_panel(trace_data, step, index, len(steps))
67
+ body = f"{tree_panel}\n\n{detail}"
68
+ live.update(Panel(body, title=f"Replaying step {index}/{len(steps)}", border_style="bright_black", padding=(1, 2)))
69
+ delay = float(step.get("duration", 0.0)) / 1000 / max(speed, 0.1)
70
+ time.sleep(max(0.08, min(0.55, delay)))
71
+
72
+ status = str(trace_data.get("status", "success")).lower()
73
+ if status == "success":
74
+ console.print(message("Replay complete", MascotState.SUCCESS))
75
+ elif status in ("warning", "failed"):
76
+ console.print(message("Warning: trace had issues", MascotState.WARNING))
77
+ console.print()
78
+ render_trace_report(trace_data)
79
+ if show_response:
80
+ console.print(Panel(str(trace_data.get("response") or ""), title="Full Response", border_style="bright_black", padding=(1, 2)))