492 lines
15 KiB
Python
492 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""Minimal Ralph dashboard for localhost monitoring."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
from datetime import datetime
|
|
from http import HTTPStatus
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
RALPH_ROOT = Path(__file__).resolve().parents[1]
|
|
STATE_DIR = RALPH_ROOT / "state"
|
|
RUNS_DIR = RALPH_ROOT / "runs"
|
|
|
|
|
|
def _read_json(path: Path, default: Any) -> Any:
|
|
try:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return default
|
|
|
|
|
|
def _read_text(path: Path) -> str:
|
|
try:
|
|
return path.read_text(encoding="utf-8")
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def _tail_jsonl(path: Path, limit: int = 80) -> List[Dict[str, Any]]:
|
|
if not path.exists():
|
|
return []
|
|
|
|
try:
|
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
except Exception:
|
|
return []
|
|
|
|
events: List[Dict[str, Any]] = []
|
|
for line in lines[-limit:]:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
events.append(json.loads(line))
|
|
except Exception:
|
|
continue
|
|
events.reverse()
|
|
return events
|
|
|
|
|
|
def _is_pid_running(pid: Any) -> bool:
|
|
try:
|
|
pid_value = int(pid)
|
|
except Exception:
|
|
return False
|
|
|
|
if pid_value <= 0:
|
|
return False
|
|
|
|
try:
|
|
os.kill(pid_value, 0)
|
|
except PermissionError:
|
|
return True
|
|
except OSError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _iso_to_local(value: Any) -> str:
|
|
if not value:
|
|
return ""
|
|
try:
|
|
return datetime.fromisoformat(str(value).replace("Z", "+00:00")).strftime("%Y-%m-%d %H:%M:%S")
|
|
except Exception:
|
|
return str(value)
|
|
|
|
|
|
def _collect_recent_runs(limit: int = 8) -> List[Dict[str, Any]]:
|
|
runs: List[Dict[str, Any]] = []
|
|
if not RUNS_DIR.exists():
|
|
return runs
|
|
|
|
for run_dir in sorted([p for p in RUNS_DIR.iterdir() if p.is_dir()], key=lambda item: item.name, reverse=True)[:limit]:
|
|
summary_path = run_dir / "SUMMARY.md"
|
|
final_status_path = run_dir / "final_status.txt"
|
|
summary_text = _read_text(summary_path).strip()
|
|
final_status_text = _read_text(final_status_path).strip()
|
|
changes_exists = (run_dir / "CHANGES.md").exists()
|
|
implementer_patch = (run_dir / "implementer.patch").exists()
|
|
final_patch = (run_dir / "final.patch").exists()
|
|
runs.append(
|
|
{
|
|
"run_id": run_dir.name,
|
|
"path": str(run_dir),
|
|
"updated_at": datetime.fromtimestamp(run_dir.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
|
|
"summary_excerpt": "\n".join(summary_text.splitlines()[:10]),
|
|
"final_status_excerpt": "\n".join(final_status_text.splitlines()[:8]),
|
|
"changes_exists": changes_exists,
|
|
"implementer_patch": implementer_patch,
|
|
"final_patch": final_patch,
|
|
}
|
|
)
|
|
return runs
|
|
|
|
|
|
def build_dashboard() -> Dict[str, Any]:
|
|
current_run = _read_json(STATE_DIR / "current_run.json", {})
|
|
background = _read_json(STATE_DIR / "last_background_run.json", {})
|
|
background["alive"] = _is_pid_running(background.get("pid"))
|
|
|
|
return {
|
|
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
"current_run": current_run,
|
|
"background": background,
|
|
"events": _tail_jsonl(STATE_DIR / "events.jsonl", limit=120),
|
|
"recent_runs": _collect_recent_runs(),
|
|
"provider_smoke": _read_json(STATE_DIR / "provider_smoke.json", {}),
|
|
}
|
|
|
|
|
|
HTML_TEMPLATE = """<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Ralph Dashboard</title>
|
|
<style>
|
|
:root {
|
|
--bg: #f3efe5;
|
|
--ink: #1a1a1a;
|
|
--muted: #5f5a52;
|
|
--card: #fffaf2;
|
|
--line: #d8cfbf;
|
|
--ok: #2a7f62;
|
|
--warn: #a96814;
|
|
--bad: #ad2e24;
|
|
--run: #144c7d;
|
|
--shadow: rgba(0, 0, 0, 0.08);
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
|
background: linear-gradient(180deg, #efe7d8 0%, var(--bg) 100%);
|
|
color: var(--ink);
|
|
}
|
|
header {
|
|
padding: 20px 24px;
|
|
background: #17324d;
|
|
color: #fff8eb;
|
|
border-bottom: 4px solid #c98b2b;
|
|
}
|
|
header h1 { margin: 0 0 6px; font-size: 28px; }
|
|
header p { margin: 0; color: #d7e4ef; }
|
|
main { padding: 20px 24px 32px; }
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
gap: 16px;
|
|
margin-bottom: 18px;
|
|
}
|
|
.card {
|
|
background: var(--card);
|
|
border: 1px solid var(--line);
|
|
border-radius: 14px;
|
|
padding: 16px;
|
|
box-shadow: 0 8px 24px var(--shadow);
|
|
}
|
|
.card h2 {
|
|
margin: 0 0 10px;
|
|
font-size: 18px;
|
|
}
|
|
.muted { color: var(--muted); }
|
|
.badge {
|
|
display: inline-block;
|
|
padding: 4px 9px;
|
|
border-radius: 999px;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
background: #ece4d6;
|
|
color: var(--ink);
|
|
}
|
|
.status-completed, .status-success, .status-running { color: var(--ok); }
|
|
.status-failed, .status-error { color: var(--bad); }
|
|
.status-pending, .status-warning { color: var(--warn); }
|
|
.agents { display: grid; gap: 10px; }
|
|
.agent {
|
|
border: 1px solid var(--line);
|
|
border-radius: 10px;
|
|
padding: 10px 12px;
|
|
background: #fffdf8;
|
|
}
|
|
.agent strong { display: block; margin-bottom: 4px; }
|
|
.timeline {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: grid;
|
|
gap: 10px;
|
|
max-height: 540px;
|
|
overflow: auto;
|
|
}
|
|
.timeline li {
|
|
border-left: 4px solid #c98b2b;
|
|
padding: 8px 10px 8px 12px;
|
|
background: #fffdf8;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--line);
|
|
}
|
|
.run-list {
|
|
display: grid;
|
|
gap: 12px;
|
|
}
|
|
.run-item {
|
|
border: 1px solid var(--line);
|
|
border-radius: 12px;
|
|
padding: 12px;
|
|
background: #fffdf8;
|
|
}
|
|
pre {
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
background: #f6f1e8;
|
|
border: 1px solid var(--line);
|
|
border-radius: 10px;
|
|
padding: 12px;
|
|
margin: 8px 0 0;
|
|
font-size: 12px;
|
|
}
|
|
.empty {
|
|
padding: 16px;
|
|
border: 1px dashed var(--line);
|
|
border-radius: 10px;
|
|
color: var(--muted);
|
|
background: #faf6ef;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>Ralph Dashboard</h1>
|
|
<p>Simple local view of who is working, what stage the swarm is in, and what happened last.</p>
|
|
</header>
|
|
<main>
|
|
<div class="grid">
|
|
<section class="card">
|
|
<h2>Overview</h2>
|
|
<div id="overview" class="muted">Loading...</div>
|
|
</section>
|
|
<section class="card">
|
|
<h2>Background Runner</h2>
|
|
<div id="background" class="muted">Loading...</div>
|
|
</section>
|
|
<section class="card">
|
|
<h2>Provider Smoke</h2>
|
|
<div id="providers" class="muted">Loading...</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div class="grid">
|
|
<section class="card">
|
|
<h2>Current Run</h2>
|
|
<div id="current-run" class="muted">Loading...</div>
|
|
</section>
|
|
<section class="card">
|
|
<h2>Agent Status</h2>
|
|
<div id="agents" class="agents"></div>
|
|
</section>
|
|
</div>
|
|
|
|
<div class="grid">
|
|
<section class="card">
|
|
<h2>Timeline</h2>
|
|
<ul id="timeline" class="timeline"></ul>
|
|
</section>
|
|
<section class="card">
|
|
<h2>Recent Runs</h2>
|
|
<div id="recent-runs" class="run-list"></div>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
<script>
|
|
function statusBadge(value) {
|
|
const text = value || "unknown";
|
|
const cls = "badge status-" + String(text).toLowerCase();
|
|
return `<span class="${cls}">${text}</span>`;
|
|
}
|
|
|
|
function renderAgent(name, data) {
|
|
if (!data) return "";
|
|
const started = data.started_at ? new Date(data.started_at).toLocaleString() : "";
|
|
const finished = data.finished_at ? new Date(data.finished_at).toLocaleString() : "";
|
|
const output = data.output_file ? `<div class="muted">${data.output_file}</div>` : "";
|
|
return `
|
|
<div class="agent">
|
|
<strong>${name}</strong>
|
|
<div>${statusBadge(data.status)}</div>
|
|
${started ? `<div class="muted">Start: ${started}</div>` : ""}
|
|
${finished ? `<div class="muted">End: ${finished}</div>` : ""}
|
|
${output}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderOverview(data) {
|
|
const run = data.current_run || {};
|
|
if (!run.run_id) {
|
|
return `<div class="empty">No current run state has been written yet.</div>`;
|
|
}
|
|
return `
|
|
<div><strong>Run ID:</strong> ${run.run_id}</div>
|
|
<div><strong>Stage:</strong> ${statusBadge(run.stage)}</div>
|
|
<div><strong>Status:</strong> ${statusBadge(run.status)}</div>
|
|
<div><strong>Message:</strong> ${run.latest_message || "-"}</div>
|
|
<div class="muted">Updated: ${data.generated_at}</div>
|
|
`;
|
|
}
|
|
|
|
function renderBackground(data) {
|
|
const bg = data.background || {};
|
|
if (!bg.pid) {
|
|
return `<div class="empty">No background launch metadata found.</div>`;
|
|
}
|
|
return `
|
|
<div><strong>PID:</strong> ${bg.pid}</div>
|
|
<div><strong>Alive:</strong> ${statusBadge(bg.alive ? "running" : "stopped")}</div>
|
|
<div><strong>Run label:</strong> ${bg.run_label || "-"}</div>
|
|
<div class="muted">${bg.stdout_log || ""}</div>
|
|
<div class="muted">${bg.stderr_log || ""}</div>
|
|
`;
|
|
}
|
|
|
|
function renderProviders(data) {
|
|
const smoke = data.provider_smoke || {};
|
|
const checks = smoke.checks || [];
|
|
if (!checks.length) {
|
|
return `<div class="empty">No provider smoke data found yet.</div>`;
|
|
}
|
|
return checks.map((item) => `
|
|
<div class="agent">
|
|
<strong>${item.provider || "provider"}</strong>
|
|
<div>${statusBadge(item.status || "unknown")}</div>
|
|
<div class="muted">${item.model || ""}</div>
|
|
</div>
|
|
`).join("");
|
|
}
|
|
|
|
function renderCurrentRun(data) {
|
|
const run = data.current_run || {};
|
|
if (!run.run_id) {
|
|
return `<div class="empty">No active or recent run state.</div>`;
|
|
}
|
|
return `
|
|
<div><strong>Run:</strong> ${run.run_id}</div>
|
|
<div><strong>Task dir:</strong> ${run.task_directory || "-"}</div>
|
|
<div><strong>Worktree:</strong> ${run.worktree || "-"}</div>
|
|
<div><strong>Started:</strong> ${run.started_at ? new Date(run.started_at).toLocaleString() : "-"}</div>
|
|
<div><strong>Finished:</strong> ${run.finished_at ? new Date(run.finished_at).toLocaleString() : "-"}</div>
|
|
${(run.errors || []).length ? `<pre>${(run.errors || []).join("\\n")}</pre>` : ""}
|
|
`;
|
|
}
|
|
|
|
function renderAgents(data) {
|
|
const run = data.current_run || {};
|
|
const html = [];
|
|
if (run.implementer) {
|
|
html.push(renderAgent("Implementer: " + (run.implementer.name || "unknown"), run.implementer));
|
|
}
|
|
(run.reviewers || []).forEach((reviewer) => {
|
|
html.push(renderAgent("Reviewer: " + (reviewer.name || "unknown"), reviewer));
|
|
});
|
|
if (run.codex_master) {
|
|
html.push(renderAgent("Codex Master", run.codex_master));
|
|
}
|
|
if (run.fix_pass) {
|
|
html.push(renderAgent("Fix Pass", run.fix_pass));
|
|
}
|
|
document.getElementById("agents").innerHTML = html.length ? html.join("") : `<div class="empty">No agent state yet.</div>`;
|
|
}
|
|
|
|
function renderTimeline(data) {
|
|
const events = data.events || [];
|
|
const target = document.getElementById("timeline");
|
|
if (!events.length) {
|
|
target.innerHTML = `<li class="empty">No events yet.</li>`;
|
|
return;
|
|
}
|
|
target.innerHTML = events.map((event) => `
|
|
<li>
|
|
<div><strong>${event.actor || "system"}</strong> ${statusBadge(event.status || "unknown")}</div>
|
|
<div>${event.message || "-"}</div>
|
|
<div class="muted">${event.stage || "-"} · ${new Date(event.timestamp).toLocaleString()}</div>
|
|
</li>
|
|
`).join("");
|
|
}
|
|
|
|
function renderRecentRuns(data) {
|
|
const runs = data.recent_runs || [];
|
|
const target = document.getElementById("recent-runs");
|
|
if (!runs.length) {
|
|
target.innerHTML = `<div class="empty">No run folders found.</div>`;
|
|
return;
|
|
}
|
|
target.innerHTML = runs.map((run) => `
|
|
<div class="run-item">
|
|
<div><strong>${run.run_id}</strong></div>
|
|
<div class="muted">${run.updated_at}</div>
|
|
<div>${run.changes_exists ? statusBadge("changes") : statusBadge("no changes")}</div>
|
|
<div>${run.final_patch ? statusBadge("final patch") : statusBadge("no final patch")}</div>
|
|
${run.final_status_excerpt ? `<pre>${run.final_status_excerpt}</pre>` : ""}
|
|
${run.summary_excerpt ? `<pre>${run.summary_excerpt}</pre>` : ""}
|
|
</div>
|
|
`).join("");
|
|
}
|
|
|
|
async function refresh() {
|
|
const response = await fetch("/api/dashboard", { cache: "no-store" });
|
|
const data = await response.json();
|
|
document.getElementById("overview").innerHTML = renderOverview(data);
|
|
document.getElementById("background").innerHTML = renderBackground(data);
|
|
document.getElementById("providers").innerHTML = renderProviders(data);
|
|
document.getElementById("current-run").innerHTML = renderCurrentRun(data);
|
|
renderAgents(data);
|
|
renderTimeline(data);
|
|
renderRecentRuns(data);
|
|
}
|
|
|
|
refresh();
|
|
setInterval(refresh, 2000);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
class RalphHandler(BaseHTTPRequestHandler):
|
|
def _send_json(self, payload: Dict[str, Any]) -> None:
|
|
body = json.dumps(payload, indent=2).encode("utf-8")
|
|
self.send_response(HTTPStatus.OK)
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _send_html(self, payload: str) -> None:
|
|
body = payload.encode("utf-8")
|
|
self.send_response(HTTPStatus.OK)
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def do_GET(self) -> None:
|
|
route = urlparse(self.path).path
|
|
if route == "/api/dashboard":
|
|
self._send_json(build_dashboard())
|
|
return
|
|
if route == "/" or route == "/index.html":
|
|
self._send_html(HTML_TEMPLATE)
|
|
return
|
|
|
|
self.send_error(HTTPStatus.NOT_FOUND, "Not found")
|
|
|
|
def log_message(self, fmt: str, *args: Any) -> None:
|
|
return
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Run the Ralph localhost dashboard.")
|
|
parser.add_argument("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)")
|
|
parser.add_argument("--port", type=int, default=8765, help="Bind port (default: 8765)")
|
|
args = parser.parse_args()
|
|
|
|
server = ThreadingHTTPServer((args.host, args.port), RalphHandler)
|
|
print(f"Ralph dashboard listening on http://{args.host}:{args.port}")
|
|
server.serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|