feat(dashboard): SSE realtime + story rollback button

Replaces 5s polling on /echo/ralph.html with EventSource streaming and adds
a rollback control for the running Ralph cards.

Server (dashboard/handlers/ralph.py):
- /api/ralph/stream — Server-Sent Events. Emits `event: status` whenever a
  signature over the projects' state changes (poll filesystem at 2s); emits
  `event: heartbeat` every 30s to keep proxies happy. Disables proxy
  buffering via X-Accel-Buffering:no.
- /api/ralph/<slug>/rollback (POST) — runs `git revert --no-edit HEAD` in
  the project; falls back to `git reset --hard HEAD~1` only if revert
  reports conflict. After rolling back the commit, decrements `passes` on
  the last user story marked complete in prd.json (atomic temp+rename
  write, same pattern as ralph_dag.py). Returns
  `{success, message, reverted_commit, story_reverted, method}`.
- _ralph_validate_slug tightened to a strict regex (alphanum + dash +
  underscore, ≤64 chars) plus explicit ../, /, \ rejection. All previously
  accepted slugs still pass; URL-encoded traversal and shell metachars
  now blocked before the filesystem is touched.
- _ralph_collect_status / _ralph_signature factored out of
  handle_ralph_status so the SSE loop can reuse them and detect changes
  cheaply.

Server (dashboard/api.py):
- HTTPServer → ThreadingHTTPServer with daemon_threads=True. SSE is a
  long-lived response; without threading a single client would block all
  other dashboard endpoints.
- /api/ralph/stream (GET) and /api/ralph/<slug>/rollback (POST) wired
  into the dispatch.

Client (dashboard/ralph.html):
- EventSource('/api/ralph/stream') with permanent fallback to 5s polling
  when readyState=CLOSED (no server, CORS blocked, browser without SSE).
- Indicator badge: 🟢 Live (SSE), ⏱ Polling (fallback), Offline.
- Rollback button (undo-2 icon) on running cards; native confirm() with
  message: "Asta va da git revert HEAD pe <slug> și va decrementa ultima
  story trecută. Continui?"

Tests (tests/test_dashboard_ralph_endpoint.py, +20 cases):
- Strict slug validator: underscore allowed, >64 rejected, special chars
  / backslash / URL-encoded traversal rejected.
- _ralph_collect_status + _ralph_signature: stable when nothing changes,
  flips when project added or `passes` toggles.
- Rollback: invalid slug → 400, non-git project → 400, real two-commit
  repo revert succeeds and decrements last passing story (US-002 goes
  passes:false while US-001 stays passes:true), no-passing-stories case
  succeeds with story_reverted=None, response shape contract, atomic
  helper leaves no .tmp file behind.
- API routing smoke: confirms ThreadingHTTPServer + stream + rollback
  references present in dashboard/api.py.

39/39 tests pass on tests/test_dashboard_ralph_endpoint.py. Pre-existing
failures in test_dashboard_constants.py::test_base_dir_is_echo_core (the
worktree dir is `echo-core-realtime`, not `echo-core`) and
test_dashboard_unified_index.py::test_index_has_all_panels are unrelated
to this change and reproduced on master.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 19:07:13 +00:00
parent dedeedf024
commit ff9b9a0d1d
4 changed files with 647 additions and 39 deletions

View File

@@ -7,7 +7,7 @@ server bootstrap.
""" """
import json import json
import sys import sys
from http.server import HTTPServer, SimpleHTTPRequestHandler from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
# Make dashboard/ importable for the handler submodules (constants, # Make dashboard/ importable for the handler submodules (constants,
@@ -159,6 +159,8 @@ class TaskBoardHandler(
self.handle_eco_doctor() self.handle_eco_doctor()
elif self.path == '/api/ralph/status' or self.path.startswith('/api/ralph/status?'): elif self.path == '/api/ralph/status' or self.path.startswith('/api/ralph/status?'):
self.handle_ralph_status() self.handle_ralph_status()
elif self.path == '/api/ralph/stream' or self.path.startswith('/api/ralph/stream?'):
self.handle_ralph_stream()
elif self.path.startswith('/api/ralph/'): elif self.path.startswith('/api/ralph/'):
# /api/ralph/<slug>/log or /api/ralph/<slug>/prd # /api/ralph/<slug>/log or /api/ralph/<slug>/prd
parts = self.path.split('?', 1)[0].split('/') parts = self.path.split('?', 1)[0].split('/')
@@ -233,11 +235,18 @@ class TaskBoardHandler(
self.handle_eco_git_commit() self.handle_eco_git_commit()
elif self.path == '/api/eco/restart-taskboard': elif self.path == '/api/eco/restart-taskboard':
self.handle_eco_restart_taskboard() self.handle_eco_restart_taskboard()
elif self.path.startswith('/api/ralph/') and self.path.endswith('/stop'): elif self.path.startswith('/api/ralph/'):
# /api/ralph/<slug>/{stop,rollback}
parts = self.path.split('?', 1)[0].split('/') parts = self.path.split('?', 1)[0].split('/')
if len(parts) >= 5: if len(parts) >= 5:
slug = parts[3] slug = parts[3]
self.handle_ralph_stop(slug) action = parts[4]
if action == 'stop':
self.handle_ralph_stop(slug)
elif action == 'rollback':
self.handle_ralph_rollback(slug)
else:
self.send_error(404)
else: else:
self.send_error(404) self.send_error(404)
else: else:
@@ -264,5 +273,8 @@ if __name__ == '__main__':
os.chdir(KANBAN_DIR) os.chdir(KANBAN_DIR)
print(f"Starting Echo Task Board API on port {port}") print(f"Starting Echo Task Board API on port {port}")
httpd = HTTPServer(('0.0.0.0', port), TaskBoardHandler) # ThreadingHTTPServer permite SSE long-lived (/api/ralph/stream) fără să
# blocheze celelalte request-uri.
httpd = ThreadingHTTPServer(('0.0.0.0', port), TaskBoardHandler)
httpd.daemon_threads = True
httpd.serve_forever() httpd.serve_forever()

View File

@@ -1,13 +1,16 @@
"""Ralph live dashboard endpoints (W3). """Ralph live dashboard endpoints (W3 + dashboard-realtime).
Endpoints: Endpoints:
GET /api/ralph/status — toate proiectele Ralph (cards data) GET /api/ralph/status — toate proiectele Ralph (cards data)
GET /api/ralph/stream — Server-Sent Events stream (realtime)
GET /api/ralph/<slug>/log — tail progress.txt (default 100 lines) GET /api/ralph/<slug>/log — tail progress.txt (default 100 lines)
GET /api/ralph/<slug>/prd — full prd.json content GET /api/ralph/<slug>/prd — full prd.json content
POST /api/ralph/<slug>/stop — SIGTERM la Ralph PID POST /api/ralph/<slug>/stop — SIGTERM la Ralph PID
POST /api/ralph/<slug>/rollback — git revert HEAD + decrement last passing story
Polling: 5s din ralph.html (suficient pentru iter 8-15min Ralph). SSE detail: stream emite `event: status\\ndata: <json>\\n\\n` la schimbări (poll
NU SSE/WebSocket pentru MVP. fişiere la 2s); heartbeat la 30s pentru ca clientul să nu reseze conexiunea.
Necesită ThreadingHTTPServer în api.py — altfel un singur stream blochează tot.
Citește status din `~/workspace/<slug>/scripts/ralph/`: Citește status din `~/workspace/<slug>/scripts/ralph/`:
- prd.json → stories (passes/failed/blocked/retries) - prd.json → stories (passes/failed/blocked/retries)
@@ -19,14 +22,20 @@ Reuse path constants din `dashboard/constants.py` (WORKSPACE_DIR).
""" """
import json import json
import os import os
import re
import signal import signal
import subprocess
import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from urllib.parse import unquote
import constants import constants
# Slug strict: alphanum + dash + underscore, max 64 chars. Reject path traversal explicit.
_SLUG_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
# Path Ralph per proiect (mereu în scripts/ralph/) # Path Ralph per proiect (mereu în scripts/ralph/)
def _ralph_dir(project_dir: Path) -> Path: def _ralph_dir(project_dir: Path) -> Path:
return project_dir / "scripts" / "ralph" return project_dir / "scripts" / "ralph"
@@ -41,10 +50,20 @@ class RalphHandlers:
# ── helpers ──────────────────────────────────────────────── # ── helpers ────────────────────────────────────────────────
def _ralph_validate_slug(self, slug: str): def _ralph_validate_slug(self, slug: str):
"""Validează slug-ul + returnează project_dir sau None.""" """Validează slug-ul + returnează project_dir sau None.
if not slug or "/" in slug or ".." in slug:
Strict: alphanum + dash + underscore, ≤64 chars. Path traversal sequences
(`..`, `/`, `\\`) sau caractere ne-alfanumerice sunt respinse înainte de
orice atingere a filesystem-ului.
"""
if not slug:
return None
# Defense-in-depth: explicit path-traversal/separator reject (regex îl
# acoperă, dar îl ţinem explicit ca safety net dacă regex-ul se relaxează).
if ".." in slug or "/" in slug or "\\" in slug:
return None
if not _SLUG_RE.match(slug):
return None return None
slug = unquote(slug)
project_dir = constants.WORKSPACE_DIR / slug project_dir = constants.WORKSPACE_DIR / slug
try: try:
resolved = project_dir.resolve() resolved = project_dir.resolve()
@@ -174,30 +193,121 @@ class RalphHandlers:
], ],
} }
# ── /api/ralph/status (GET) ──────────────────────────────── def _ralph_collect_status(self) -> dict:
def handle_ralph_status(self): """Construieşte payload-ul de status pentru toate proiectele.
"""Întoarce status pentru toate proiectele Ralph din workspace."""
try:
projects = []
if not constants.WORKSPACE_DIR.exists():
self.send_json({"projects": [], "fetchedAt": datetime.now().isoformat()})
return
Folosit de `/api/ralph/status` (GET single-shot) şi de `/api/ralph/stream`
(SSE — emis la schimbări).
"""
projects: list[dict] = []
if constants.WORKSPACE_DIR.exists():
for entry in sorted(constants.WORKSPACE_DIR.iterdir()): for entry in sorted(constants.WORKSPACE_DIR.iterdir()):
if not entry.is_dir() or entry.name.startswith("."): if not entry.is_dir() or entry.name.startswith("."):
continue continue
summary = self._ralph_summarize_project(entry) summary = self._ralph_summarize_project(entry)
if summary is not None: if summary is not None:
projects.append(summary) projects.append(summary)
return {
"projects": projects,
"fetchedAt": datetime.now().isoformat(),
"count": len(projects),
}
self.send_json({ def _ralph_signature(self, snapshot: dict) -> tuple:
"projects": projects, """Compactă semnătură pentru change-detection în SSE — doar fields care
"fetchedAt": datetime.now().isoformat(), contează pentru UI (status, counts, current story). Timestamps de iter
"count": len(projects), au granularitate de second pentru a evita flicker pe nanosecond drift.
}) """
sig: list[tuple] = []
for p in snapshot.get("projects", []) or []:
cs = p.get("currentStory") or {}
sig.append((
p.get("slug"),
p.get("status"),
bool(p.get("running")),
p.get("storiesTotal"),
p.get("storiesComplete"),
p.get("storiesFailed"),
p.get("storiesBlocked"),
p.get("lastIterAt"),
cs.get("id"),
cs.get("retries"),
))
return tuple(sorted(sig, key=lambda t: t[0] or ""))
# ── /api/ralph/status (GET) ────────────────────────────────
def handle_ralph_status(self):
"""Întoarce status pentru toate proiectele Ralph din workspace."""
try:
self.send_json(self._ralph_collect_status())
except Exception as exc: except Exception as exc:
self.send_json({"error": str(exc)}, 500) self.send_json({"error": str(exc)}, 500)
# ── /api/ralph/stream (GET, SSE) ───────────────────────────
def handle_ralph_stream(self):
"""Server-Sent Events: emite snapshot la schimbări (poll fişiere 2s).
Heartbeat la 30s pentru a evita timeout pe proxy-uri. Loop-ul iese
curat la BrokenPipe (clientul închis tab-ul). Necesită
ThreadingHTTPServer în api.py — altfel blochează toate request-urile.
"""
try:
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "keep-alive")
# Disable proxy buffering (nginx/cloudflare) — flush imediat
self.send_header("X-Accel-Buffering", "no")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
except (BrokenPipeError, ConnectionResetError):
return
last_signature: tuple | None = None
last_heartbeat = time.monotonic()
# Initial snapshot — clientul nu aşteaptă primul change
try:
snapshot = self._ralph_collect_status()
last_signature = self._ralph_signature(snapshot)
payload = json.dumps(snapshot).encode("utf-8")
self.wfile.write(b"event: status\ndata: " + payload + b"\n\n")
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError):
return
except Exception as exc:
try:
err = json.dumps({"error": str(exc)}).encode("utf-8")
self.wfile.write(b"event: error\ndata: " + err + b"\n\n")
self.wfile.flush()
except Exception:
pass
return
# Stream loop
while True:
try:
time.sleep(2)
snapshot = self._ralph_collect_status()
signature = self._ralph_signature(snapshot)
now = time.monotonic()
if signature != last_signature:
payload = json.dumps(snapshot).encode("utf-8")
self.wfile.write(b"event: status\ndata: " + payload + b"\n\n")
self.wfile.flush()
last_signature = signature
last_heartbeat = now
elif now - last_heartbeat >= 30:
self.wfile.write(b"event: heartbeat\ndata: {}\n\n")
self.wfile.flush()
last_heartbeat = now
except (BrokenPipeError, ConnectionResetError):
return
except Exception:
# Best-effort: o iteraţie eşuată nu trebuie să termine stream-ul,
# dar dacă socketul e mort BrokenPipe va prinde next loop.
continue
# ── /api/ralph/<slug>/log (GET) ──────────────────────────── # ── /api/ralph/<slug>/log (GET) ────────────────────────────
def handle_ralph_log(self, slug: str): def handle_ralph_log(self, slug: str):
"""Tail progress.txt pentru un slug. Default last 100 lines.""" """Tail progress.txt pentru un slug. Default last 100 lines."""
@@ -303,3 +413,147 @@ class RalphHandlers:
self.send_json({"success": True, "message": f"Ralph stopped (PID {pid})"}) self.send_json({"success": True, "message": f"Ralph stopped (PID {pid})"})
except Exception as exc: except Exception as exc:
self.send_json({"success": False, "error": str(exc)}, 500) self.send_json({"success": False, "error": str(exc)}, 500)
# ── /api/ralph/<slug>/rollback (POST) ──────────────────────
def _ralph_decrement_last_pass(self, project_dir: Path) -> str | None:
"""Marchează ultima story `passes=True` (din ordinea din prd.json) ca
incompletă (`passes=False`, şterge `failed`/`blocked`/`failureReason`,
retries=0). Atomic write (temp + rename). Întoarce id-ul story-ului
sau None dacă nu există nimic de decrementat / prd.json invalid.
"""
prd_path = _ralph_dir(project_dir) / "prd.json"
if not prd_path.exists():
return None
try:
prd = json.loads(prd_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return None
stories = prd.get("userStories", []) or []
target_idx: int | None = None
# ultima poziţională cu passes=True (DAG-order = ordine de finalizare)
for i in range(len(stories) - 1, -1, -1):
if stories[i].get("passes"):
target_idx = i
break
if target_idx is None:
return None
story_id = stories[target_idx].get("id")
stories[target_idx]["passes"] = False
# Reset stare derivată — story-ul e disponibil pentru re-run
stories[target_idx].pop("failed", None)
stories[target_idx].pop("blocked", None)
stories[target_idx].pop("failureReason", None)
stories[target_idx]["retries"] = 0
# Atomic write (acelaşi pattern ca W3 ralph_dag.py)
tmp = prd_path.with_suffix(".json.tmp")
try:
tmp.write_text(json.dumps(prd, indent=2), encoding="utf-8")
tmp.replace(prd_path)
except OSError:
tmp.unlink(missing_ok=True)
return None
return story_id
def handle_ralph_rollback(self, slug: str):
"""Rollback ultimul commit într-un proiect Ralph.
Strategy: `git revert --no-edit HEAD` (history-preserving). Fallback la
`git reset --hard HEAD~1` doar dacă revert eşuează (conflict, binary
file). După succes, decrementează `passes` pe ultima story marcată
complete în prd.json (atomic write).
Returns: `{success, message, reverted_commit, story_reverted, method}`.
"""
try:
project_dir = self._ralph_validate_slug(slug)
if not project_dir:
self.send_json({
"success": False,
"message": "Invalid project slug",
"reverted_commit": None,
"story_reverted": None,
}, 400)
return
git_dir = project_dir / ".git"
if not git_dir.exists():
self.send_json({
"success": False,
"message": "Not a git repository",
"reverted_commit": None,
"story_reverted": None,
}, 400)
return
# Read HEAD before any operation (raportăm SHA-ul afectat)
head_proc = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=str(project_dir), capture_output=True, text=True, timeout=10,
)
if head_proc.returncode != 0:
self.send_json({
"success": False,
"message": f"git rev-parse HEAD failed: {head_proc.stderr.strip()}",
"reverted_commit": None,
"story_reverted": None,
}, 500)
return
commit_to_revert = head_proc.stdout.strip()
# Try revert (preserves history, recommended)
method = "revert"
revert = subprocess.run(
["git", "revert", "--no-edit", "HEAD"],
cwd=str(project_dir), capture_output=True, text=True, timeout=30,
)
if revert.returncode != 0:
# Conflict / binary file — abort & fall back to reset --hard
subprocess.run(
["git", "revert", "--abort"],
cwd=str(project_dir), capture_output=True, timeout=10,
)
reset = subprocess.run(
["git", "reset", "--hard", "HEAD~1"],
cwd=str(project_dir), capture_output=True, text=True, timeout=30,
)
if reset.returncode != 0:
self.send_json({
"success": False,
"message": (
f"revert failed ({revert.stderr.strip()[:200]}), "
f"reset failed ({reset.stderr.strip()[:200]})"
),
"reverted_commit": commit_to_revert,
"story_reverted": None,
}, 500)
return
method = "reset"
# Best-effort: decrement story passes (nu fail dacă lipseşte prd.json)
story_reverted = self._ralph_decrement_last_pass(project_dir)
short_sha = commit_to_revert[:8]
msg_bits = [f"Rolled back {short_sha} via git {method}"]
if story_reverted:
msg_bits.append(f"story {story_reverted} marked incomplete")
self.send_json({
"success": True,
"message": "; ".join(msg_bits),
"reverted_commit": commit_to_revert,
"story_reverted": story_reverted,
"method": method,
})
except subprocess.TimeoutExpired:
self.send_json({
"success": False,
"message": "git operation timed out",
"reverted_commit": None,
"story_reverted": None,
}, 500)
except Exception as exc:
self.send_json({
"success": False,
"message": str(exc),
"reverted_commit": None,
"story_reverted": None,
}, 500)

View File

@@ -72,6 +72,19 @@
animation: pulse 2s ease-in-out infinite; animation: pulse 2s ease-in-out infinite;
} }
/* Indicator state: live (SSE) vs polling (fallback) vs offline */
.live-indicator[data-mode="polling"] .live-dot {
background: var(--status-blocked);
animation: none;
}
.live-indicator[data-mode="offline"] .live-dot {
background: var(--status-failed);
animation: none;
}
.live-indicator[data-mode="connecting"] .live-dot {
background: var(--text-muted);
}
@keyframes pulse { @keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); } 0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.2); } 50% { opacity: 0.5; transform: scale(1.2); }
@@ -378,11 +391,11 @@
<i data-lucide="bot" aria-hidden="true"></i> <i data-lucide="bot" aria-hidden="true"></i>
Echo · Ralph Echo · Ralph
</div> </div>
<div class="page-subtitle">Live status pe proiectele autonome (polling 5s)</div> <div class="page-subtitle">Live status pe proiectele autonome</div>
</div> </div>
<div class="live-indicator" aria-live="polite"> <div class="live-indicator" aria-live="polite" id="liveIndicator" data-mode="connecting">
<span class="live-dot" aria-hidden="true"></span> <span class="live-dot" aria-hidden="true"></span>
<span id="liveLabel">Live</span> <span id="liveLabel">Conectare…</span>
<span class="last-fetch" id="lastFetch"></span> <span class="last-fetch" id="lastFetch"></span>
</div> </div>
</header> </header>
@@ -416,11 +429,24 @@
const contentEl = document.getElementById('ralphContent'); const contentEl = document.getElementById('ralphContent');
const lastFetchEl = document.getElementById('lastFetch'); const lastFetchEl = document.getElementById('lastFetch');
const liveLabel = document.getElementById('liveLabel'); const liveLabel = document.getElementById('liveLabel');
const liveIndicator = document.getElementById('liveIndicator');
const drawer = document.getElementById('ralphDrawer'); const drawer = document.getElementById('ralphDrawer');
const drawerTitle = document.getElementById('drawerTitle'); const drawerTitle = document.getElementById('drawerTitle');
const drawerBody = document.getElementById('drawerBody'); const drawerBody = document.getElementById('drawerBody');
const drawerClose = document.getElementById('drawerClose'); const drawerClose = document.getElementById('drawerClose');
// Connection mode: 'connecting' → 'live' (SSE) | 'polling' (fallback) | 'offline'
function setMode(mode) {
liveIndicator.dataset.mode = mode;
const labels = {
connecting: 'Conectare…',
live: '🟢 Live',
polling: '⏱ Polling',
offline: 'Offline',
};
liveLabel.textContent = labels[mode] || mode;
}
function fmtAgo(iso) { function fmtAgo(iso) {
if (!iso) return '—'; if (!iso) return '—';
const t = new Date(iso).getTime(); const t = new Date(iso).getTime();
@@ -469,6 +495,14 @@
</button>` </button>`
: ''; : '';
// Rollback: vizibil pe card-uri running (corectează ultima iteraţie
// dacă Ralph a marcat passes prematur). Confirm dialog la click.
const rollbackBtn = p.running
? `<button type="button" class="ralph-icon-btn" data-action="rollback" data-slug="${escapeHtml(p.slug)}" aria-label="Rollback ultima iteraţie" title="Rollback ultima iteraţie (git revert HEAD)">
<i data-lucide="undo-2" aria-hidden="true"></i>
</button>`
: '';
return ` return `
<article class="ralph-card" data-status="${escapeHtml(p.status)}"> <article class="ralph-card" data-status="${escapeHtml(p.status)}">
<header class="ralph-card-head"> <header class="ralph-card-head">
@@ -498,6 +532,7 @@
<button type="button" class="ralph-icon-btn" data-action="prd" data-slug="${escapeHtml(p.slug)}" aria-label="Vezi PRD"> <button type="button" class="ralph-icon-btn" data-action="prd" data-slug="${escapeHtml(p.slug)}" aria-label="Vezi PRD">
<i data-lucide="file-text" aria-hidden="true"></i> <i data-lucide="file-text" aria-hidden="true"></i>
</button> </button>
${rollbackBtn}
${stopBtn} ${stopBtn}
</div> </div>
</footer> </footer>
@@ -521,23 +556,26 @@
</div>`; </div>`;
} }
function renderSnapshot(data) {
const projects = data.projects || [];
if (projects.length === 0) {
contentEl.innerHTML = renderEmpty();
} else {
contentEl.innerHTML = `<div class="ralph-grid">${projects.map(renderCard).join('')}</div>`;
}
lastFetchEl.textContent = '· ' + fmtAgo(data.fetchedAt);
if (window.lucide) lucide.createIcons();
}
async function fetchStatus() { async function fetchStatus() {
try { try {
const res = await fetch('/api/ralph/status', { cache: 'no-store' }); const res = await fetch('/api/ralph/status', { cache: 'no-store' });
if (!res.ok) throw new Error('HTTP ' + res.status); if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json(); const data = await res.json();
const projects = data.projects || []; renderSnapshot(data);
if (projects.length === 0) {
contentEl.innerHTML = renderEmpty();
} else {
contentEl.innerHTML = `<div class="ralph-grid">${projects.map(renderCard).join('')}</div>`;
}
lastFetchEl.textContent = '· ' + fmtAgo(data.fetchedAt);
liveLabel.textContent = 'Live';
if (window.lucide) lucide.createIcons();
} catch (err) { } catch (err) {
contentEl.innerHTML = renderError(err.message || String(err)); contentEl.innerHTML = renderError(err.message || String(err));
liveLabel.textContent = 'Offline'; setMode('offline');
if (window.lucide) lucide.createIcons(); if (window.lucide) lucide.createIcons();
} }
} }
@@ -583,6 +621,22 @@
} }
} }
async function rollbackRalph(slug) {
if (!confirm(`Asta va da git revert HEAD pe ${slug} și va decrementa ultima story trecută. Continui?`)) return;
try {
const res = await fetch(`/api/ralph/${encodeURIComponent(slug)}/rollback`, { method: 'POST' });
const data = await res.json();
if (!data.success) {
alert('Rollback eşuat: ' + (data.message || 'unknown'));
} else {
alert('✓ ' + (data.message || 'Rollback OK'));
fetchStatus();
}
} catch (err) {
alert('Eroare rollback: ' + (err.message || err));
}
}
contentEl.addEventListener('click', (e) => { contentEl.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]'); const btn = e.target.closest('[data-action]');
if (!btn) return; if (!btn) return;
@@ -591,6 +645,7 @@
if (action === 'log') openLog(slug); if (action === 'log') openLog(slug);
else if (action === 'prd') openPrd(slug); else if (action === 'prd') openPrd(slug);
else if (action === 'stop') stopRalph(slug); else if (action === 'stop') stopRalph(slug);
else if (action === 'rollback') rollbackRalph(slug);
}); });
drawerClose.addEventListener('click', () => { drawerClose.addEventListener('click', () => {
@@ -605,9 +660,82 @@
if (e.key === 'Escape') drawer.dataset.open = 'false'; if (e.key === 'Escape') drawer.dataset.open = 'false';
}); });
// Boot + poll // ────────────────────────────────────────────────────────
// Connection: try SSE first; fallback to polling on error.
// ────────────────────────────────────────────────────────
let eventSource = null;
let pollHandle = null;
function startPolling() {
if (pollHandle) return;
setMode('polling');
fetchStatus();
pollHandle = setInterval(fetchStatus, POLL_MS);
}
function stopPolling() {
if (pollHandle) {
clearInterval(pollHandle);
pollHandle = null;
}
}
function startSSE() {
if (typeof EventSource === 'undefined') {
startPolling();
return;
}
try {
eventSource = new EventSource('/api/ralph/stream');
} catch (err) {
startPolling();
return;
}
// Server-confirmed open — switch to live mode
eventSource.addEventListener('open', () => {
stopPolling();
setMode('live');
});
eventSource.addEventListener('status', (ev) => {
stopPolling();
setMode('live');
try {
const data = JSON.parse(ev.data);
renderSnapshot(data);
} catch (err) {
// malformed payload — ignore, next event will reconcile
}
});
eventSource.addEventListener('heartbeat', () => {
// Keep-alive; nothing to render but it confirms the link.
if (liveIndicator.dataset.mode !== 'live') setMode('live');
});
eventSource.addEventListener('error', () => {
// EventSource auto-reconnect kicks in by default. If the
// endpoint never responds (404/500/CORS), readyState=CLOSED
// and we fall back permanently to polling.
if (eventSource && eventSource.readyState === EventSource.CLOSED) {
eventSource = null;
startPolling();
} else {
// Transient — show polling state until reconnect succeeds
setMode('polling');
if (!pollHandle) {
// Don't double-fetch; SSE reconnect should resume soon
fetchStatus();
}
}
});
}
// Initial paint via fetch (so first frame renders even if SSE handshake
// takes a beat); SSE will then take over for live updates.
fetchStatus(); fetchStatus();
setInterval(fetchStatus, POLL_MS); startSSE();
if (window.lucide) lucide.createIcons(); if (window.lucide) lucide.createIcons();
})(); })();
</script> </script>

View File

@@ -207,3 +207,217 @@ class TestValidateSlug:
def test_nonexistent_returns_none(self, handler): def test_nonexistent_returns_none(self, handler):
assert handler._ralph_validate_slug("does-not-exist") is None assert handler._ralph_validate_slug("does-not-exist") is None
def test_underscore_allowed(self, handler, tmp_path):
(tmp_path / "snake_case_slug").mkdir()
result = handler._ralph_validate_slug("snake_case_slug")
assert result is not None
def test_too_long_rejected(self, handler):
assert handler._ralph_validate_slug("a" * 65) is None
def test_special_chars_rejected(self, handler):
# Punctuaţie / spaţii / shell metachars — toate respinse de regex
for bad in ("a b", "a;b", "a$b", "a.b", "a&b", "a|b", "a%2E"):
assert handler._ralph_validate_slug(bad) is None, bad
def test_backslash_rejected(self, handler):
assert handler._ralph_validate_slug("a\\b") is None
# ── _ralph_collect_status / _ralph_signature (SSE helpers) ────
class TestCollectAndSignature:
def test_collect_empty_when_no_workspace(self, handler):
snap = handler._ralph_collect_status()
assert snap == {"projects": [], "fetchedAt": snap["fetchedAt"], "count": 0}
def test_collect_lists_projects(self, handler, tmp_path):
_make_ralph_project(tmp_path, "proj-x", [
{"id": "US-001", "passes": True, "failed": False, "blocked": False,
"retries": 0, "tags": [], "title": "x", "priority": 10},
])
snap = handler._ralph_collect_status()
assert snap["count"] == 1
assert snap["projects"][0]["slug"] == "proj-x"
def test_signature_stable_when_unchanged(self, handler, tmp_path):
_make_ralph_project(tmp_path, "p1", [])
snap1 = handler._ralph_collect_status()
snap2 = handler._ralph_collect_status()
# fetchedAt diferă — semnătura ignoră asta intenţionat
assert handler._ralph_signature(snap1) == handler._ralph_signature(snap2)
def test_signature_changes_when_project_added(self, handler, tmp_path):
_make_ralph_project(tmp_path, "p1", [])
sig1 = handler._ralph_signature(handler._ralph_collect_status())
_make_ralph_project(tmp_path, "p2", [])
sig2 = handler._ralph_signature(handler._ralph_collect_status())
assert sig1 != sig2
def test_signature_changes_when_passes_changes(self, handler, tmp_path):
_make_ralph_project(tmp_path, "p1", [
{"id": "US-001", "passes": False, "failed": False, "blocked": False,
"retries": 0, "tags": [], "title": "a", "priority": 10},
])
sig1 = handler._ralph_signature(handler._ralph_collect_status())
# mutăm story la passes=True
ralph_dir = tmp_path / "p1" / "scripts" / "ralph"
prd = json.loads((ralph_dir / "prd.json").read_text())
prd["userStories"][0]["passes"] = True
(ralph_dir / "prd.json").write_text(json.dumps(prd))
sig2 = handler._ralph_signature(handler._ralph_collect_status())
assert sig1 != sig2
# ── /api/ralph/<slug>/rollback ─────────────────────────────────
def _git(cmd: list[str], cwd):
"""Run a git subcommand for test setup; raise if it fails."""
import subprocess
return subprocess.run(
["git"] + cmd, cwd=str(cwd), check=True,
capture_output=True, text=True,
)
def _init_repo_with_two_commits(project_dir):
"""Create a real git repo with two commits — needed for revert/reset tests."""
project_dir.mkdir(parents=True, exist_ok=True)
_git(["init", "-q", "-b", "main"], project_dir)
_git(["config", "user.email", "test@example.com"], project_dir)
_git(["config", "user.name", "Test"], project_dir)
_git(["config", "commit.gpgsign", "false"], project_dir)
(project_dir / "README.md").write_text("first")
_git(["add", "README.md"], project_dir)
_git(["commit", "-q", "-m", "first"], project_dir)
(project_dir / "feature.txt").write_text("second commit content")
_git(["add", "feature.txt"], project_dir)
_git(["commit", "-q", "-m", "second"], project_dir)
class TestRollback:
def test_invalid_slug_400(self, handler):
handler.handle_ralph_rollback("../etc/passwd")
assert handler.captured_code == 400
assert handler.captured["success"] is False
def test_path_traversal_blocked(self, handler):
handler.handle_ralph_rollback("..")
assert handler.captured_code == 400
def test_not_a_git_repo_400(self, handler, tmp_path):
# Project există dar nu e git repo
_make_ralph_project(tmp_path, "no-git", [])
handler.handle_ralph_rollback("no-git")
assert handler.captured_code == 400
assert "not a git" in handler.captured["message"].lower()
def test_revert_success_with_story_decrement(self, handler, tmp_path):
slug = "revert-ok"
_make_ralph_project(tmp_path, slug, [
{"id": "US-001", "passes": True, "failed": False, "blocked": False,
"retries": 0, "tags": [], "title": "first", "priority": 10},
{"id": "US-002", "passes": True, "failed": False, "blocked": False,
"retries": 1, "tags": [], "title": "second", "priority": 20},
{"id": "US-003", "passes": False, "failed": False, "blocked": False,
"retries": 0, "tags": [], "title": "third", "priority": 30},
])
_init_repo_with_two_commits(tmp_path / slug)
head = _git(["rev-parse", "HEAD"], tmp_path / slug).stdout.strip()
handler.handle_ralph_rollback(slug)
assert handler.captured_code == 200, handler.captured
assert handler.captured["success"] is True
assert handler.captured["reverted_commit"] == head
assert handler.captured["method"] == "revert"
# ultima story trecută (US-002) trebuie marcată incompletă
assert handler.captured["story_reverted"] == "US-002"
# Verify atomic write efect: prd.json reflectă passes=False pe US-002
prd = json.loads(
(tmp_path / slug / "scripts" / "ralph" / "prd.json").read_text()
)
assert prd["userStories"][1]["id"] == "US-002"
assert prd["userStories"][1]["passes"] is False
assert prd["userStories"][1]["retries"] == 0
# US-001 rămâne neatins
assert prd["userStories"][0]["passes"] is True
# Verify git history: HEAD should be a new revert commit (not the old HEAD)
new_head = _git(["rev-parse", "HEAD"], tmp_path / slug).stdout.strip()
assert new_head != head
def test_revert_with_no_passing_stories_succeeds_without_decrement(self, handler, tmp_path):
slug = "no-stories"
_make_ralph_project(tmp_path, slug, [
{"id": "US-001", "passes": False, "failed": False, "blocked": False,
"retries": 0, "tags": [], "title": "a", "priority": 10},
])
_init_repo_with_two_commits(tmp_path / slug)
handler.handle_ralph_rollback(slug)
assert handler.captured_code == 200
assert handler.captured["success"] is True
# nimic de decrementat → story_reverted=None
assert handler.captured["story_reverted"] is None
def test_response_shape_contract(self, handler, tmp_path):
"""Răspunsul trebuie să aibă fix aceste keys ca să meargă în UI."""
slug = "shape"
_make_ralph_project(tmp_path, slug, [])
_init_repo_with_two_commits(tmp_path / slug)
handler.handle_ralph_rollback(slug)
for k in ("success", "message", "reverted_commit", "story_reverted"):
assert k in handler.captured, f"missing key: {k}"
def test_decrement_helper_atomic_write(self, handler, tmp_path):
"""_ralph_decrement_last_pass: temp file nu trebuie să rămână în filesystem."""
slug = "atomic"
ralph_dir = _make_ralph_project(tmp_path, slug, [
{"id": "US-001", "passes": True, "failed": False, "blocked": False,
"retries": 0, "tags": [], "title": "x", "priority": 10},
])
result = handler._ralph_decrement_last_pass(tmp_path / slug)
assert result == "US-001"
# tmp file curăţat
assert not (ralph_dir / "prd.json.tmp").exists()
# passes=False persistat
prd = json.loads((ralph_dir / "prd.json").read_text())
assert prd["userStories"][0]["passes"] is False
def test_decrement_helper_no_passing_returns_none(self, handler, tmp_path):
slug = "nothing-to-revert"
_make_ralph_project(tmp_path, slug, [
{"id": "US-001", "passes": False, "failed": False, "blocked": False,
"retries": 0, "tags": [], "title": "x", "priority": 10},
])
result = handler._ralph_decrement_last_pass(tmp_path / slug)
assert result is None
# ── api.py routing ─────────────────────────────────────────────
class TestApiRouting:
"""Smoke test pentru ThreadingHTTPServer + dispatch /api/ralph/stream + rollback."""
def test_threading_http_server_in_use(self):
import api # type: ignore
# ThreadingHTTPServer este folosit pentru SSE non-blocking
from http.server import ThreadingHTTPServer
# Verify import doesn't reference deprecated HTTPServer at module level
src = (PROJECT_ROOT / "dashboard" / "api.py").read_text()
assert "ThreadingHTTPServer" in src
def test_stream_route_dispatches_handler(self):
"""/api/ralph/stream trebuie să apeleze handle_ralph_stream."""
src = (PROJECT_ROOT / "dashboard" / "api.py").read_text()
assert "/api/ralph/stream" in src
assert "handle_ralph_stream" in src
def test_rollback_route_dispatches_handler(self):
src = (PROJECT_ROOT / "dashboard" / "api.py").read_text()
assert "handle_ralph_rollback" in src