Files
automaton/automaton/dashboard/ui/app.py
T
Lap Tran f32f98575b
CI / build (push) Has been cancelled
Make test suite runnable from clean checkout + cross-platform vram_detect
runnable-test-suite (parent) — complete. Three sub-tasks all complete:

- make-tests-runnable: add requirements.txt pinning pytest==7.4.4, sweep all
  docs/prompts from bare 'python' to 'python3' (stock macOS/Windows ships
  python3), add idempotent .venv install block to scripts/install.sh, and add
  'from __future__ import annotations' to 3 dashboard modules using PEP 604
  union syntax at definition time so they import on Python 3.9+. The
  PEP 604 bug was caught by the streak verifier itself during implementation.

- vram-detect-cross-platform: scripts/vram_detect.py now branches on
  platform.system() for Linux/Darwin/Windows. macOS path uses
  system_profiler SPDisplaysDataType (Apple Silicon unified memory via
  sysctl hw.memsize; Intel Macs via 'VRAM (Total):'). Windows uses wmic
  path win32_VideoController get AdapterRAM with PowerShell fallback. Linux
  /proc/meminfo and nvidia-smi/lspci paths unchanged (regression test locks
  them). Added 14 local-LLM context-window entries (llama-3.1, qwen2.5,
  mistral, deepseek-r1/v3, glm-4/4.5, gemma-2, phi-3/4) with source-cited
  model cards. Added _probe_ollama_model() that runs 'ollama list' as a
  last-resort fallback. run_command() now wraps PowerShell cmdlets on
  Windows (['powershell', '-NoProfile', '-NoLogo', '-Command', ...]).

- vram-detect-cross-platform-tests: 11 new monkeypatched tests in
  tests/test_vram_detect.py covering Linux/Darwin/Windows branches for
  detect_ram and detect_gpu_vram, prefix-match for unknown model names,
  ollama probe, Windows PowerShell wrapper, and a LOCKED regression test
  for _detect_ram_linux(). All external subprocess/sysctl/wmic calls are
  mocked; no live hardware probes. Suite total: 235 passed, 0 errors.

Verified on this box: gpu_vram_gb 0 -> 32 on Apple M5 (32GB unified memory),
target context correctly jumped 12k -> 42k.

Subtask-2 implementation was authored by local LLM (gemma-4-26B-A4B-it
via headroom proxy @ localhost:8787). The 10-consecutive-clean-pass streak
verifier ran as the independent checker model (article #2/#9/#13 in
'WTF Is a Loop? Part 2'). One anti-spin rail fired: local LLM produced
inline branches where subtask-3 tests expected private _detect_ram_linux()
helper; extracted helper to match the test contract without weakening tests.

Parent + all 3 subtasks complete. Prior opencode-subagent implementation
of subtask-2 preserved in git stash for reference.
2026-06-21 18:28:15 -04:00

457 lines
18 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
# 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()