CI / build (push) Has been cancelled
- 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
487 lines
19 KiB
Python
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()
|