From ff9b9a0d1d725eab828c627dc44b693722a47d39 Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Sun, 26 Apr 2026 19:07:13 +0000 Subject: [PATCH] feat(dashboard): SSE realtime + story rollback button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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//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 ș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) --- dashboard/api.py | 20 +- dashboard/handlers/ralph.py | 294 +++++++++++++++++++++++-- dashboard/ralph.html | 158 +++++++++++-- tests/test_dashboard_ralph_endpoint.py | 214 ++++++++++++++++++ 4 files changed, 647 insertions(+), 39 deletions(-) diff --git a/dashboard/api.py b/dashboard/api.py index 09b48ff..fff10b6 100644 --- a/dashboard/api.py +++ b/dashboard/api.py @@ -7,7 +7,7 @@ server bootstrap. """ import json import sys -from http.server import HTTPServer, SimpleHTTPRequestHandler +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path # Make dashboard/ importable for the handler submodules (constants, @@ -159,6 +159,8 @@ class TaskBoardHandler( self.handle_eco_doctor() elif self.path == '/api/ralph/status' or self.path.startswith('/api/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/'): # /api/ralph//log or /api/ralph//prd parts = self.path.split('?', 1)[0].split('/') @@ -233,11 +235,18 @@ class TaskBoardHandler( self.handle_eco_git_commit() elif self.path == '/api/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//{stop,rollback} parts = self.path.split('?', 1)[0].split('/') if len(parts) >= 5: 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: self.send_error(404) else: @@ -264,5 +273,8 @@ if __name__ == '__main__': os.chdir(KANBAN_DIR) 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() diff --git a/dashboard/handlers/ralph.py b/dashboard/handlers/ralph.py index b2bd14b..a9d5094 100644 --- a/dashboard/handlers/ralph.py +++ b/dashboard/handlers/ralph.py @@ -1,13 +1,16 @@ -"""Ralph live dashboard endpoints (W3). +"""Ralph live dashboard endpoints (W3 + dashboard-realtime). Endpoints: GET /api/ralph/status — toate proiectele Ralph (cards data) + GET /api/ralph/stream — Server-Sent Events stream (realtime) GET /api/ralph//log — tail progress.txt (default 100 lines) GET /api/ralph//prd — full prd.json content POST /api/ralph//stop — SIGTERM la Ralph PID + POST /api/ralph//rollback — git revert HEAD + decrement last passing story -Polling: 5s din ralph.html (suficient pentru iter 8-15min Ralph). -NU SSE/WebSocket pentru MVP. +SSE detail: stream emite `event: status\\ndata: \\n\\n` la schimbări (poll +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//scripts/ralph/`: - prd.json → stories (passes/failed/blocked/retries) @@ -19,14 +22,20 @@ Reuse path constants din `dashboard/constants.py` (WORKSPACE_DIR). """ import json import os +import re import signal +import subprocess +import time from datetime import datetime from pathlib import Path -from urllib.parse import unquote 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/) def _ralph_dir(project_dir: Path) -> Path: return project_dir / "scripts" / "ralph" @@ -41,10 +50,20 @@ class RalphHandlers: # ── helpers ──────────────────────────────────────────────── def _ralph_validate_slug(self, slug: str): - """Validează slug-ul + returnează project_dir sau None.""" - if not slug or "/" in slug or ".." in slug: + """Validează slug-ul + returnează project_dir sau None. + + 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 - slug = unquote(slug) project_dir = constants.WORKSPACE_DIR / slug try: resolved = project_dir.resolve() @@ -174,30 +193,121 @@ class RalphHandlers: ], } - # ── /api/ralph/status (GET) ──────────────────────────────── - def handle_ralph_status(self): - """Î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 + def _ralph_collect_status(self) -> dict: + """Construieşte payload-ul de status pentru toate proiectele. + 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()): if not entry.is_dir() or entry.name.startswith("."): continue summary = self._ralph_summarize_project(entry) if summary is not None: projects.append(summary) + return { + "projects": projects, + "fetchedAt": datetime.now().isoformat(), + "count": len(projects), + } - self.send_json({ - "projects": projects, - "fetchedAt": datetime.now().isoformat(), - "count": len(projects), - }) + def _ralph_signature(self, snapshot: dict) -> tuple: + """Compactă semnătură pentru change-detection în SSE — doar fields care + contează pentru UI (status, counts, current story). Timestamps de iter + 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: 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//log (GET) ──────────────────────────── def handle_ralph_log(self, slug: str): """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})"}) except Exception as exc: self.send_json({"success": False, "error": str(exc)}, 500) + + # ── /api/ralph//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) diff --git a/dashboard/ralph.html b/dashboard/ralph.html index bc5f44c..6b665c8 100644 --- a/dashboard/ralph.html +++ b/dashboard/ralph.html @@ -72,6 +72,19 @@ 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 { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.5; transform: scale(1.2); } @@ -378,11 +391,11 @@ Echo · Ralph -
Live status pe proiectele autonome (polling 5s)
+
Live status pe proiectele autonome
-
+
- Live + Conectare…
@@ -416,11 +429,24 @@ const contentEl = document.getElementById('ralphContent'); const lastFetchEl = document.getElementById('lastFetch'); const liveLabel = document.getElementById('liveLabel'); + const liveIndicator = document.getElementById('liveIndicator'); const drawer = document.getElementById('ralphDrawer'); const drawerTitle = document.getElementById('drawerTitle'); const drawerBody = document.getElementById('drawerBody'); 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) { if (!iso) return '—'; const t = new Date(iso).getTime(); @@ -469,6 +495,14 @@ ` : ''; + // Rollback: vizibil pe card-uri running (corectează ultima iteraţie + // dacă Ralph a marcat passes prematur). Confirm dialog la click. + const rollbackBtn = p.running + ? `` + : ''; + return `
@@ -498,6 +532,7 @@ + ${rollbackBtn} ${stopBtn}
@@ -521,23 +556,26 @@ `; } + function renderSnapshot(data) { + const projects = data.projects || []; + if (projects.length === 0) { + contentEl.innerHTML = renderEmpty(); + } else { + contentEl.innerHTML = `
${projects.map(renderCard).join('')}
`; + } + lastFetchEl.textContent = '· ' + fmtAgo(data.fetchedAt); + if (window.lucide) lucide.createIcons(); + } + async function fetchStatus() { try { const res = await fetch('/api/ralph/status', { cache: 'no-store' }); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); - const projects = data.projects || []; - if (projects.length === 0) { - contentEl.innerHTML = renderEmpty(); - } else { - contentEl.innerHTML = `
${projects.map(renderCard).join('')}
`; - } - lastFetchEl.textContent = '· ' + fmtAgo(data.fetchedAt); - liveLabel.textContent = 'Live'; - if (window.lucide) lucide.createIcons(); + renderSnapshot(data); } catch (err) { contentEl.innerHTML = renderError(err.message || String(err)); - liveLabel.textContent = 'Offline'; + setMode('offline'); 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) => { const btn = e.target.closest('[data-action]'); if (!btn) return; @@ -591,6 +645,7 @@ if (action === 'log') openLog(slug); else if (action === 'prd') openPrd(slug); else if (action === 'stop') stopRalph(slug); + else if (action === 'rollback') rollbackRalph(slug); }); drawerClose.addEventListener('click', () => { @@ -605,9 +660,82 @@ 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(); - setInterval(fetchStatus, POLL_MS); + startSSE(); if (window.lucide) lucide.createIcons(); })(); diff --git a/tests/test_dashboard_ralph_endpoint.py b/tests/test_dashboard_ralph_endpoint.py index f0a72a2..98e4f05 100644 --- a/tests/test_dashboard_ralph_endpoint.py +++ b/tests/test_dashboard_ralph_endpoint.py @@ -207,3 +207,217 @@ class TestValidateSlug: def test_nonexistent_returns_none(self, handler): 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//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