"""Web-based dashboard application.""" from __future__ import annotations import json import mimetypes import os import posixpath import re import sys import time from http.server import HTTPServer, SimpleHTTPRequestHandler from pathlib import Path from typing import Optional from urllib.parse import unquote from ..core.scope import detect_scope from ..core.task import discover_tasks, TaskState, COLUMN_HEADERS from ..config import DashboardConfig, get_config_path # Task states for API responses - maps state names to artifact names TASK_STATE_ARTIFACT = { "research": "SPEC.md", "decomposition": "DECOMPOSITION.md", "design": "DESIGN.md", "test_design": "TEST_PLAN.md", "implement": "IMPLEMENTATION.md", "bug_find": "BUG_REPORT.md", "adv_bug_find": "ADVERSARIAL_BUG_REPORT.md", "doc_review": "DOC_REVIEW.md", "referee": "VERDICT.md", } TASK_STATES = list(TASK_STATE_ARTIFACT.keys()) MAX_POST_BODY = 65536 # 64KB MAX_REVIEW_COMMENT_LENGTH = 4096 CACHE_TTL = 1.0 # seconds CORS_HEADERS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, PUT, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", } _task_cache = {"tasks": [], "timestamp": 0.0} def _get_cached_tasks(project_root): now = time.time() if (now - _task_cache["timestamp"]) < CACHE_TTL and _task_cache["tasks"]: return _task_cache["tasks"] tasks_dir = DashboardHandler._find_tasks_dir(project_root) tasks = discover_tasks(tasks_dir) _task_cache["tasks"] = tasks _task_cache["timestamp"] = now return tasks def _invalidate_task_cache(): _task_cache["timestamp"] = 0.0 class DashboardHandler(SimpleHTTPRequestHandler): """HTTP handler that serves the dashboard files and task API data.""" dashboard_path = Path(__file__).resolve().parent.parent / "html" config: Optional[DashboardConfig] = None project_root: Optional[Path] = None scope: str = "none" def do_GET(self): if self.path == "/api/tasks": self._serve_tasks() elif self.path == "/api/config": self._serve_config() elif self.path == "/api/scope": self._serve_scope() elif self.path == "/api/project-name": self._serve_project_name() elif self.path == "/api/task/" or self.path.startswith("/api/task/"): task_name = unquote(self.path.split("/api/task/")[1]) if task_name.endswith("/review"): task_name = task_name[:-7] self._serve_task_review(task_name) else: self._serve_task(task_name) elif self.path == "/api/review-summary": self._serve_review_summary() else: self._serve_static() def do_POST(self): if self.path.startswith("/api/task/") and self.path.endswith("/review"): task_name = unquote(self.path.split("/api/task/")[1][:-7]) content_length = int(self.headers.get('Content-Length', 0)) if content_length > MAX_POST_BODY: self._send_error(413, "Payload too large") return self._handle_review(task_name) else: self._send_error(404, "Not found") def do_PUT(self): if self.path == "/api/config": self._handle_config_update() else: self._send_error(404, "Not found") def do_OPTIONS(self): self.send_response(200) self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS") self.send_header("Access-Control-Allow-Headers", "Content-Type") self.send_header("Access-Control-Max-Age", "86400") self.end_headers() def _serve_static(self): """Serve static files from the dashboard HTML directory.""" # Strip query string and fragment path = self.path.split('?', 1)[0] path = path.split('#', 1)[0] # Normalize path and strip leading / (pathlib treats absolute paths specially) path = posixpath.normpath(unquote(path)).lstrip('/') # Handle root path — serve index.html if path == '' or path == '.': path = 'index.html' # Build the full file path full_path = self.dashboard_path / path # Check if file exists and is within the dashboard directory try: resolved = full_path.resolve() base = self.dashboard_path.resolve() # Ensure the resolved path is inside the base directory resolved.relative_to(base) except (ValueError, RuntimeError, OSError): self._send_error(403, "Forbidden") return if not resolved.exists() or not resolved.is_file(): self._send_error(404, "File not found") return # Determine content type content_type, encoding = mimetypes.guess_type(str(resolved)) if not content_type: content_type = 'application/octet-stream' # Serve the file try: with open(resolved, 'rb') as f: data = f.read() self.send_response(200) self.send_header("Content-Type", content_type) self.send_header("Content-Length", len(data)) self.send_header("Cache-Control", "no-cache") self.end_headers() self.wfile.write(data) except Exception: self._send_error(500, "Internal error") @staticmethod def _find_tasks_dir(project_root: Path) -> Path: """Return the tasks directory path (may or may not exist on disk). Callers are responsible for checking existence or passing to ``discover_tasks()`` which returns ``[]`` for missing dirs. """ return project_root / ".automaton" / "tasks" def _serve_tasks(self): project_root = self.project_root if not project_root: tasks = [] else: tasks = _get_cached_tasks(project_root) tasks_data = [ { "name": t.name, "display_name": t.display_name, "state": t.state.value, "status_reason": t.status_reason, "artifacts": {s: TASK_STATE_ARTIFACT[s] in t.artifacts for s in TASK_STATES}, "sub_tasks": [ {"name": st.name, "has_verdict": st.has_verdict, "verdict_status": st.verdict_status} for st in t.sub_tasks ], "verdict_content": t.verdict_content, "bug_report_content": t.bug_report_content, "spec_content": t.spec_content, "decomposition_content": t.decomposition_content, "parent_spec_content": t.parent_spec_content, "vram_config_content": t.vram_config_content, "blocked_action_items": t.blocked_action_items, "unblock_instructions": t.unblock_instructions, "phase_guidance": t.phase_guidance, "required_artifact_name": t.required_artifact_name, "next_phase_name": t.next_phase_name, "is_edit_phase": t.is_edit_phase, "is_approval_gated": t.is_approval_gated, "blocker": t.blocker, "waves": [{"wave_number": w.wave_number, "label": w.label, "sub_task_names": w.sub_task_names} for w in t.waves], "review": self._get_review_status(t.name), } for t in tasks ] self._send_json({"tasks": tasks_data}) def _serve_config(self): if self.config is None: self._send_error(503, "Config not available") return self._send_json(self.config.to_dict()) def _handle_config_update(self): if self.config is None: self._send_error(503, "Config not available") return content_length = int(self.headers.get('Content-Length', 0)) if content_length > MAX_POST_BODY: self._send_error(413, "Payload too large") return try: body = self.rfile.read(content_length).decode() if content_length else "{}" data = json.loads(body) except (json.JSONDecodeError, UnicodeDecodeError): self._send_error(400, "Invalid JSON") return new_config = DashboardConfig.from_dict({**self.config.to_dict(), **data}) errors = new_config.validate() if errors: self._send_error(400, "; ".join(errors)) return project_root = self.project_root if not project_root: self._send_error(503, "Not in automaton project") return config_path = get_config_path(project_root) new_config.save(config_path) self.config = new_config self._send_json(new_config.to_dict()) def _serve_scope(self): self._send_json({"scope": self.scope, "project_root": str(self.project_root) if self.project_root else None}) def _serve_project_name(self): project_root = self.project_root if not project_root: self._send_json({"project_name": None}) return # Try .automaton/project-name.md first project_name_file = project_root / ".automaton" / "project-name.md" if project_name_file.exists(): try: project_name = project_name_file.read_text().strip() self._send_json({"project_name": project_name}) return except Exception: pass # Fall back to README.md first heading (in automaton root or .automaton dir) for readme_path in [project_root / "README.md", project_root / ".automaton" / "README.md"]: if readme_path.exists(): try: lines = readme_path.read_text().strip().split('\n') for line in lines: if line.startswith('# '): self._send_json({"project_name": line[2:].strip()}) return except Exception: pass # Fall back to directory name self._send_json({"project_name": project_root.name}) def _serve_task(self, task_name): project_root = self.project_root if not project_root: self._send_error(404, "Not in automaton project") return tasks = _get_cached_tasks(project_root) task = next((t for t in tasks if t.name == task_name), None) if not task: self._send_error(404, "Task not found") return task_data = { "name": task.name, "display_name": task.display_name, "state": task.state.value, "status_reason": task.status_reason, "artifacts": {s: TASK_STATE_ARTIFACT[s] in task.artifacts for s in TASK_STATES}, "sub_tasks": [ {"name": st.name, "has_verdict": st.has_verdict, "verdict_status": st.verdict_status} for st in task.sub_tasks ], "verdict_content": task.verdict_content, "bug_report_content": task.bug_report_content, "spec_content": task.spec_content, "decomposition_content": task.decomposition_content, "parent_spec_content": task.parent_spec_content, "vram_config_content": task.vram_config_content, "waves": [{"wave_number": w.wave_number, "label": w.label, "sub_task_names": w.sub_task_names} for w in task.waves], "review": self._get_review_status(task.name), } self._send_json(task_data) REVIEW_FILE = "REVIEW.md" @staticmethod def _validate_task_name(task_name: str) -> bool: if not task_name: return False # Allow kebab-case names with letters, digits, hyphens, and underscores. if not re.fullmatch(r"[A-Za-z0-9_-]+", task_name): return False return True def _get_review_path(self, task_name: str) -> Path | None: project_root = self.project_root if not project_root or not self._validate_task_name(task_name): return None return project_root / ".automaton" / "tasks" / task_name / self.REVIEW_FILE def _get_review_status(self, task_name: str) -> dict: review_path = self._get_review_path(task_name) if not review_path or not review_path.exists(): return {"status": "pending"} try: content = review_path.read_text().strip() status = "pending" timestamp = "" comment = "" for line in content.split('\n'): line = line.strip() if line.startswith("- **Status**"): status = line.split("**:", 1)[1].strip().rstrip() if "**: " in line else "pending" elif line.startswith("- **Timestamp**"): timestamp = line.split("**:", 1)[1].strip().rstrip() if "**: " in line else "" elif line.startswith("- **Comment**"): raw = line.split("**: ", 1) comment = raw[1] if len(raw) > 1 else "" return {"status": status, "timestamp": timestamp, "comment": comment} except Exception: return {"status": "pending"} def _write_review(self, task_name: str, status: str, comment: str = ""): review_path = self._get_review_path(task_name) if not review_path: return from datetime import datetime content = f"# Review\n- **Status**: {status}\n- **Timestamp**: {datetime.now().isoformat()}\n" content += f"- **Comment**: {comment.replace(chr(10), ' ').strip()}\n" review_path.parent.mkdir(parents=True, exist_ok=True) review_path.write_text(content) def _serve_task_review(self, task_name: str): review = self._get_review_status(task_name) self._send_json(review) def _handle_review(self, task_name: str): try: content_length = int(self.headers.get('Content-Length', 0)) if content_length > MAX_POST_BODY: self._send_error(413, "Payload too large") return body = self.rfile.read(content_length).decode() if content_length else "{}" data = json.loads(body) status = data.get("status", "pending") comment = data.get("comment", "")[:MAX_REVIEW_COMMENT_LENGTH] if status not in ("approved", "changes_requested", "pending"): self._send_error(400, "Invalid status. Use 'approved', 'changes_requested', or 'pending'.") return self._write_review(task_name, status, comment) _invalidate_task_cache() self._send_json({"success": True, "status": status}) except json.JSONDecodeError: self._send_error(400, "Invalid JSON") def _serve_review_summary(self): project_root = self.project_root if not project_root: self._send_json({"pending": 0, "approved": 0, "changes_requested": 0}) return tasks = _get_cached_tasks(project_root) counts = {"pending": 0, "approved": 0, "changes_requested": 0} for t in tasks: status = self._get_review_status(t.name).get("status", "pending") if status in counts: counts[status] += 1 else: counts["pending"] += 1 self._send_json(counts) def _send_json(self, data): self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Cache-Control", "no-cache") self.send_header("X-Content-Type-Options", "nosniff") for k, v in CORS_HEADERS.items(): self.send_header(k, v) self.end_headers() self.wfile.write(json.dumps(data).encode()) def _send_error(self, code, message): self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("X-Content-Type-Options", "nosniff") for k, v in CORS_HEADERS.items(): self.send_header(k, v) self.end_headers() self.wfile.write(json.dumps({"error": message}).encode()) def log_message(self, format, *args): pass class DashboardApp: """Main dashboard application.""" def __init__(self, start_path: Optional[Path] = None, host: str = "localhost", port: int = 8080): self.start_path = start_path or Path.cwd() self.host = host self.port = port self.project_root: Optional[Path] = None self.scope: str = "none" self.config: Optional[DashboardConfig] = None self._server: Optional[HTTPServer] = None def initialize(self) -> bool: project_root, scope = detect_scope(self.start_path) if scope == "none": print("Error: Not inside an automaton project.") print(" The dashboard must be run from a project root or ~/.automaton/") return False self.project_root = project_root self.scope = scope config_path = get_config_path(self.project_root) self.config = DashboardConfig.from_file(config_path) return True def run(self) -> None: DashboardHandler.config = self.config DashboardHandler.project_root = self.project_root DashboardHandler.scope = self.scope self._server = HTTPServer((self.host, self.port), DashboardHandler) scope_text = "Framework" if self.scope == "framework" else "Project" print(f"\n{'=' * 60}") print(f" Automaton Dashboard - {scope_text} Mode") print(f"{'=' * 60}") print(f" Open: http://{self.host}:{self.port}") print(f"{'=' * 60}\n") print("Press Ctrl+C to stop\n") try: self._server.serve_forever() except KeyboardInterrupt: print("\nDashboard stopped.") def stop(self) -> None: if self._server: self._server.shutdown()