M2.2: marchwarden replay CLI command #12
2 changed files with 137 additions and 1 deletions
80
cli/main.py
80
cli/main.py
|
|
@ -6,7 +6,9 @@ ResearchResult contracts to the terminal.
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
@ -20,6 +22,9 @@ from rich.text import Text
|
||||||
from researchers.web.models import ResearchResult
|
from researchers.web.models import ResearchResult
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_TRACE_DIR = "~/.marchwarden/traces"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# MCP client
|
# MCP client
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -197,5 +202,80 @@ def ask(
|
||||||
render_result(result, console)
|
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__":
|
if __name__ == "__main__":
|
||||||
cli()
|
cli()
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from unittest.mock import patch
|
||||||
|
|
||||||
from click.testing import CliRunner
|
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 (
|
from researchers.web.models import (
|
||||||
Citation,
|
Citation,
|
||||||
ConfidenceFactors,
|
ConfidenceFactors,
|
||||||
|
|
@ -130,3 +130,59 @@ class TestAskCommand:
|
||||||
|
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert "mcp went sideways" in result.output
|
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()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue