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:
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user