Merge pull request 'M2.2: marchwarden replay CLI command' (#12) from feat/cli-replay into main

Reviewed-on: #12
Reviewed-by: archeious <archeious@unbiasedgeek.com>
This commit is contained in:
archeious 2026-04-08 20:59:12 +00:00
commit bca7294ec8
2 changed files with 137 additions and 1 deletions

View file

@ -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()

View file

@ -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()