From 273d1443812889e8cc6d6e3beff3ac0708a4c5c5 Mon Sep 17 00:00:00 2001 From: Jeff Smith Date: Wed, 8 Apr 2026 14:57:37 -0600 Subject: [PATCH] M2.2: marchwarden replay CLI command (#9) Adds `marchwarden replay ` to pretty-print a prior research run from its JSONL trace file. Resolves the trace under ~/.marchwarden/traces/ by default; --trace-dir overrides for tests and custom locations. Renders each step as a row with action, decision, extra fields, and content_hash. Friendly errors for unknown trace_id and malformed JSON lines. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/main.py | 80 +++++++++++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 58 +++++++++++++++++++++++++++++++++- 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/cli/main.py b/cli/main.py index 777ec2e..4802f11 100644 --- a/cli/main.py +++ b/cli/main.py @@ -6,7 +6,9 @@ ResearchResult contracts to the terminal. import asyncio import json +import os import sys +from pathlib import Path from typing import Optional import click @@ -20,6 +22,9 @@ from rich.text import Text from researchers.web.models import ResearchResult +DEFAULT_TRACE_DIR = "~/.marchwarden/traces" + + # --------------------------------------------------------------------------- # MCP client # --------------------------------------------------------------------------- @@ -197,5 +202,80 @@ def ask( render_result(result, console) +def _resolve_trace_path(trace_id: str, trace_dir: Optional[str]) -> Path: + """Resolve the JSONL path for a trace_id.""" + base = Path(os.path.expanduser(trace_dir or DEFAULT_TRACE_DIR)) + return base / f"{trace_id}.jsonl" + + +def render_trace(entries: list[dict], trace_id: str, console: Console) -> None: + """Pretty-print a list of trace entries.""" + console.print( + Panel( + f"[bold]trace_id:[/bold] {trace_id}\n[bold]steps:[/bold] {len(entries)}", + title="[cyan]Replay[/cyan]", + border_style="cyan", + ) + ) + + if not entries: + console.print("[dim]Trace file is empty.[/dim]") + return + + table = Table(show_lines=True, expand=True) + table.add_column("#", style="dim", width=4) + table.add_column("Action", style="magenta") + table.add_column("Decision", overflow="fold") + table.add_column("Details", overflow="fold") + table.add_column("Hash", style="dim", overflow="fold") + + reserved = {"step", "action", "decision", "timestamp", "content_hash"} + for e in entries: + step = str(e.get("step", "?")) + action = str(e.get("action", "")) + decision = str(e.get("decision", "")) + content_hash = str(e.get("content_hash", "") or "") + extras = {k: v for k, v in e.items() if k not in reserved} + details = "\n".join(f"{k}: {v}" for k, v in extras.items()) + table.add_row(step, action, decision, details, content_hash) + + console.print(table) + + +@cli.command() +@click.argument("trace_id") +@click.option( + "--trace-dir", + default=None, + help=f"Trace directory (default: {DEFAULT_TRACE_DIR}).", +) +def replay(trace_id: str, trace_dir: Optional[str]) -> None: + """Replay a prior research run by TRACE_ID.""" + console = Console() + path = _resolve_trace_path(trace_id, trace_dir) + if not path.exists(): + console.print( + f"[bold red]Error:[/bold red] no trace file found for " + f"trace_id [bold]{trace_id}[/bold] at {path}" + ) + sys.exit(1) + + entries: list[dict] = [] + with open(path, "r", encoding="utf-8") as f: + for lineno, line in enumerate(f, 1): + line = line.strip() + if not line: + continue + try: + entries.append(json.loads(line)) + except json.JSONDecodeError as e: + console.print( + f"[bold red]Error:[/bold red] invalid JSON on line {lineno}: {e}" + ) + sys.exit(1) + + render_trace(entries, trace_id, console) + + if __name__ == "__main__": cli() diff --git a/tests/test_cli.py b/tests/test_cli.py index 189faa8..41c5b79 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,7 +4,7 @@ from unittest.mock import patch from click.testing import CliRunner -from cli.main import cli, render_result +from cli.main import cli, render_result, render_trace from researchers.web.models import ( Citation, ConfidenceFactors, @@ -130,3 +130,59 @@ class TestAskCommand: assert result.exit_code == 1 assert "mcp went sideways" in result.output + + +class TestReplayCommand: + def _write_trace(self, tmp_path, trace_id="trace-xyz"): + path = tmp_path / f"{trace_id}.jsonl" + path.write_text( + '{"step": 1, "action": "search", "decision": "initial query", ' + '"timestamp": "2026-04-08T00:00:00Z", "query": "utah crops"}\n' + '{"step": 2, "action": "fetch_url", "decision": "promising source", ' + '"timestamp": "2026-04-08T00:00:01Z", "url": "https://example.com", ' + '"content_hash": "sha256:deadbeef"}\n' + '{"step": 3, "action": "synthesize", "decision": "have enough", ' + '"timestamp": "2026-04-08T00:00:02Z"}\n' + ) + return path + + def test_replay_renders_trace(self, tmp_path): + runner = CliRunner() + self._write_trace(tmp_path) + result = runner.invoke( + cli, + ["replay", "trace-xyz", "--trace-dir", str(tmp_path)], + ) + assert result.exit_code == 0, result.output + assert "trace-xyz" in result.output + assert "search" in result.output + assert "fetch_url" in result.output + assert "synthesize" in result.output + assert "sha256:deadbeef" in result.output + assert "utah crops" in result.output + + def test_replay_unknown_trace_id(self, tmp_path): + runner = CliRunner() + result = runner.invoke( + cli, + ["replay", "missing-id", "--trace-dir", str(tmp_path)], + ) + assert result.exit_code == 1 + assert "no trace file found" in result.output + + def test_replay_invalid_json(self, tmp_path): + runner = CliRunner() + (tmp_path / "broken.jsonl").write_text("{not json}\n") + result = runner.invoke( + cli, + ["replay", "broken", "--trace-dir", str(tmp_path)], + ) + assert result.exit_code == 1 + assert "invalid JSON" in result.output + + def test_render_trace_empty(self): + console = Console(record=True, width=120) + render_trace([], "empty-trace", console) + out = console.export_text() + assert "empty-trace" in out + assert "empty" in out.lower()