Lines {step.first_line}–{step.last_line}
+diff --git a/README-cn.md b/README-cn.md index fc45408..09f6273 100644 --- a/README-cn.md +++ b/README-cn.md @@ -50,8 +50,20 @@ uv run yada --task-file issue.md --workspace /workspace --yes Yada 会打印每轮 DeepSeek 调用和工具执行,最后报告任务是否通过验证门槛。默认 Trace 保存在目标仓库的 `.yada/runs/` 目录下。 -仓库测试可以执行任意代码。Yada 提供 Guardrail,但不是完整的操作系统沙箱; -处理陌生项目时请使用一次性 VM 或容器。 +陌生项目可能包含并执行任意代码。Yada 虽然提供了 Guardrail,但并不是完整的 +操作系统沙箱,运行这类项目仍可能危及系统安全。处理陌生项目时,请使用一次性 +VM 或容器。 + +## 检查每一个步骤 + +将任意 JSONL Trace 转换成完全离线、自包含的可视化页面,无需服务器、CDN 或 +额外运行时依赖: + +```bash +uv run yada-trace TRACE.jsonl --html +``` + +[](docs/assets/yada-trace-viewer.jpg) ## 更多文档 diff --git a/README.md b/README.md index a51e15f..b2a71e9 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,21 @@ Yada prints each DeepSeek turn and tool execution, then reports whether the task passed its verification gate. Traces are written under the target repository's `.yada/runs/` directory by default. -Repository tests can execute arbitrary code. Yada provides guardrails, not a -complete OS sandbox; use a disposable VM or container for unfamiliar projects. +Unfamiliar projects may contain and execute arbitrary code. Yada provides +guardrails, but it is not a complete operating-system sandbox, so running such +projects can still put your system at risk. Use a disposable VM or container +when working with unfamiliar projects. + +## Inspect every step + +Turn any JSONL trace into a self-contained offline viewer—no server, CDN, or +additional runtime dependency required: + +```bash +uv run yada-trace TRACE.jsonl --html +``` + +[](docs/assets/yada-trace-viewer.jpg) ## Learn more diff --git a/docs/assets/yada-trace-viewer.jpg b/docs/assets/yada-trace-viewer.jpg new file mode 100644 index 0000000..0851d92 Binary files /dev/null and b/docs/assets/yada-trace-viewer.jpg differ diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2e32401..7a29447 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -301,7 +301,7 @@ errors or non-verdict outcomes such as skipped grading. ## `yada-trace` ```text -yada-trace TRACE.jsonl [--step N | --verbose | --events] +yada-trace TRACE.jsonl [--step N | --verbose | --events | --html [PATH]] ``` | Option | Meaning | @@ -309,6 +309,7 @@ yada-trace TRACE.jsonl [--step N | --verbose | --events] | `--step N` | Expand one complete request → response → tools step. | | `--verbose` | Expand payloads inside every grouped step. | | `--events` | Show the flat event timeline with physical JSONL line numbers. | +| `--html [PATH]` | Write one self-contained offline HTML viewer. Without `PATH`, write it beside the trace and open it. | The default view groups records by agent step and shows physical JSONL line references for the model request/response and each tool call/result pair: @@ -317,8 +318,19 @@ references for the model request/response and each tool call/result pair: uv run yada-trace TRACE.jsonl uv run yada-trace TRACE.jsonl --step 8 uv run yada-trace TRACE.jsonl --events +uv run yada-trace TRACE.jsonl --html +uv run yada-trace TRACE.jsonl --html trace.html ``` +Without an output path, `--html` writes `TRACE.html` beside `TRACE.jsonl` and +opens it in the default browser. Pass a path to export the viewer without opening +it. The HTML viewer groups the same validated records by step and adds local step +filtering, failure/file-change filters, collapsed large payloads, and the final +diff. It contains inline CSS and JavaScript only: opening the file +does not start a server or request external resources. The generated file can +contain prompts, reasoning, source, patches, and command output from the source +trace, so handle it with the same care as the JSONL. + `yada-trace` returns `0` after rendering and `2` for an unreadable or invalid trace. See the [debugging guide](dev/debugging.md) for event semantics, trace levels, `jq` recipes, and reproduction workflows. diff --git a/docs/dev/debugging.md b/docs/dev/debugging.md index 60dd920..7542243 100644 --- a/docs/dev/debugging.md +++ b/docs/dev/debugging.md @@ -109,6 +109,23 @@ investigation: uv run yada-trace TRACE.jsonl --events ``` +For longer runs, generate a portable semantic view and open it directly in a +browser: + +```bash +uv run yada-trace TRACE.jsonl --html +``` + +This writes `TRACE.html` beside the JSONL and opens it in the default browser. +Pass an explicit path after `--html` to export without opening it. The single +HTML file works offline and groups requests, reasoning, responses, +plans, tool calls/results, failures, and the final diff by step. Step filtering +runs locally in the browser. Large prompts, patches, and command output +are collapsed by default. The viewer preserves redaction from the JSONL and +cannot recover omitted or redacted fields. + +[](../assets/yada-trace-viewer.jpg) + Then inspect exact records using the line references: ```bash diff --git a/src/yada/traces/__init__.py b/src/yada/traces/__init__.py index 0889a2f..0ce0bde 100644 --- a/src/yada/traces/__init__.py +++ b/src/yada/traces/__init__.py @@ -1,5 +1,6 @@ """Trace persistence and human-readable run diagnostics.""" +from yada.traces.html import render_trace_html, write_trace_html from yada.traces.jsonl import TRACE_LEVELS, TRACE_SCHEMA_VERSION, TraceWriter from yada.traces.report import ( LocatedTraceEvent, @@ -27,5 +28,7 @@ "read_located_trace", "read_trace", "reconstruct_model_request", + "render_trace_html", "render_trace_report", + "write_trace_html", ] diff --git a/src/yada/traces/cli.py b/src/yada/traces/cli.py index d1d1a9b..88de426 100644 --- a/src/yada/traces/cli.py +++ b/src/yada/traces/cli.py @@ -4,10 +4,14 @@ import argparse import sys +import webbrowser from pathlib import Path +from yada.traces.html import write_trace_html from yada.traces.report import TraceFormatError, render_trace_report +_AUTO_HTML = object() + def build_parser() -> argparse.ArgumentParser: """Create the parser for the lightweight ``yada-trace`` command.""" @@ -32,6 +36,17 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="Show the legacy flat event timeline with physical line numbers.", ) + parser.add_argument( + "--html", + nargs="?", + const=_AUTO_HTML, + type=Path, + metavar="PATH", + help=( + "Write a self-contained offline HTML viewer; omit PATH to write " + "beside TRACE and open it." + ), + ) return parser @@ -40,8 +55,30 @@ def run_cli(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) try: + trace_path = args.trace.expanduser().resolve() + if args.html is not None: + if args.step is not None or args.verbose or args.events: + raise TraceFormatError( + "--html cannot be combined with --step, --verbose, or --events" + ) + auto_open = args.html is _AUTO_HTML + output_path = ( + trace_path.with_suffix(".html") + if auto_open + else args.html.expanduser().resolve() + ) + write_trace_html(trace_path, output_path) + if auto_open and _open_html(output_path): + print(f"Wrote and opened offline trace viewer: {output_path}") + else: + print(f"Wrote offline trace viewer: {output_path}") + if auto_open: + print( + "Could not open it automatically; open the file in a browser." + ) + return 0 report = render_trace_report( - args.trace.expanduser().resolve(), + trace_path, step=args.step, verbose=args.verbose, events=args.events, @@ -63,6 +100,13 @@ def _positive_step(value: str) -> int: return step +def _open_html(path: Path) -> bool: + try: + return webbrowser.open(path.as_uri(), new=2) + except (OSError, webbrowser.Error): + return False + + def main() -> None: """Console-script entry point.""" diff --git a/src/yada/traces/html.py b/src/yada/traces/html.py new file mode 100644 index 0000000..5d2babb --- /dev/null +++ b/src/yada/traces/html.py @@ -0,0 +1,565 @@ +"""Self-contained offline HTML rendering for Yada traces.""" + +from __future__ import annotations + +import html +import json +from pathlib import Path +from typing import Any + +from yada.traces.report import ( + LocatedTraceEvent, + TraceFormatError, + TraceRun, + TraceStep, + TraceToolExecution, + build_trace_run, + read_located_trace, +) + +_EDIT_TOOLS = {"apply_patch", "replace_text"} +_COLLAPSE_CHARS = 4_000 +_TITLE_CHARS = 120 + + +def render_trace_html(path: Path) -> str: + """Render one validated JSONL trace as a portable offline HTML document.""" + + run = build_trace_run(read_located_trace(path)) + start = run.run_start.data if run.run_start else {} + run_id = _run_id(run) + panels = "".join( + _render_step(step, selected=index == 0) for index, step in enumerate(run.steps) + ) + navigation = "".join( + _render_step_button(step, selected=index == 0) + for index, step in enumerate(run.steps) + ) + if not run.steps: + panels = '
No agent steps were recorded.
' + + content = ( + _render_header(path, run) + + _render_run_details(run) + + 'Interrupted trace: missing run_end. ' + "The final state may be unavailable.
" + ) + return f""" +Yada trace viewer · offline
+{_escape(path)} · run {_escape(_run_id(run))}
+Lines {step.first_line}–{step.last_line}
+{_escape(unique)}
' + + +def _step_flags(step: TraceStep) -> tuple[str, ...]: + flags = [] + if step.model_response and step.model_response.name == "model_error": + flags.append("model-error") + if any(event.name == "protocol_violation" for event in step.protocol_events): + flags.append("protocol-violation") + if any( + execution.call and execution.call.data.get("rejected") is True + for execution in step.tool_executions + ): + flags.append("rejected-tool") + if any(_nonzero_exit(execution) for execution in step.tool_executions): + flags.append("nonzero-exit") + if any(_changes_files(execution) for execution in step.tool_executions): + flags.append("file-change") + if _step_interrupted(step): + flags.append("incomplete") + return tuple(flags) + + +def _step_interrupted(step: TraceStep) -> bool: + return bool( + (step.model_request and step.model_response is None) + or any( + execution.call is None or execution.result is None + for execution in step.tool_executions + ) + ) + + +def _nonzero_exit(execution: TraceToolExecution) -> bool: + result = _tool_result(execution) + exit_code = result.get("exit_code") + return isinstance(exit_code, int) and exit_code != 0 + + +def _changes_files(execution: TraceToolExecution) -> bool: + result = _tool_result(execution) + if result.get("ok") is not True: + return False + changed = result.get("changed_files") + if isinstance(changed, list) and changed: + return True + source = execution.call or execution.result + return bool(source and source.data.get("tool") in _EDIT_TOOLS) + + +def _tool_result(execution: TraceToolExecution) -> dict[str, Any]: + if execution.result is None: + return {} + result = execution.result.data.get("result") + return result if isinstance(result, dict) else {} + + +def _response(response: LocatedTraceEvent | None) -> dict[str, Any] | None: + located = _located(response) + if located is None: + return located + located["data"] = { + key: value + for key, value in located["data"].items() + if key != "message_field_presence" + } + return located + + +def _located(event: LocatedTraceEvent | None) -> dict[str, Any] | None: + if event is None: + return None + return { + "jsonl_line": event.line_number, + "event": event.name, + "data": event.data, + } + + +def _details( + title: str, + value: Any, + *, + open_by_default: bool, + searchable: bool = False, + class_name: str = "", +) -> str: + rendered = _json_text(value) + open_attribute = ( + " open" if open_by_default and len(rendered) <= _COLLAPSE_CHARS else "" + ) + search_attribute = " data-searchable" if searchable else "" + classes = f"trace-section {class_name}".rstrip() + return ( + f'{_escape(rendered)}