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