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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+ | ID |
+ Task |
+ Model |
+ Status |
+ Duration |
+ Logs & Artifacts |
+ Prompts |
+ Error |
+
+
+
+ {"".join(rows)}
+
+
+
+ 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,
)