Files
automaton/automaton/dashboard/ui/app.py
T
Lap Tran 4a2301b077
CI / build (push) Has been cancelled
Archive completed tasks, add cleanup commands, self-documenting dashboard UI
- Archive 79 completed framework-dev tasks from tasks/ -> tasks/complete/
- status.py: add --cleanup-done and --install-cleanup-schedule commands
- Add scripts/automaton-cleanup.sh for periodic task archiving
- Dashboard: rename 'Background' tab -> 'Agent', 'Cleanup' agent -> 'Completed Task Archiver', remove redundant group headers and pill badges, dim inactive agent placeholders
- .rules.md: add Self-Documenting UI Names rule
- New tests: test_cleanup_done.py, expanded test_app.py and test_task.py
2026-06-24 22:43:33 -04:00

487 lines
19 KiB
Python

"""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
_status_module = None
def _get_status_module():
"""Lazily import scripts/status.py so the dashboard can call
`_list_scheduled_jobs` without adding scripts/ to sys.path permanently."""
global _status_module
if _status_module is None:
import importlib.util
status_path = Path.home() / ".automaton" / "scripts" / "status.py"
if not status_path.exists():
return None
spec = importlib.util.spec_from_file_location("automaton_status", status_path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
_status_module = mod
return _status_module
# 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
# Security headers (same-origin only — no CORS wildcard)
SECURITY_HEADERS = {
"X-Content-Type-Options": "nosniff",
}
_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()
elif self.path == "/api/scheduled":
self._serve_scheduled()
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("X-Content-Type-Options", "nosniff")
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 _serve_scheduled(self):
"""Serve the list of installed scheduled jobs (loop ticks + cleanup)."""
status_mod = _get_status_module()
if status_mod is None:
self._send_json({"jobs": [], "available": False})
return
list_fn = getattr(status_mod, "_list_scheduled_jobs", None)
if list_fn is None:
self._send_json({"jobs": [], "available": False})
return
try:
project_arg = str(self.project_root) if self.project_root else None
jobs = list_fn(project=project_arg)
except Exception as exc:
self._send_json({"jobs": [], "available": True, "error": str(exc)})
return
self._send_json({"jobs": jobs, "available": True})
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")
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")
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()