diff --git a/.gitignore b/.gitignore index e0e0488ae..f86d82547 100644 --- a/.gitignore +++ b/.gitignore @@ -65,4 +65,7 @@ docs-site/.next docs-site/content # python cache -__pycache__/ \ No newline at end of file +__pycache__/ + +integration-tests/concurrent-runner/output/ +integration-tests/concurrent-runner/task-* \ No newline at end of file diff --git a/integration-tests/concurrent-runner/render-chat-temp.html b/integration-tests/concurrent-runner/render-chat-temp.html new file mode 100644 index 000000000..5f33eaf69 --- /dev/null +++ b/integration-tests/concurrent-runner/render-chat-temp.html @@ -0,0 +1,277 @@ + + + + + + + Qwen Code Chat Export + + + + + + + + + + + + + + + + + +
+
+
+

Qwen Code Export

+
+
+
+ Session Id + - +
+
+ Export Time + - +
+
+
+ +
+
+ + + + + + + diff --git a/integration-tests/concurrent-runner/runner.py b/integration-tests/concurrent-runner/runner.py index 9d3a0613f..32a81e2ad 100644 --- a/integration-tests/concurrent-runner/runner.py +++ b/integration-tests/concurrent-runner/runner.py @@ -9,6 +9,7 @@ the Qwen CLI in parallel with status tracking and output capture. from __future__ import annotations import argparse +import html import asyncio import json import os @@ -93,6 +94,7 @@ class RunRecord: prompt_results: List[PromptResult] = field(default_factory=list) diff_file: Optional[str] = None # Path to git diff output session_log_file: Optional[str] = None # Path to session log (chat recording) + session_html_file: Optional[str] = None # Path to rendered chat HTML session_id: Optional[str] = None # Session ID (UUID from chat recording) def to_dict(self) -> Dict[str, Any]: @@ -111,6 +113,7 @@ class RunRecord: "error_message": self.error_message, "diff_file": self.diff_file, "session_log_file": self.session_log_file, + "session_html_file": self.session_html_file, "session_id": self.session_id, "prompt_results": [ { @@ -142,6 +145,7 @@ class RunRecord: error_message=data.get("error_message"), diff_file=data.get("diff_file"), session_log_file=data.get("session_log_file"), + session_html_file=data.get("session_html_file"), session_id=data.get("session_id"), ) @@ -248,7 +252,7 @@ class GitWorktreeManager: return result.stdout - async def collect_session_log(self, worktree_dir: Path, output_dir: Path) -> Optional[Tuple[Path, str]]: + async def collect_session_log(self, worktree_dir: Path, output_dir: Path) -> Optional[Tuple[Path, str, Path]]: """Collect the session log file from the worktree's chat recording. Session logs are stored at: @@ -257,7 +261,7 @@ class GitWorktreeManager: Where projectId is the sanitized worktree path. Returns: - Tuple of (output_path, session_id) or None if not found. + Tuple of (output_path, session_id, rendered_html_path) or None if not found. """ import re @@ -293,6 +297,8 @@ class GitWorktreeManager: # Read the original file, modify cwd field, and write to output # cwd should be the actual current working dir (where runner is executed) actual_cwd = str(Path.cwd()) + messages = [] + start_time = None async with aiofiles.open(session_log, 'r') as src, aiofiles.open(output_log, 'w') as dst: async for line in src: line = line.strip() @@ -300,6 +306,9 @@ class GitWorktreeManager: try: record = json.loads(line) record['cwd'] = actual_cwd + messages.append(record) + if not start_time and 'time' in record: + start_time = record['time'] await dst.write(json.dumps(record, ensure_ascii=False) + '\n') except json.JSONDecodeError: # If line is not valid JSON, write it as-is @@ -307,7 +316,38 @@ class GitWorktreeManager: self.console.print(f"[dim]Session log copied: {session_log.name}[/dim]") - return output_log, session_id + # Generate rendered HTML + rendered_html_path = chats_output_dir / f"{session_id}.html" + try: + template_path = Path(__file__).parent / "render-chat-temp.html" + if template_path.exists(): + async with aiofiles.open(template_path, 'r') as f: + template_content = await f.read() + + chat_data = { + "sessionId": session_id, + "startTime": start_time or datetime.now().isoformat(), + "messages": messages + } + + # Simple string replacement for injection + # The template has + placeholder = ' to prevent breaking the HTML script tag + json_str = json_str.replace('', '<\\/script>') + injection = f'{placeholder}\n{json_str}\n' + rendered_content = template_content.replace(placeholder, injection) + + async with aiofiles.open(rendered_html_path, 'w') as f: + await f.write(rendered_content) + self.console.print(f"[dim]Rendered chat HTML saved: {rendered_html_path.name}[/dim]") + else: + self.console.print(f"[yellow]Warning: Chat template not found at {template_path}[/yellow]") + except Exception as e: + self.console.print(f"[yellow]Warning: Failed to render chat HTML: {e}[/yellow]") + + return output_log, session_id, rendered_html_path async def _run_command( self, @@ -372,19 +412,165 @@ class StatusTracker: await self._persist() async def _persist(self) -> None: - """Persist current state to JSON file.""" + """Persist current state to JSON file and generate HTML report.""" data = { "updated_at": datetime.now().isoformat(), "runs": [run.to_dict() for run in self._runs.values()], } - # Write atomically + # Write JSON atomically temp_file = self.results_file.with_suffix('.tmp') async with aiofiles.open(temp_file, 'w') as f: await f.write(json.dumps(data, indent=2)) temp_file.replace(self.results_file) + # Generate HTML report + await self._generate_html(data) + + async def _generate_html(self, data: Dict[str, Any]) -> None: + """Generate a beautiful HTML report.""" + html_file = self.results_file.with_name("index.html") + + # Calculate summary + total = len(data["runs"]) + succeeded = sum(1 for r in data["runs"] if r["status"] == "succeeded") + failed = sum(1 for r in data["runs"] if r["status"] == "failed") + running = sum(1 for r in data["runs"] if r["status"] in ["preparing", "running"]) + + # Build rows + rows = [] + for run in sorted(data["runs"], key=lambda x: x.get("started_at") or "", reverse=True): + status = run["status"] + status_class = f"status-{status}" + + # Links + links = [] + + # Output Directory + if run.get("output_dir"): + # Make path absolute for local viewing + abs_output_dir = os.path.abspath(run["output_dir"]) + links.append(f'Outputs') + + # Diff File + if run.get("diff_file"): + abs_diff_file = os.path.abspath(run["diff_file"]) + links.append(f'Diff') + + # Session Log + if run.get("session_html_file"): + abs_session_html = os.path.abspath(run["session_html_file"]) + links.append(f'Chat') + elif run.get("session_log_file"): + abs_session_log = os.path.abspath(run["session_log_file"]) + links.append(f'Chat (Raw)') + + # Worktree + if run.get("worktree_path"): + abs_worktree = os.path.abspath(run["worktree_path"]) + links.append(f'Worktree') + + # Prompt results (stdout/stderr) + prompt_links = [] + for i, p in enumerate(run.get("prompt_results", []), 1): + p_links = [] + if p.get("stdout_file"): + p_links.append(f'out') + if p.get("stderr_file"): + p_links.append(f'err') + + if p_links: + prompt_links.append(f'P{i}: {"|".join(p_links)}') + + links_html = " | ".join(links) + prompts_html = "
".join(prompt_links) + + duration = "N/A" + if run.get("started_at") and run.get("ended_at"): + try: + start = datetime.fromisoformat(run["started_at"]) + end = datetime.fromisoformat(run["ended_at"]) + duration = f"{(end - start).total_seconds():.1f}s" + except: pass + + error_msg = f'
{html.escape(run["error_message"])}
' if run.get("error_message") else "" + + rows.append(f""" + + {run["run_id"]} + {html.escape(run["task_name"])} + {html.escape(run["model"])} + {status} + {duration} + {links_html} + {prompts_html} + {error_msg} + + """) + + html_content = f""" + + + + Qwen Runner Report + + + +

Qwen Runner Execution Report

+
+

Total

{total}
+

Succeeded

{succeeded}
+

Failed

{failed}
+

Running

{running}
+
+ + + + + + + + + + + + + + + {"".join(rows)} + +
IDTaskModelStatusDurationLogs & ArtifactsPromptsError
+
+ Updated at: {data["updated_at"]} +
+ +""" + + async with aiofiles.open(html_file, 'w') as f: + await f.write(html_content) + def get_state(self) -> ExecutionState: """Get current execution state.""" runs = list(self._runs.values()) @@ -752,8 +938,9 @@ async def execute_single_run( try: result = await worktree_manager.collect_session_log(worktree_dir, output_dir) if result: - session_log, session_id = result + session_log, session_id, session_html = result run.session_log_file = str(session_log) + run.session_html_file = str(session_html) run.session_id = session_id console.print(f"[dim]Session log saved: {session_log.name} (ID: {session_id})[/dim]") except Exception as e: @@ -765,6 +952,7 @@ async def execute_single_run( run.status, diff_file=run.diff_file, session_log_file=run.session_log_file, + session_html_file=run.session_html_file, session_id=run.session_id, )