"""CLI for visualizing agent run logs.""" from __future__ import annotations import argparse import webbrowser from pathlib import Path from supply_agent.logging.visualize import generate_visualization # supply_agent/logging/cli.py → project root _PROJECT_ROOT = Path(__file__).resolve().parents[2] def _has_run_logs(directory: Path) -> bool: if not directory.is_dir(): return False return any(directory.glob("run_*.jsonl")) or any(directory.glob("run_*.log")) def _resolve_logs_dir(logs_dir: str | Path) -> Path: """ Resolve logs directory. Relative paths prefer the SupplyAgent project root (stable regardless of CWD). Falls back to CWD only when that location actually contains run logs. """ path = Path(logs_dir) if path.is_absolute(): return path.resolve() project_candidate = (_PROJECT_ROOT / path).resolve() cwd_candidate = (Path.cwd() / path).resolve() if _has_run_logs(project_candidate): return project_candidate if _has_run_logs(cwd_candidate): return cwd_candidate if project_candidate.is_dir(): return project_candidate if cwd_candidate.is_dir(): return cwd_candidate return project_candidate def _resolve_run_path(run: str | Path) -> Path: path = Path(run) if path.is_absolute(): return path.resolve() cwd_candidate = (Path.cwd() / path).resolve() if cwd_candidate.exists(): return cwd_candidate project_candidate = (_PROJECT_ROOT / path).resolve() if project_candidate.exists(): return project_candidate # Stem / missing suffix: try under project logs/ logs = _resolve_logs_dir("logs") for suffix in (".jsonl", ".log", ""): candidate = logs / f"{path.name}{suffix}" if suffix else logs / path.name if candidate.exists(): return candidate return project_candidate def _find_latest_run(logs_dir: Path) -> Path: runs = _list_all_runs(logs_dir) if not runs: raise FileNotFoundError(f"No run_*.jsonl / run_*.log found in {logs_dir}") return runs[-1] def _list_all_runs(logs_dir: Path) -> list[Path]: """ List all unique agent runs in logs_dir. Prefers ``.jsonl`` over ``.log`` when both exist for the same stem. Sorted by mtime ascending (oldest first). """ logs_dir = _resolve_logs_dir(logs_dir) if not logs_dir.is_dir(): raise FileNotFoundError(f"Logs directory not found: {logs_dir}") by_stem: dict[str, Path] = {} for path in logs_dir.glob("run_*.jsonl"): by_stem[path.stem] = path for path in logs_dir.glob("run_*.log"): by_stem.setdefault(path.stem, path) return sorted(by_stem.values(), key=lambda p: p.stat().st_mtime) def main(argv: list[str] | None = None) -> int: """ Generate HTML visualizations for agent run logs. Parameters ---------- argv: Optional CLI args. Use ``main()`` or ``main([])`` from Python to visualize all runs under the project ``logs/`` directory (CWD-independent). """ parser = argparse.ArgumentParser( description="将 Agent 运行日志生成为可视化 HTML 页面", ) parser.add_argument( "run", nargs="?", help="日志路径(.jsonl / .log / 不带后缀的 run id);省略则处理 logs 目录下全部运行", ) parser.add_argument( "-o", "--output", help="输出 HTML 路径(仅单文件模式有效;默认与日志同目录同名 .html)", ) parser.add_argument( "--logs-dir", default="logs", help="日志目录(默认项目根下 logs;无参数时批量生成,或配合 --latest)", ) parser.add_argument( "--latest", action="store_true", help="仅可视化 logs 目录中最新一次运行", ) parser.add_argument( "--open", action="store_true", help="生成后在浏览器中打开(批量模式打开最新一份)", ) # 直接 main() / main(None) 视为无参数批量,避免 IDE / 其它入口的 argv 干扰 args = parser.parse_args([] if argv is None else argv) logs_dir = _resolve_logs_dir(args.logs_dir) if args.latest: run_paths = [_find_latest_run(logs_dir)] elif args.run: run_paths = [_resolve_run_path(args.run)] else: try: run_paths = _list_all_runs(logs_dir) except FileNotFoundError as exc: print(exc) return 1 if not run_paths: print(f"未找到可可视化的日志: {logs_dir}/run_*.jsonl|*.log") return 1 if args.output: parser.error("批量生成时不能指定 --output,请省略 -o 或指定单个 run 路径") outputs: list[Path] = [] errors = 0 for run_path in run_paths: try: out = generate_visualization( run_path, args.output if len(run_paths) == 1 else None, ) outputs.append(out) print(f"已生成可视化页面: {out}") except Exception as exc: errors += 1 print(f"跳过 {run_path}: {exc}") if not outputs: return 1 if args.open: webbrowser.open(outputs[-1].resolve().as_uri()) if len(outputs) > 1: print(f"共生成 {len(outputs)} 个页面" + (f",失败 {errors} 个" if errors else "")) return 1 if errors else 0 if __name__ == "__main__": import sys raise SystemExit(main(sys.argv[1:]))