#!/usr/bin/env python3
"""HTTP server for agent dev loop tool.
Provides:
- Static file serving (index.html)
- Config generation API
- Trace viewing API
- Live streaming of agent runs
Usage:
    python -m rollouts.frontend.server
    python -m rollouts.frontend.server --port 8080
    python -m rollouts.frontend.server --project ~/myproject
"""
import argparse
import json
import logging
import os
import signal
import subprocess
import threading
import time
import webbrowser
from datetime import datetime
from http.server import HTTPServer, SimpleHTTPRequestHandler
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
# Configure logging
logging.basicConfig(
    level=logging.INFO, format="[%(asctime)s] %(levelname)s: %(message)s", datefmt="%H:%M:%S"
)
logger = logging.getLogger(__name__)
# Global process registry for tracking running agent evaluations
_active_runs = {}  # run_id -> {process, config_name, start_time, status, output_lines, exit_code}
_run_counter = 0
_run_lock = threading.Lock()
# Semaphore for limiting concurrent runs (default: 2 concurrent runs)
_max_concurrent_runs = 2
_run_semaphore = threading.Semaphore(_max_concurrent_runs)
class DevLoopServer(SimpleHTTPRequestHandler):
    """HTTP server for agent dev loop tool.
    Serves static files and provides API endpoints for:
    - /api/configs - List available configs
    - /api/traces - List/load evaluation traces
    - /api/generate - Generate new config files
    """
    # Class variable to store project root (set by main())
    project_root: Path = Path.cwd()
    def log_message(self, format: str, *args: object) -> None:
        """Override to use our logger instead of stderr."""
        logger.info(f"{self.address_string()} - {format % args}")
    def do_GET(self) -> None:
        """Handle GET requests."""
        parsed = urlparse(self.path)
        path = parsed.path
        if path == "/" or path == "/index.html":
            self._serve_index()
        elif path == "/api/configs":
            self._list_configs()
        elif path == "/api/traces":
            self._list_traces()
        elif path.startswith("/api/trace/"):
            trace_id = path.split("/api/trace/")[1]
            self._get_trace(trace_id)
        elif path.startswith("/api/load-config/"):
            config_name = path.split("/api/load-config/")[1]
            self._load_config(config_name)
        elif path.startswith("/api/dataset-preview/"):
            config_name = path.split("/api/dataset-preview/")[1]
            self._get_dataset_preview(config_name)
        elif path.startswith("/api/parse-messages/"):
            config_name = path.split("/api/parse-messages/")[1]
            self._parse_messages(config_name)
        elif path.startswith("/api/parse-tools/"):
            config_name = path.split("/api/parse-tools/")[1]
            self._parse_tools(config_name)
        elif path.startswith("/api/view-hook/"):
            config_name = path.split("/api/view-hook/")[1]
            self._view_hook(config_name)
        elif path.startswith("/api/view-environment/"):
            config_name = path.split("/api/view-environment/")[1]
            self._view_environment(config_name)
        elif path == "/api/models":
            self._list_models()
        elif path == "/api/list-datasets":
            self._list_datasets()
        elif path == "/api/runs":
            self._list_active_runs()
        elif path.startswith("/api/stream/"):
            run_id = path.split("/api/stream/")[1]
            self._stream_run_output(run_id)
        else:
            # Default behavior for other files
            super().do_GET()
    def do_POST(self) -> None:
        """Handle POST requests."""
        parsed = urlparse(self.path)
        path = parsed.path
        if path == "/api/generate":
            self._generate_config()
        elif path == "/api/launch":
            self._launch_config()
        elif path == "/api/log":
            self._log_from_frontend()
        elif path == "/api/preview-dataset":
            self._preview_dataset_direct()
        elif path.startswith("/api/kill/"):
            run_id = path.split("/api/kill/")[1]
            self._kill_run(run_id)
        elif path.startswith("/api/delete-run/"):
            run_id = path.split("/api/delete-run/")[1]
            self._delete_run(run_id)
        else:
            self.send_error(404, "Not found")
    def _serve_index(self) -> None:
        """Serve the main HTML file."""
        index_path = Path(__file__).parent / "index.html"
        if not index_path.exists():
            self.send_error(404, "index.html not found")
            return
        content = index_path.read_bytes()
        self.send_response(200)
        self.send_header("Content-Type", "text/html")
        self.send_header("Content-Length", str(len(content)))
        self.end_headers()
        self.wfile.write(content)
    def _list_configs(self) -> None:
        """List available config files in project."""
        configs_dir = self.project_root / "configs"
        if not configs_dir.exists():
            self._json_response([])
            return
        configs = []
        for config_file in sorted(configs_dir.glob("*.py")):

            if config_file.stem.startswith("_"):
                continue
            configs.append({
                "name": config_file.stem,
                "path": str(config_file.relative_to(self.project_root)),
                "modified": config_file.stat().st_mtime,
            })
        self._json_response(configs)
    def _list_traces(self) -> None:
        """List available evaluation traces in results/."""
        results_dir = self.project_root / "results"
        if not results_dir.exists():
            self._json_response([])
            return
        traces = []
        for trace_dir in sorted(results_dir.iterdir(), reverse=True):
            if not trace_dir.is_dir():
                continue
            report_path = trace_dir / "report.json"
            if not report_path.exists():
                continue
            report = json.loads(report_path.read_text())
            traces.append({
                "id": trace_dir.name,
                "name": trace_dir.name,
                "timestamp": trace_dir.stat().st_mtime,
                "total_samples": report.get("total_samples", 0),
                "mean_reward": report.get("summary_metrics", {}).get("mean_reward", 0),
            })
        self._json_response(traces)
    def _get_trace(self, trace_id: str) -> None:
        """Load a specific evaluation trace."""
        trace_dir = self.project_root / "results" / trace_id
        if not trace_dir.exists():
            self.send_error(404, f"Trace not found: {trace_id}")
            return
        report_path = trace_dir / "report.json"
        if not report_path.exists():
            self.send_error(404, f"No report.json in trace: {trace_id}")
            return
        report = json.loads(report_path.read_text())
        trajectories_dir = trace_dir / "trajectories"
        samples = []
        if trajectories_dir.exists():
            for traj_file in sorted(trajectories_dir.glob("*.jsonl")):
                messages = []
                rewards = []
                metadata = {}
                for line in traj_file.read_text().splitlines():
                    if not line.strip():
                        continue
                    event = json.loads(line)
                    if "messages" in event:
                        messages = event["messages"]
                    if "rewards" in event:
                        rewards.append(event["rewards"])
                    elif "reward" in event:
                        rewards.append(event["reward"])
                    if "metadata" in event:
                        metadata = event["metadata"]
                samples.append({
                    "name": traj_file.stem,
                    "messages": messages,
                    "rewards": rewards[-1] if rewards else 0,
                    "metadata": metadata,
                })
        trace_data = {
            "id": trace_id,
            "name": report.get("config_name", trace_id),
            "total_samples": report.get("total_samples", len(samples)),
            "mean_reward": report.get("summary_metrics", {}).get("mean_reward", 0),
            "samples": samples,
            "report": report,
        }
        self._json_response(trace_data)
    def _load_config(self, config_name: str) -> None:
        """Load and parse an existing config file."""
        config_path = self.project_root / "configs" / f"{config_name}.py"
        if not config_path.exists():
            self.send_error(404, f"Config not found: {config_name}")
            return
        # Read the config file
        config_source = config_path.read_text()
        # This is intentionally simple - just extracting common patterns
        import re
        config_data = {
            "name": config_name,
            "source": config_source,
        }
        model_match = re.search(r'model_name\s*[:=]\s*["\']([^"\']+)["\']', config_source)
        if model_match:
            config_data["model"] = model_match.group(1)
        temp_match = re.search(r"temperature\s*[:=]\s*([0-9.]+)", config_source)
        if temp_match:
            config_data["temperature"] = float(temp_match.group(1))
        prepare_msg_match = re.search(
            r"(def prepare_messages\(self.*?^    def \w+|def prepare_messages\(self.*?^class \w+|def prepare_messages\(self.*?$)",
            config_source,
            re.DOTALL | re.MULTILINE,
        )
        if prepare_msg_match:
            # Clean up and dedent the function
            func_text = prepare_msg_match.group(1)
            # Remove trailing class/def if captured
            func_text = re.sub(r"\n    (def |class )\w+.*$", "", func_text, flags=re.DOTALL)
            config_data["prepareMessages"] = func_text.strip()
        # Also extract just system_prompt for backward compatibility
        prompt_match = re.search(r'system_prompt\s*=\s*"""([^"]+)"""', config_source, re.DOTALL)
        if prompt_match:
            config_data["systemPrompt"] = prompt_match.group(1).strip()
        turns_match = re.search(r"max_turns\s*[:=]\s*(\d+)", config_source)
        if turns_match:
            config_data["maxTurns"] = int(turns_match.group(1))
        samples_match = re.search(r"num_samples\s*[:=]\s*(\d+)", config_source)
        if samples_match:
            config_data["numSamples"] = int(samples_match.group(1))
        seed_match = re.search(r"seed\s*[:=]\s*(\d+)", config_source)
        if seed_match:
            config_data["seed"] = int(seed_match.group(1))
        start_match = re.search(r"start_idx\s*[:=]\s*(\d+)", config_source)
        if start_match:
            config_data["startIdx"] = int(start_match.group(1))
        end_match = re.search(r"end_idx\s*[:=]\s*(\d+)", config_source)
        if end_match:
            config_data["endIdx"] = int(end_match.group(1))
        ssh_match = re.search(r'["\']ssh_target["\']\s*:\s*["\']([^"\']+)["\']', config_source)
        if not ssh_match:
            ssh_match = re.search(r'ssh_target\s*[:=]\s*["\']([^"\']+)["\']', config_source)
        if ssh_match:
            config_data["sshTarget"] = ssh_match.group(1)
        # Try to match cuda_device_ids as a list first
        gpu_list_match = re.search(r'["\']cuda_device_ids["\']\s*:\s*\[([^\]]+)\]', config_source)
        if not gpu_list_match:
            gpu_list_match = re.search(r"cuda_device_ids\s*[:=]\s*\[([^\]]+)\]", config_source)
        if gpu_list_match:
            cuda_device_ids_str = gpu_list_match.group(1)
            config_data["gpuIds"] = [
                int(x.strip()) for x in cuda_device_ids_str.split(",") if x.strip().isdigit()
            ]
        else:

            gpu_match = re.search(r'["\']gpu_id["\']\s*:\s*(\d+)', config_source)
            if not gpu_match:
                gpu_match = re.search(r"gpu_id\s*[:=]\s*(\d+)", config_source)
            if gpu_match:
                config_data["gpuIds"] = [int(gpu_match.group(1))]
        dataset_match = re.search(
            r'["\']dataset_path["\']\s*:\s*Path\(["\']([^"\']+)["\']\)', config_source
        )
        if not dataset_match:
            dataset_match = re.search(
                r'dataset_path\s*[:=]\s*Path\(["\']([^"\']+)["\']\)', config_source
            )
        if dataset_match:
            config_data["datasetPath"] = dataset_match.group(1)
        env_name_match = re.search(r'env_name\s*[:=]\s*["\']([^"\']+)["\']', config_source)
        if env_name_match:
            config_data["envName"] = env_name_match.group(1)
        tools_section = re.search(
            r"def get_tools\(self\).*?return \[(.*?)\]", config_source, re.DOTALL
        )
        if tools_section:
            # This is a simplified parser - just check if it returns empty list or has tools
            tools_content = tools_section.group(1).strip()
            config_data["hasTools"] = len(tools_content) > 0 and "Tool(" in tools_content
        else:
            config_data["hasTools"] = False
        self._json_response(config_data)
    def _get_dataset_preview(self, config_name: str) -> None:
        """Get preview of dataset for a config - shows first sample with all fields."""
        import re
        config_path = self.project_root / "configs" / f"{config_name}.py"
        if not config_path.exists():
            self._json_response({"error": f"Config not found: {config_name}"})
            return
        # Read config to extract dataset path
        config_source = config_path.read_text()
        # Try to find dataset_path in the source
        dataset_match = re.search(
            r'["\']dataset_path["\']\s*:\s*Path\(["\']([^"\']+)["\']\)', config_source
        )
        if not dataset_match:
            dataset_match = re.search(
                r'dataset_path\s*[:=]\s*Path\(["\']([^"\']+)["\']\)', config_source
            )
        if not dataset_match:
            self._json_response({"error": "Could not find dataset_path in config"})
            return
        dataset_path_str = dataset_match.group(1)
        dataset_path = self.project_root / dataset_path_str
        if not dataset_path.exists():
            self._json_response({"error": f"Dataset not found: {dataset_path_str}"})
            return
        # Read first sample from dataset and count total
        try:
            dataset_size = 0
            if dataset_path.suffix == ".jsonl":
                # JSONL format - read first line and count total
                with dataset_path.open() as f:
                    first_line = f.readline()
                    sample = json.loads(first_line)
                    # Count total lines
                    dataset_size = 1 + sum(1 for _ in f)
            else:
                # JSON array format
                data = json.loads(dataset_path.read_text())
                if isinstance(data, list) and len(data) > 0:
                    sample = data[0]
                    dataset_size = len(data)
                else:
                    self._json_response({"error": "Dataset is empty or not a list"})
                    return
            fields = list(sample.keys())
            preview_sample = {}
            for key, value in sample.items():
                if isinstance(value, str) and len(value) > 100:
                    preview_sample[key] = value[:100] + "..."
                else:
                    preview_sample[key] = value
            self._json_response({
                "datasetPath": dataset_path_str,
                "fields": fields,
                "sample": preview_sample,
                "datasetSize": dataset_size,
                "error": None,
            })
        except Exception as e:
            self._json_response({"error": f"Error reading dataset: {str(e)}"})
    def _parse_messages(self, config_name: str) -> None:
        """Parse prepare_messages() method from config to extract message list."""
        import re
        config_path = self.project_root / "configs" / f"{config_name}.py"
        if not config_path.exists():
            self._json_response({"error": f"Config not found: {config_name}"})
            return
        config_source = config_path.read_text()

        method_match = re.search(
            r"def prepare_messages\(.*?\).*?:\s*\n(.*?)(?=\ndef |\nclass |\Z)",
            config_source,
            re.DOTALL,
        )
        if not method_match:
            self._json_response({"error": "Could not find prepare_messages() function"})
            return
        method_body = method_match.group(1)
        variable_values = {}
        triple_quote_pattern = r'(\w+)\s*=\s*"""(.*?)"""'
        for match in re.finditer(triple_quote_pattern, method_body, re.DOTALL):
            var_name = match.group(1)
            var_value = match.group(2).strip()
            variable_values[var_name] = var_value
        f_triple_quote_pattern = r'(\w+)\s*=\s*f"""(.*?)"""'
        for match in re.finditer(f_triple_quote_pattern, method_body, re.DOTALL):
            var_name = match.group(1)
            var_value = match.group(2).strip()
            variable_values[var_name] = var_value
        sample_data_pattern = r'(\w+)\s*=\s*sample_data\[["\'](\w+)["\']\]'
        for match in re.finditer(sample_data_pattern, method_body):
            var_name = match.group(1)
            field_name = match.group(2)
            variable_values[var_name] = f"{{{field_name}}}"
        simple_string_pattern = r'(\w+)\s*=\s*"([^"]+)"'
        for match in re.finditer(simple_string_pattern, method_body):
            var_name = match.group(1)
            var_value = match.group(2)
            # Don't override if already captured by triple-quote pattern
            if var_name not in variable_values:
                variable_values[var_name] = var_value

        return_match = re.search(r"return\s*\[(.*?)\]", method_body, re.DOTALL)
        if not return_match:
            self._json_response({"error": "Could not parse messages from return statement"})
            return
        messages_str = return_match.group(1)
        messages = []
        message_pattern = (
            r'Message\(\s*role\s*=\s*["\'](\w+)["\']\s*,\s*content\s*=\s*(.*?)\s*\)(?=\s*(?:,|\]))'
        )
        for match in re.finditer(message_pattern, messages_str, re.DOTALL):
            role = match.group(1)
            content_expr = match.group(2).strip()
            if content_expr.startswith('f"""') or content_expr.startswith("f'''"):
                # f-string with triple quotes
                content = (
                    re.search(r'f["\']{{3}}(.*?)["\']{{3}}', content_expr, re.DOTALL)
                    .group(1)
                    .strip()
                )
            elif content_expr.startswith('"""') or content_expr.startswith("'''"):
                # Regular triple-quoted string
                content = (
                    re.search(r'["\']{{3}}(.*?)["\']{{3}}', content_expr, re.DOTALL)
                    .group(1)
                    .strip()
                )
            elif content_expr.startswith('f"') or content_expr.startswith("f'"):
                # f-string with single quotes
                quote_char = content_expr[1]
                content = content_expr[2 : content_expr.rfind(quote_char)]
            elif content_expr.startswith('"') or content_expr.startswith("'"):
                # Regular string
                quote_char = content_expr[0]
                content = content_expr[1 : content_expr.rfind(quote_char)]
            elif content_expr in variable_values:
                # Variable reference - look up the value
                content = variable_values[content_expr]
            else:
                # Complex expression - use as-is
                content = content_expr
            messages.append({"role": role, "content": content})
        self._json_response({"messages": messages, "error": None})
    def _parse_tools(self, config_name: str) -> None:
        """Parse get_tools() method from config to extract tool definitions."""
        import re
        config_path = self.project_root / "configs" / f"{config_name}.py"
        if not config_path.exists():
            self._json_response({"error": f"Config not found: {config_name}"})
            return
        config_source = config_path.read_text()

        method_match = re.search(
            r"def get_tools\(self\).*?:\s*\n(.*?)(?=\n    def |\nclass |\Z)",
            config_source,
            re.DOTALL,
        )
        if not method_match:
            self._json_response({"tools": [], "hasTools": False, "error": None})
            return
        method_body = method_match.group(1)

        return_match = re.search(r"return\s*\[(.*?)\]", method_body, re.DOTALL)
        if not return_match:
            self._json_response({"tools": [], "hasTools": False, "error": None})
            return
        tools_str = return_match.group(1)
        if not tools_str.strip() or "Tool(" not in tools_str:
            self._json_response({"tools": [], "hasTools": False, "error": None})
            return
        tools = []
        tool_pattern = r'Tool\(\s*name\s*=\s*["\'](\w+)["\']\s*,\s*description\s*=\s*["\']{{3}}(.*?)["\']{{3}}\s*,\s*parameters\s*=\s*\{(.*?)\}\s*\)'
        for match in re.finditer(tool_pattern, tools_str, re.DOTALL):
            tool_name = match.group(1)
            tool_desc = match.group(2).strip()
            params_str = match.group(3)
            parameters = []
            param_pattern = r'["\'](\w+)["\']\s*:\s*ToolParam\(\s*type\s*=\s*["\'](\w+)["\']\s*,\s*description\s*=\s*["\']([^"\']+)["\']\s*(?:,\s*required\s*=\s*(True|False))?\s*\)'
            for param_match in re.finditer(param_pattern, params_str):
                param_name = param_match.group(1)
                param_type = param_match.group(2)
                param_desc = param_match.group(3)
                param_required = param_match.group(4) != "False" if param_match.group(4) else True
                parameters.append({
                    "name": param_name,
                    "type": param_type,
                    "description": param_desc,
                    "required": param_required,
                })
            tools.append({"name": tool_name, "description": tool_desc, "parameters": parameters})
        self._json_response({"tools": tools, "hasTools": len(tools) > 0, "error": None})
    def _view_hook(self, config_name: str) -> None:
        """View on_assistant_message() implementation from environment."""
        import re
        config_path = self.project_root / "configs" / f"{config_name}.py"
        if not config_path.exists():
            self._json_response({"error": f"Config not found: {config_name}"})
            return
        config_source = config_path.read_text()
        # Try to find environment class name or environment file
        env_match = re.search(r"from\s+([\w.]+)\s+import\s+(\w+Environment)", config_source)
        if not env_match:
            self._json_response({"error": "Could not find environment import"})
            return
        env_module = env_match.group(1)
        _env_class = env_match.group(2)  # Available for class-specific handling
        module_parts = env_module.split(".")
        env_file = self.project_root / Path(*module_parts[:-1]) / f"{module_parts[-1]}.py"
        if not env_file.exists():
            # Try without the last part
            env_file = self.project_root / f"{module_parts[0]}.py"
        if not env_file.exists():
            self._json_response({"error": f"Could not find environment file: {env_module}"})
            return
        env_source = env_file.read_text()

        method_match = re.search(
            r"(async def on_assistant_message\(.*?\).*?:\s*\n.*?)(?=\n    async def |\n    def |\nclass |\Z)",
            env_source,
            re.DOTALL,
        )
        if not method_match:
            self._json_response({"error": "Could not find on_assistant_message() method"})
            return
        method_source = method_match.group(1).strip()
        self._json_response({"source": method_source, "error": None})
    def _view_environment(self, config_name: str) -> None:
        """View full environment source code."""
        import re
        config_path = self.project_root / "configs" / f"{config_name}.py"
        if not config_path.exists():
            self._json_response({"error": f"Config not found: {config_name}"})
            return
        config_source = config_path.read_text()
        # Try to find environment class name or environment file
        env_match = re.search(r"from\s+([\w.]+)\s+import\s+(\w+Environment)", config_source)
        if not env_match:
            self._json_response({"error": "Could not find environment import"})
            return
        env_module = env_match.group(1)
        _env_class = env_match.group(2)  # Available for class-specific handling
        module_parts = env_module.split(".")
        env_file = self.project_root / Path(*module_parts[:-1]) / f"{module_parts[-1]}.py"
        if not env_file.exists():
            # Try without the last part
            env_file = self.project_root / f"{module_parts[0]}.py"
        if not env_file.exists():
            self._json_response({"error": f"Could not find environment file: {env_module}"})
            return
        # Read entire environment file
        env_source = env_file.read_text()
        self._json_response({
            "source": env_source,
            "file_path": str(env_file.relative_to(self.project_root)),
            "class_name": _env_class,
            "error": None,
        })
    def _list_models(self) -> None:
        """Fetch available models from OpenAI and Anthropic APIs."""
        import os
        import urllib.request
        models = []
        errors = []
        # Fetch OpenAI models
        openai_key = os.environ.get("OPENAI_API_KEY")
        if openai_key:
            try:
                req = urllib.request.Request(
                    "https://api.openai.com/v1/models",
                    headers={"Authorization": f"Bearer {openai_key}"},
                )
                with urllib.request.urlopen(req, timeout=5) as response:
                    data = json.loads(response.read().decode())
                    for model in data.get("data", []):
                        model_id = model.get("id", "")
                        if any(model_id.startswith(prefix) for prefix in ["gpt-4", "o1", "o3"]):
                            models.append({"id": model_id, "provider": "openai", "name": model_id})
            except Exception as e:
                errors.append(f"OpenAI: {str(e)}")
        # Fetch Anthropic models
        anthropic_key = os.environ.get("ANTHROPIC_API_KEY")
        if anthropic_key:
            try:
                req = urllib.request.Request(
                    "https://api.anthropic.com/v1/models",
                    headers={"x-api-key": anthropic_key, "anthropic-version": "2023-06-01"},
                )
                with urllib.request.urlopen(req, timeout=5) as response:
                    data = json.loads(response.read().decode())
                    for model in data.get("data", []):
                        model_id = model.get("id", "")
                        models.append({
                            "id": model_id,
                            "provider": "anthropic",
                            "name": model.get("display_name", model_id),
                        })
            except Exception as e:
                errors.append(f"Anthropic: {str(e)}")
        self._json_response({"models": models, "errors": errors if errors else None})
    def _list_datasets(self) -> None:
        """List available dataset files in the datasets directory."""
        datasets_dir = self.project_root / "datasets"
        if not datasets_dir.exists():
            self._json_response({"datasets": [], "error": "datasets/ directory not found"})
            return
        try:

            datasets = []
            for file_path in datasets_dir.rglob("*"):
                if file_path.suffix in [".json", ".jsonl"] and file_path.is_file():
                    relative_path = file_path.relative_to(self.project_root)
                    datasets.append({
                        "path": str(relative_path),
                        "name": file_path.name,
                        "size": file_path.stat().st_size,
                    })
            datasets.sort(key=lambda x: x["name"])
            self._json_response({"datasets": datasets, "error": None})
        except Exception as e:
            self._json_response({"datasets": [], "error": f"Error listing datasets: {str(e)}"})
    def _preview_dataset_direct(self) -> None:
        """Preview a dataset by path directly (not via config)."""
        content_length = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(content_length)
        try:
            data = json.loads(body)
            dataset_path_str = data.get("datasetPath", "")
        except json.JSONDecodeError:
            self._json_response({"error": "Invalid JSON in request body"})
            return
        if not dataset_path_str:
            self._json_response({"error": "Missing datasetPath in request"})
            return
        dataset_path = self.project_root / dataset_path_str
        if not dataset_path.exists():
            self._json_response({"error": f"Dataset not found: {dataset_path_str}"})
            return
        # Read first sample from dataset and count total
        try:
            dataset_size = 0
            if dataset_path.suffix == ".jsonl":
                # JSONL format - read first line and count total
                with dataset_path.open() as f:
                    first_line = f.readline()
                    if not first_line:
                        self._json_response({"error": "Dataset is empty"})
                        return
                    sample = json.loads(first_line)
                    # Count total lines
                    dataset_size = 1 + sum(1 for _ in f)
            else:
                # JSON array format
                data = json.loads(dataset_path.read_text())
                if isinstance(data, list) and len(data) > 0:
                    sample = data[0]
                    dataset_size = len(data)
                else:
                    self._json_response({"error": "Dataset is empty or not a list"})
                    return
            fields = list(sample.keys())
            preview_sample = {}
            for key, value in sample.items():
                if isinstance(value, str) and len(value) > 100:
                    preview_sample[key] = value[:100] + "..."
                else:
                    preview_sample[key] = value
            self._json_response({
                "datasetPath": dataset_path_str,
                "fields": fields,
                "sample": preview_sample,
                "datasetSize": dataset_size,
            })
        except json.JSONDecodeError as e:
            self._json_response({"error": f"Invalid JSON in dataset: {str(e)}"})
        except Exception as e:
            self._json_response({"error": f"Error reading dataset: {str(e)}"})
    def _generate_config(self) -> None:
        """Generate a new config file from JSON payload."""
        content_length = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(content_length)
        try:
            config_data = json.loads(body)
        except json.JSONDecodeError as e:
            self.send_error(400, f"Invalid JSON: {e}")
            return
        # Generate config file content
        config_text = self._build_config_file(config_data)
        # Save to file
        config_name = config_data.get("configName", "untitled_config")
        config_path = self.project_root / "configs" / f"{config_name}.py"
        # Ensure configs directory exists
        config_path.parent.mkdir(parents=True, exist_ok=True)
        # Write config file
        config_path.write_text(config_text)
        self._json_response({
            "success": True,
            "file_path": str(config_path.relative_to(self.project_root)),
            "config_name": config_name,
        })
    def _build_config_file(self, data: dict[str, Any]) -> str:
        """Build config file content from data.
        Args:
            data: Config parameters from frontend
        Returns:
            Python source code for config file
        """
        # Normalize cuda_device_ids: convert to list if needed
        if "cuda_device_ids" in data:
            cuda_device_ids = data["cuda_device_ids"]
            if not isinstance(cuda_device_ids, list):
                data["cuda_device_ids"] = [cuda_device_ids]
        base_name = data.get("baseName")
        logger.debug(f"_build_config: baseName={base_name}, configName={data.get('configName')}")
        if base_name:
            logger.debug(f"Building from base config: {base_name}")
            return self._build_from_base_config(data, base_name)
        else:
            logger.debug("Building new config from template")
            return self._build_new_config(data)
    def _build_from_base_config(self, data: dict[str, Any], base_name: str) -> str:
        """Build config by copying and modifying base config."""
        base_path = self.project_root / "configs" / f"{base_name}.py"
        if not base_path.exists():

            logger.warning(f"Base config not found: {base_path}, falling back to template")
            return self._build_new_config(data)
        # Read base config
        config_source = base_path.read_text()
        import re
        env_import = re.search(r"from\s+([\w.]+)\s+import\s+(\w+Environment)", config_source)
        env_class = re.search(r"environment_class:\s*type\s*=\s*(\w+)", config_source)
        logger.debug(
            f"Base config has environment import: {env_import.group(0) if env_import else 'None'}"
        )
        logger.debug(
            f"Base config has environment_class: {env_class.group(1) if env_class else 'None'}"
        )
        # Replace specific values
        import re

        if "model" in data:
            model_name = data["model"]
            config_source = re.sub(
                r'(model_name\s*[:=]\s*)["\']([^"\']+)["\']', f'\\1"{model_name}"', config_source
            )
            # Also update provider and api_key_env_var based on new model
            if "claude" in model_name.lower() or "anthropic" in model_name.lower():
                provider = "anthropic"
                api_key_env_var = "ANTHROPIC_API_KEY"
                api_base = "https://api.anthropic.com"  # SDK adds /v1 automatically
            elif (
                "gpt" in model_name.lower()
                or "o1" in model_name.lower()
                or "o3" in model_name.lower()
            ):
                provider = "openai"
                api_key_env_var = "OPENAI_API_KEY"
                api_base = "https://api.openai.com/v1"
            else:
                provider = "openai"
                api_key_env_var = "OPENAI_API_KEY"
                api_base = "https://api.openai.com/v1"

            config_source = re.sub(
                r'(provider\s*[:=]\s*)["\']([^"\']+)["\']', f'\\1"{provider}"', config_source
            )

            config_source = re.sub(
                r'(api_key_env_var\s*[:=]\s*)["\']([^"\']+)["\']',
                f'\\1"{api_key_env_var}"',
                config_source,
            )

            config_source = re.sub(
                r'(api_base\s*[:=]\s*)["\']([^"\']+)["\']', f'\\1"{api_base}"', config_source
            )

        if "temperature" in data:
            config_source = re.sub(
                r"(temperature\s*[:=]\s*)([0-9.]+)", f"\\g<1>{data['temperature']}", config_source
            )

        if "systemPrompt" in data:
            prompt = data["systemPrompt"]
            config_source = re.sub(
                r'(system_prompt\s*=\s*""")([^"]+)(""")',
                f"\\1{prompt}\\3",
                config_source,
                flags=re.DOTALL,
            )

        if "maxTurns" in data:
            config_source = re.sub(
                r"(max_turns\s*[:=]\s*)(\d+)", f"\\g<1>{data['maxTurns']}", config_source
            )

        if "numSamples" in data:
            config_source = re.sub(
                r"(num_samples\s*[:=]\s*)(\d+)", f"\\g<1>{data['numSamples']}", config_source
            )

        env_fields = data.get("envFields", {})
        if "sshTarget" in env_fields:
            config_source = re.sub(
                r'(ssh_target\s*[:=]\s*)["\']([^"\']+)["\']',
                f'\\1"{env_fields["sshTarget"]}"',
                config_source,
            )
        if "cuda_device_ids" in data:
            cuda_device_ids = data["cuda_device_ids"]
            cuda_device_ids_str = str(cuda_device_ids)  # Convert list to string like [0, 1, 2]
            # Try to replace cuda_device_ids list first
            config_source = re.sub(
                r"(cuda_device_ids\s*[:=]\s*)\[[^\]]*\]",
                f"\\g<1>{cuda_device_ids_str}",
                config_source,
            )
            # Also try in dict format
            config_source = re.sub(
                r'(["\']cuda_device_ids["\']\s*:\s*)\[[^\]]*\]',
                f"\\g<1>{cuda_device_ids_str}",
                config_source,
            )
            # Fallback: replace old gpu_id with first GPU from list
            if cuda_device_ids:
                config_source = re.sub(
                    r"(gpu_id\s*[:=]\s*)(\d+)", f"\\g<1>{cuda_device_ids[0]}", config_source
                )
                config_source = re.sub(
                    r'(["\']gpu_id["\']\s*:\s*)(\d+)', f"\\g<1>{cuda_device_ids[0]}", config_source
                )
        if "datasetPath" in env_fields:
            config_source = re.sub(
                r'(dataset_path\s*[:=]\s*Path\()["\']([^"\']+)(["\'])',
                f'\\1"{env_fields["datasetPath"]}"\\3',
                config_source,
            )
        if "envName" in env_fields:
            config_source = re.sub(
                r'(env_name\s*[:=]\s*)["\']([^"\']+)["\']',
                f'\\1"{env_fields["envName"]}"',
                config_source,
            )

        if "messages" in data and data["messages"]:
            # Detect if prepare_messages is standalone or a method
            existing_match = re.search(r"def prepare_messages\((.*?)\)", config_source)
            is_standalone = existing_match and "self" not in existing_match.group(1)
            messages_code = self._generate_prepare_messages_method(
                data["messages"], is_standalone=is_standalone
            )
            # Replace the existing prepare_messages method
            config_source = re.sub(
                r"def prepare_messages\(.*?\).*?:\s*\n.*?(?=\ndef |\nclass |\Z)",
                messages_code,
                config_source,
                flags=re.DOTALL,
            )

        if "tools" in data and data["tools"]:
            config_source = self._update_tool_descriptions(config_source, data["tools"])
        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        config_name = data.get("configName", "custom_config")
        header = f'"""Agent configuration - {config_name}\n\nGenerated: {timestamp}\nBased on: {base_name}\n"""\n'
        # Replace existing docstring or prepend
        config_source = re.sub(r'^"""[^"]*"""\n', header, config_source)
        return config_source
    def _generate_prepare_messages_method(
        self, messages: list[dict], is_standalone: bool = True
    ) -> str:
        """Generate prepare_messages() code from message list.
        Args:
            messages: List of message dicts
            is_standalone: If True, generates standalone function. If False, generates class method.
        """
        lines = []
        if is_standalone:
            lines.append("def prepare_messages(sample_data: Dict[str, Any]) -> List[Message]:")
            lines.append('    """Prepare initial messages from dataset sample."""')
            indent = "    "
        else:
            lines.append(
                "    def prepare_messages(self, sample_data: dict[str, Any]) -> list[Message]:"
            )
            lines.append('        """Prepare initial messages for the agent."""')
            indent = "        "
        for i, msg in enumerate(messages):
            role = msg["role"]
            content = msg["content"]
            has_placeholders = "{" in content and "}" in content
            # Escape any existing triple quotes in content
            content_escaped = content.replace('"""', r"\"\"\"")
            if has_placeholders:
                import re
                def replace_placeholder(match: re.Match[str]) -> str:
                    field = match.group(1)
                    return f"{{sample_data.get('{field}', '')}}"
                content_escaped = re.sub(r"\{(\w+)\}", replace_placeholder, content_escaped)
                lines.append(f'{indent}msg{i}_content = f"""')
                lines.append(content_escaped)
                lines.append(f'{indent}"""')
            else:
                lines.append(f'{indent}msg{i}_content = """')
                lines.append(content_escaped)
                lines.append(f'{indent}"""')
        lines.append(f"{indent}return [")
        for i, msg in enumerate(messages):
            role = msg["role"]
            lines.append(f'{indent}    Message(role="{role}", content=msg{i}_content),')
        lines.append(f"{indent}]")
        return "\n".join(lines)
    def _update_tool_descriptions(self, config_source: str, tools: list[dict]) -> str:
        """Update tool and parameter descriptions in config source."""
        import re
        for tool in tools:
            tool_name = tool["name"]
            tool_desc = tool["description"]

            pattern = rf'(Tool\(\s*name\s*=\s*["\']){tool_name}(["\']\\s*,\s*description\s*=\s*["\']{{3}}).*?(["\']{{3}})'
            replacement = rf"\1{tool_name}\2{tool_desc}\3"
            config_source = re.sub(pattern, replacement, config_source, flags=re.DOTALL)

            for param in tool.get("parameters", []):
                param_name = param["name"]
                param_desc = param["description"]

                param_pattern = rf'(["\']){param_name}\1\s*:\s*ToolParam\(\s*type\s*=\s*["\'](\w+)["\']\\s*,\s*description\s*=\s*["\']([^"\']*)["\']'
                param_replacement = (
                    rf'\1{param_name}\1: ToolParam(type="\2", description="{param_desc}"'
                )
                config_source = re.sub(param_pattern, param_replacement, config_source)
        return config_source
    def _build_new_config(self, data: dict[str, Any]) -> str:
        """Build a new config from scratch."""
        model_name = data.get("model", "gpt-4-turbo")
        system_prompt = data.get("systemPrompt", "You are an expert assistant.")
        _max_turns = data.get("maxTurns", 5)  # TODO: Use this in eval config
        num_samples = data.get("numSamples", 10)
        temperature = data.get("temperature", 0.1)
        stream_tokens = data.get("stream_tokens", True)  # Default to True for dev loop
        ssh_target = data.get("ssh_target", "")
        cuda_device_ids = data.get("cuda_device_ids", [0])
        dataset_path = data.get("dataset_path", "datasets/default.json")
        # Infer provider and API key env var from model name
        if "claude" in model_name.lower() or "anthropic" in model_name.lower():
            provider = "anthropic"
            api_key_env_var = "ANTHROPIC_API_KEY"
            api_base = "https://api.anthropic.com"  # SDK adds /v1 automatically
        elif (
            "gpt" in model_name.lower() or "o1" in model_name.lower() or "o3" in model_name.lower()
        ):
            provider = "openai"
            api_key_env_var = "OPENAI_API_KEY"
            api_base = "https://api.openai.com/v1"
        else:
            # Default to OpenAI
            provider = "openai"
            api_key_env_var = "OPENAI_API_KEY"
            api_base = "https://api.openai.com/v1"
        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        config = f'''"""Agent configuration - Generated by dev loop tool
Generated: {timestamp}
Model: {model_name}
"""
from pathlib import Path
from dataclasses import dataclass, field
from typing import List, Dict, Any
from ..dtypes import Message, Tool
from ..config import BaseModelConfig, BaseEvaluationConfig
@dataclass
class CustomEnvironment:
    """Custom agent environment."""
    # Environment identification
    env_name: str = "custom-environment"
    # Infrastructure settings (passed from environment_config)
    ssh_target: str = ""
    cuda_device_ids: List[int] = field(default_factory=lambda: [0])
    dataset_path: Path = field(default_factory=lambda: Path("datasets/default.json"))
    def get_tools(self) -> List[Tool]:
        """Return tools available to agent."""
        # TODO: Add custom tools here
        return []
    def prepare_messages(self, sample_data: Dict[str, Any]) -> List[Message]:
        """Prepare initial messages for task.
        Args:
            sample_data: Sample from dataset
        Returns:
            List of messages to initialize conversation
        """
        system_prompt = """{system_prompt}"""
        # Try common dataset field names
        user_prompt = (
            sample_data.get("problem_description") or
            sample_data.get("prompt") or
            sample_data.get("question") or
            sample_data.get("input") or
            str(sample_data)
        )
        return [
            Message(role="system", content=system_prompt),
            Message(role="user", content=user_prompt),
        ]
    async def on_assistant_message(self, message: Message, state):
        """Handle assistant messages.
        This is where you process agent outputs, extract actions, etc.
        """
        # TODO: Implement environment reaction to agent
        return state
@dataclass(frozen=True)
class Config:
    """Main configuration."""
    # Model configuration
    model: BaseModelConfig = field(
        default_factory=lambda: BaseModelConfig(
            model_name="{model_name}",
            provider="{provider}",
            api_base="{api_base}",
            api_key_env_var="{api_key_env_var}",
            temperature={temperature},
            max_tokens=16384,
        )
    )
    # Environment configuration
    environment_class: type = CustomEnvironment
    environment_config: dict = field(default_factory=lambda: {{
        'ssh_target': '{ssh_target}',
        'cuda_device_ids': {cuda_device_ids},
        'dataset_path': Path('{dataset_path}'),
    }})
    # Evaluation settings
    evaluation: BaseEvaluationConfig = field(
        default_factory=lambda: BaseEvaluationConfig(
            environment=None,  # Will be set by factory
            eval_name="custom_eval",
            num_samples={num_samples},
            output_dir=Path("results/custom"),
            verbose=True,
            show_progress=True,
            stream_tokens={stream_tokens},  # Enable/disable token streaming to stdout
        )
    )
    experiment_name: str = "custom_experiment"
    async def create_environment(self, sample_data: dict):
        """Create environment instance for a sample."""
        return self.environment_class(**self.environment_config)
    # Backward compatibility methods for entrypoint
    def to_endpoint(self):
        """Convert to rollouts Endpoint (delegates to model config)."""
        return self.model.to_endpoint()
    def to_eval_config(self, score_fn):
        """Convert to rollouts EvalConfig (delegates to evaluation config)."""
        return self.evaluation.to_eval_config(score_fn)
    @property
    def dataset_path(self):
        """Access dataset path from environment config."""
        return self.environment_config.get('dataset_path')
    @property
    def ssh_target(self) -> str:
        return self.environment_config.get('ssh_target')
    @property
    def gpu_id(self) -> int:
        cuda_device_ids = self.environment_config.get('cuda_device_ids')
        if cuda_device_ids and isinstance(cuda_device_ids, list) and len(cuda_device_ids) > 0:
            return cuda_device_ids[0]
        return self.environment_config.get('gpu_id', 0)
    @property
    def max_turns(self) -> int:
        return self.evaluation.max_turns
    @property
    def num_samples(self) -> int:
        return self.evaluation.num_samples
    @property
    def model_name(self) -> str:
        return self.model.model_name
# Export config instance
config = Config()
# Export prepare_messages for backward compatibility
def prepare_messages(sample_data: Dict[str, Any]) -> List[Message]:
    """Prepare initial messages from dataset sample.
    Note: You can use f-string placeholders to reference dataset fields.
    For example: "Solve this problem: {{problem_description}}"
    """
    system_prompt = """{system_prompt}"""
    user_prompt = f"""{{sample_data.get("problem_description") or sample_data.get("prompt") or sample_data.get("question") or sample_data.get("input") or str(sample_data)}}"""
    return [
        Message(role="system", content=system_prompt),
        Message(role="user", content=user_prompt),
    ]
'''
        return config
    def _launch_config(self) -> None:
        """Launch a config in the background with live streaming support."""
        global _run_counter, _active_runs
        content_length = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(content_length)
        try:
            data = json.loads(body)
            config_name = data.get("configName")
            logger.debug(f"🚀 Launch request received for config: {config_name}")
            if not config_name:
                logger.error("Launch failed: Missing configName")
                self.send_error(400, "Missing configName")
                return
            config_path = self.project_root / "configs" / f"{config_name}.py"
            if not config_path.exists():
                logger.error(f"Launch failed: Config not found: {config_name}")
                self.send_error(404, f"Config not found: {config_name}")
                return
            logger.debug(f"Config file found: {config_path}")
            # GPU preflight check (if config has cuda_device_ids)
            # Read config to check for GPU requirements
            config_source = config_path.read_text()
            import re
            cuda_device_ids = []
            # Try to match cuda_device_ids as a list first
            gpu_list_match = re.search(
                r'["\']cuda_device_ids["\']\s*:\s*\[([^\]]+)\]', config_source
            )
            if not gpu_list_match:
                gpu_list_match = re.search(r"cuda_device_ids\s*[:=]\s*\[([^\]]+)\]", config_source)
            if gpu_list_match:
                cuda_device_ids_str = gpu_list_match.group(1)
                cuda_device_ids = [
                    int(x.strip()) for x in cuda_device_ids_str.split(",") if x.strip().isdigit()
                ]
            else:

                gpu_match = re.search(r'["\']gpu_id["\']\s*:\s*(\d+)', config_source)
                if not gpu_match:
                    gpu_match = re.search(r"gpu_id\s*[:=]\s*(\d+)", config_source)
                if gpu_match:
                    cuda_device_ids = [int(gpu_match.group(1))]
            if cuda_device_ids:
                try:
                    result = subprocess.run(
                        [
                            "nvidia-smi",
                            "--query-gpu=index,memory.used,utilization.gpu",
                            "--format=csv,noheader,nounits",
                        ],
                        capture_output=True,
                        text=True,
                        timeout=5,
                    )
                    if result.returncode == 0:
                        gpu_stats = {}
                        for line in result.stdout.strip().splitlines():
                            parts = [p.strip() for p in line.split(",")]
                            if len(parts) == 3:
                                try:
                                    gpu_id = int(parts[0])
                                    memory_mb = int(parts[1])
                                    util_pct = int(parts[2])
                                    gpu_stats[gpu_id] = {
                                        "memory_mb": memory_mb,
                                        "util_pct": util_pct,
                                    }
                                except ValueError:
                                    continue
                        for gpu_id in cuda_device_ids:
                            if gpu_id in gpu_stats:
                                stats = gpu_stats[gpu_id]
                                if stats["memory_mb"] > 1000 or stats["util_pct"] > 5:
                                    self._json_response({
                                        "success": False,
                                        "error": f"GPU {gpu_id} is busy ({stats['memory_mb']}MB used, {stats['util_pct']}% util)",
                                        "preflight_failed": True,
                                    })
                                    return
                except (subprocess.TimeoutExpired, FileNotFoundError):
                    # nvidia-smi not available or timed out, proceed anyway
                    pass
            # Acquire semaphore (blocks if max concurrent runs reached)
            if not _run_semaphore.acquire(blocking=False):
                self._json_response({
                    "success": False,
                    "error": f"Maximum concurrent runs ({_max_concurrent_runs}) reached. Please wait for a run to complete.",
                    "queue_full": True,
                })
                return
            # Generate unique run ID
            with _run_lock:
                _run_counter += 1
                run_id = f"run_{_run_counter}_{int(time.time())}"
            logger.debug(f"Generated run_id: {run_id}")
            command = ["python", "entrypoint.py", str(config_path)]
            logger.debug(f"Command: {' '.join(command)}")
            # Launch with tracked pipes
            try:

                env = os.environ.copy()
                env["PYTHONUNBUFFERED"] = "1"
                process = subprocess.Popen(
                    command,
                    cwd=self.project_root,
                    stdout=subprocess.PIPE,
                    stderr=subprocess.STDOUT,  # Merge stderr into stdout
                    text=True,
                    bufsize=0,  # Unbuffered - changed from 1 (line buffered) for token streaming
                    start_new_session=True,  # Detach from parent
                    env=env,
                )
                logger.debug(f"Process started with PID: {process.pid}")
            except Exception as e:
                logger.exception(f"Failed to start process: {str(e)}")
                _run_semaphore.release()  # Release semaphore on failure
                self._json_response({
                    "success": False,
                    "error": f"Failed to start process: {str(e)}",
                })
                return

            with _run_lock:
                _active_runs[run_id] = {
                    "process": process,
                    "config_name": config_name,
                    "start_time": time.time(),
                    "status": "running",
                    "output_lines": [],
                    "exit_code": None,
                    "cuda_device_ids": cuda_device_ids,
                }
            logger.debug(f"Run registered in _active_runs. Total active: {len(_active_runs)}")
            self._json_response({
                "success": True,
                "run_id": run_id,
                "command": " ".join(command),
                "config_name": config_name,
            })
        except json.JSONDecodeError as e:
            self.send_error(400, f"Invalid JSON: {e}")
        except Exception as e:
            self.send_error(500, f"Launch failed: {e}")
    def _stream_run_output(self, run_id: str) -> None:
        """Stream output from events.jsonl and stdout via SSE.
        Dual-stream approach:
        1. Parse stdout to find result_dir path
        2. Once found, tail events.jsonl for structured events (sample/turn/token)
        3. Continue streaming stdout for logs (for debugging/CLI compatibility)
        TODO: Consider semantic compression of event stream to reduce bandwidth.
        See ~/research/docs/code_style/ryolu_design_frontend.md for compression strategies.
        Current approach sends every token/turn event separately - could batch or delta-encode.
        """
        global _active_runs
        logger.debug(f"📡 Stream connection opened for run_id: {run_id}")
        if run_id not in _active_runs:
            logger.error(f"Stream failed: Run not found: {run_id}")
            logger.debug(f"Available run_ids: {list(_active_runs.keys())}")
            self.send_error(404, f"Run not found: {run_id}")
            return
        run_data = _active_runs[run_id]
        process = run_data["process"]
        logger.debug(f"Streaming from PID: {process.pid}, status: {run_data['status']}")

        self.send_response(200)
        self.send_header("Content-Type", "text/event-stream")
        self.send_header("Cache-Control", "no-cache")
        self.send_header("Connection", "keep-alive")
        self.send_header("X-Accel-Buffering", "no")  # Disable nginx buffering
        self.end_headers()
        try:
            import re
            from pathlib import Path
            logger.debug(f"🔍 Starting dual-stream (stdout + events.jsonl) for {run_id}")

            events_file = None
            events_file_handle = None
            result_dir_found = False
            # Buffer for stdout line-by-line reading
            stdout_buffer = ""
            while True:
                # Read from stdout (line-buffered for parsing)
                char = process.stdout.read(1)
                if not char:
                    # Process ended
                    logger.debug(f"🔍 Stream ended (no more data) for {run_id}")
                    break
                stdout_buffer += char
                # When we hit newline, parse the line and check for events
                if char == "\n":
                    line = stdout_buffer.rstrip()
                    if not result_dir_found:
                        match = re.search(r"📂 Results directory: (.+)", line)
                        if match:
                            result_dir = Path(match.group(1))
                            events_file = result_dir / "events.jsonl"
                            result_dir_found = True
                            logger.debug(f"📂 Found result_dir: {result_dir}")
                            logger.debug(f"📄 Will tail events from: {events_file}")
                    # Send stdout line as log event (for debugging)
                    with _run_lock:
                        run_data["output_lines"].append(line)
                    event_data = json.dumps({"line": line, "type": "stdout"})
                    self.wfile.write(f"data: {event_data}\n\n".encode())
                    self.wfile.flush()
                    stdout_buffer = ""  # Clear buffer
                # If we found events file, tail it for structured events
                if result_dir_found and events_file and events_file.exists():
                    if events_file_handle is None:
                        # Open events file for reading
                        events_file_handle = open(events_file)
                        events_file_handle.seek(0, 2)  # Seek to end
                        logger.debug("📄 Opened events.jsonl for tailing")
                    # Read new lines from events.jsonl
                    event_line = events_file_handle.readline()
                    while event_line:
                        # Tiger Style: events.jsonl is written by our own code - malformed JSON is a programmer error
                        # Fail-fast instead of silently logging. Assertions catch bugs during development.
                        line = event_line.strip()
                        if not line:  # Skip empty lines
                            event_line = events_file_handle.readline()
                            continue
                        event_obj = json.loads(line)
                        assert isinstance(event_obj, dict), (
                            f"Event must be dict, got {type(event_obj)}: {event_obj}"
                        )
                        assert "type" in event_obj, f"Event missing 'type' field: {event_obj}"
                        assert "timestamp" in event_obj, (
                            f"Event missing 'timestamp' field: {event_obj}"
                        )
                        # Forward event as-is (already has type, timestamp, data)
                        self.wfile.write(f"data: {json.dumps(event_obj)}\n\n".encode())
                        self.wfile.flush()
                        logger.debug(f"📤 Forwarded event: {event_obj['type']}")
                        event_line = events_file_handle.readline()
            # Send any remaining buffered stdout
            if stdout_buffer:
                event_data = json.dumps({"line": stdout_buffer, "type": "stdout"})
                self.wfile.write(f"data: {event_data}\n\n".encode())
                self.wfile.flush()
            # Close events file if opened
            if events_file_handle:
                events_file_handle.close()
            # Wait for process to complete
            exit_code = process.wait()
            # Release semaphore
            _run_semaphore.release()
            # Send completion event
            completion_data = json.dumps({
                "type": "complete",
                "exit_code": exit_code,
                "status": "success" if exit_code == 0 else "failed",
            })
            self.wfile.write(f"data: {completion_data}\n\n".encode())
            self.wfile.flush()

            with _run_lock:
                run_data["status"] = "completed" if exit_code == 0 else "failed"
                run_data["exit_code"] = exit_code
        except Exception as e:
            # Release semaphore on error
            _run_semaphore.release()
            error_data = json.dumps({"type": "error", "message": str(e)})
            self.wfile.write(f"data: {error_data}\n\n".encode())
            self.wfile.flush()

            with _run_lock:
                run_data["status"] = "failed"
    def _log_from_frontend(self) -> None:
        """Receive log messages from frontend."""
        content_length = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(content_length)
        try:
            data = json.loads(body)
            level = data.get("level", "info").upper()
            message = data.get("message", "")
            context = data.get("context", {})
            # Log with appropriate level
            log_msg = f"[FRONTEND] {message}"
            if context:
                log_msg += f" | {json.dumps(context)}"
            if level == "ERROR":
                logger.error(log_msg)
            elif level == "WARN":
                logger.warning(log_msg)
            else:
                logger.info(log_msg)
            self._json_response({"success": True})
        except Exception as e:
            logger.exception(f"Failed to process frontend log: {e}")
            self.send_error(400, str(e))
    def _list_active_runs(self) -> None:
        """List all active and recent runs."""
        global _active_runs
        runs = []
        with _run_lock:
            for run_id, data in _active_runs.items():
                runs.append({
                    "run_id": run_id,
                    "config_name": data["config_name"],
                    "start_time": data["start_time"],
                    "status": data["status"],
                    "exit_code": data.get("exit_code"),
                    "output_length": len(data.get("output_lines", [])),
                })
        self._json_response({"runs": runs})
    def _kill_run(self, run_id: str) -> None:
        """Kill a running process."""
        global _active_runs
        logger.debug(f"🛑 Kill request received for run_id: {run_id}")
        if run_id not in _active_runs:
            logger.error(f"Kill failed: Run not found: {run_id}")
            logger.debug(f"Available run_ids: {list(_active_runs.keys())}")
            self.send_error(404, f"Run not found: {run_id}")
            return
        run_data = _active_runs[run_id]
        process = run_data["process"]
        logger.debug(f"Run status: {run_data['status']}, PID: {process.pid}")
        if run_data["status"] != "running":
            logger.warning(f"Cannot kill: Run is not running (status: {run_data['status']})")
            self._json_response({
                "success": False,
                "message": f"Run is not running (status: {run_data['status']})",
            })
            return
        try:
            # Kill entire process group (includes child processes)
            if process.poll() is None:  # Process is still running
                pgid = os.getpgid(process.pid)
                logger.debug(f"Killing process group {pgid} (SIGTERM)")
                os.killpg(pgid, signal.SIGTERM)
                # Wait a bit, then force kill if still alive
                time.sleep(0.5)
                if process.poll() is None:
                    logger.debug("Process still alive, force killing (SIGKILL)")
                    os.killpg(pgid, signal.SIGKILL)
                else:
                    logger.debug("Process terminated successfully")
            else:
                logger.debug(f"Process already terminated (exit code: {process.poll()})")
            # Release semaphore
            _run_semaphore.release()
            logger.debug("Released semaphore")

            with _run_lock:
                run_data["status"] = "killed"
                run_data["exit_code"] = -1
            logger.info(f"successfully killed run {run_id}")
            self._json_response({"success": True, "message": f"Killed run {run_id}"})
        except Exception as e:
            logger.error(f"❌ Failed to kill run: {str(e)}", exc_info=True)
            self._json_response({"success": False, "message": f"Failed to kill run: {str(e)}"})
    def _delete_run(self, run_id: str) -> None:
        """Delete a completed/failed run from registry."""
        global _active_runs
        if run_id not in _active_runs:
            self.send_error(404, f"Run not found: {run_id}")
            return
        run_data = _active_runs[run_id]
        # Don't delete running processes
        if run_data["status"] == "running":
            self._json_response({
                "success": False,
                "message": "Cannot delete running process. Kill it first.",
            })
            return
        # Remove from registry
        with _run_lock:
            del _active_runs[run_id]
        self._json_response({"success": True, "message": f"Deleted run {run_id}"})
    def _json_response(self, data: Any) -> None:
        """Send JSON response."""
        json_data = json.dumps(data, indent=2)
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(json_data)))
        self.end_headers()
        self.wfile.write(json_data.encode("utf-8"))
def main() -> None:
    """Run the dev loop server."""
    parser = argparse.ArgumentParser(
        description="Agent dev loop tool - config builder & trace viewer"
    )
    parser.add_argument(
        "--port", type=int, default=8080, help="Port to run server on (default: 8080)"
    )
    parser.add_argument(
        "--project",
        type=Path,
        default=Path.cwd(),
        help="Project root directory (default: current directory)",
    )
    parser.add_argument(
        "--no-browser", action="store_true", help="Don't automatically open browser"
    )
    args = parser.parse_args()

    DevLoopServer.project_root = args.project.resolve()
    server = HTTPServer(("localhost", args.port), DevLoopServer)
    url = f"http://localhost:{args.port}"
    print(f"\n{'=' * 60}")
    print("🚀 Agent Dev Loop Tool")
    print(f"{'=' * 60}")
    print(f"URL: {url}")
    print(f"Project: {DevLoopServer.project_root}")
    print(f"{'=' * 60}\n")
    # Open browser
    if not args.no_browser:
        webbrowser.open(url)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\n\n✅ Server stopped")
if __name__ == "__main__":
    main()
