feat(ralph): smart gates + DAG + dashboard live (W3)

Restructurare Ralph QC loop pe smart gate dispatcher tag-driven (în loc de
5 faze fixe), DAG dependsOn cu propagare blocked, retry guard 3-strike, rate
limit detection, plus dashboard live cu polling 5s.

Changes:
- tools/ralph_prd_generator.py: parametru optional final_plan_path; când e
  furnizat, invocă Claude Opus pe final-plan.md pentru extragere user stories
  cu schema extinsă (tags, dependsOn, acceptanceCriteria 3-5). Backward compat
  păstrat — fără final_plan_path, fallback la heuristic-ul vechi.
- tools/ralph/prd-template.json: schema W3 (tags[], dependsOn[], retries,
  failed, blocked, failureReason, requiresDesignReview).
- tools/ralph/prompt.md: 4 faze (impl, base quality, smart gates, commit) +
  dispatcher pe story.tags. Tags vide → run-all-gates fallback (safe default).
- tools/ralph_dag.py (nou): tag validation heuristic anti-silent-regression
  (force ui dacă diff atinge .vue/.tsx/.html/.css/.scss; force db pentru
  migrations sau .sql; force vercel dacă există vercel.json) + topological
  sort cu blocked propagation + atomic prd.json updates.
- tools/ralph/ralph.sh: --max-turns 30, DAG-aware story selection, retry
  counter cu auto-fail la 3, rate limit detection (sleep 30min + 1 retry),
  CLI subcommands prin tools/ralph_dag.py helper.
- dashboard/handlers/ralph.py (nou): /api/ralph/status + /<slug>/log + /prd
  + /stop. Defensive vs corrupt prd.json. Sandbox-ed PID kill.
- dashboard/ralph.html (nou): live cards 3/2/1 col responsive, polling 5s,
  drawer pentru log/PRD viewer, status colors (--status-running/blocked/
  failed/complete declarate inline), Lucide icons cu aria-labels.
- dashboard/api.py: mount /api/ralph/* (GET status/log/prd, POST stop).
- tests/: 72 teste noi (smart gates, DAG, retry, dashboard endpoint).

Note arhitecturale:
- Polling 5s ales peste SSE/WebSocket (suficient pentru iter Ralph 8-15min)
- Tag validation rulează POST-iter pe diff git pentru anti-silent-regression
- Rate limit retry: 1 dată per rulare, apoi mark failed=rate_limited

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 18:36:35 +00:00
parent e06a79d98c
commit 655ed3ae09
11 changed files with 2282 additions and 189 deletions

174
tests/test_smart_gates.py Normal file
View File

@@ -0,0 +1,174 @@
"""Tests for W3 smart gates tag validation heuristic.
Acoperă:
- infer_tags_from_paths: detect ui/db/vercel pe baza file extensions / paths
- force_include_tags: combinare tags Opus + tags inferate din diff (anti-silent-regression)
- Toate combinatii de tag types (ui, db, vercel, refactor, docs, backend, infra)
- Edge cases: tags vide, tags invalide, empty diff
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from tools.ralph_dag import ( # noqa: E402
VALID_TAGS,
force_include_tags,
infer_tags_from_paths,
)
# ── infer_tags_from_paths ──────────────────────────────────────
class TestInferTags:
def test_empty_diff_no_tags(self):
assert infer_tags_from_paths([]) == []
def test_only_readme_no_tags(self):
assert infer_tags_from_paths(["README.md", "CHANGELOG.md"]) == []
def test_vue_triggers_ui(self):
assert infer_tags_from_paths(["src/App.vue"]) == ["ui"]
def test_tsx_triggers_ui(self):
assert infer_tags_from_paths(["app/page.tsx"]) == ["ui"]
def test_jsx_triggers_ui(self):
assert infer_tags_from_paths(["src/Button.jsx"]) == ["ui"]
def test_html_triggers_ui(self):
assert infer_tags_from_paths(["dashboard/index.html"]) == ["ui"]
def test_css_scss_trigger_ui(self):
assert infer_tags_from_paths(["src/main.css"]) == ["ui"]
assert infer_tags_from_paths(["src/main.scss"]) == ["ui"]
def test_svelte_triggers_ui(self):
assert infer_tags_from_paths(["src/App.svelte"]) == ["ui"]
def test_migrations_triggers_db(self):
assert infer_tags_from_paths(["db/migrations/0001_init.sql"]) == ["db"]
def test_top_level_migrations_triggers_db(self):
assert infer_tags_from_paths(["migrations/2026/04/add_users.sql"]) == ["db"]
def test_sql_outside_migrations_still_triggers_db(self):
assert infer_tags_from_paths(["scripts/seed.sql"]) == ["db"]
def test_vercel_json_only(self):
assert infer_tags_from_paths([], has_vercel_json=True) == ["vercel"]
def test_combined_ui_db_vercel(self):
result = infer_tags_from_paths(
["app/page.tsx", "db/migrations/0001.sql"], has_vercel_json=True
)
assert result == ["ui", "db", "vercel"]
def test_dedup_when_multiple_files_same_category(self):
result = infer_tags_from_paths(["a.tsx", "b.vue", "c.css"])
assert result == ["ui"]
def test_case_insensitive_extensions(self):
assert infer_tags_from_paths(["src/App.TSX"]) == ["ui"]
assert infer_tags_from_paths(["db/Init.SQL"]) == ["db"]
# ── force_include_tags ─────────────────────────────────────────
class TestForceIncludeTags:
def test_existing_only_no_diff(self):
assert force_include_tags(["backend"], [], False) == ["backend"]
def test_diff_inferred_added_to_existing(self):
# Opus marcat docs, dar diff atinge .tsx → ui forțat
result = force_include_tags(["docs"], ["src/Page.tsx"], False)
assert "docs" in result
assert "ui" in result
def test_filters_invalid_tags_from_existing(self):
# Tag-ul "frontend" nu e în VALID_TAGS — trebuie eliminat
result = force_include_tags(["frontend", "ui"], [], False)
assert "frontend" not in result
assert "ui" in result
def test_empty_when_no_existing_no_diff(self):
assert force_include_tags([], [], False) == []
def test_dedup_existing_and_inferred(self):
# Existing are ui, diff are .tsx → un singur ui în output
result = force_include_tags(["ui"], ["src/A.tsx"], False)
assert result.count("ui") == 1
def test_vercel_added_when_vercel_json_present(self):
result = force_include_tags(["backend"], [], has_vercel_json=True)
assert "vercel" in result
assert "backend" in result
def test_all_valid_tags_preserved(self):
# Verifică că force_include nu strică tags valide existente
all_valid = list(VALID_TAGS)
result = force_include_tags(all_valid, [], False)
for t in all_valid:
assert t in result
def test_order_existing_first_then_inferred(self):
# Existing tags trebuie să apară primele (stabilitate API)
result = force_include_tags(["backend"], ["src/Page.tsx", "db/migrations/0001.sql"], False)
assert result[0] == "backend"
assert "ui" in result and "db" in result
# ── Smart gates dispatcher contract (combinatii tag → expected gates) ─────────
# Acesta e un table-test pentru contractul dispatcher-ului din prompt.md.
# Verifică doar mapping-ul tag → gate name (specifice prompt.md), nu execuția.
GATE_MAPPING = {
"refactor": "/workflow:simplify",
"ui": "/qa",
"vercel": "gh pr checks",
"db": "schema diff",
"docs": None, # docs => doar typecheck base
"backend": "/review",
"infra": "/review",
}
class TestGateMapping:
"""Validează că prompt.md menționează gate-urile așteptate per tag."""
@pytest.fixture(scope="class")
def prompt_md(self):
path = PROJECT_ROOT / "tools" / "ralph" / "prompt.md"
return path.read_text(encoding="utf-8")
def test_refactor_gate_documented(self, prompt_md):
assert "/workflow:simplify" in prompt_md
def test_ui_gate_documented(self, prompt_md):
assert "/qa" in prompt_md
assert "agent-browser" in prompt_md.lower()
def test_vercel_gate_documented(self, prompt_md):
assert "gh pr checks" in prompt_md
def test_db_gate_documented(self, prompt_md):
assert "schema diff" in prompt_md.lower() or "alembic" in prompt_md.lower()
def test_backend_gate_documented(self, prompt_md):
assert "/review" in prompt_md
def test_run_all_fallback_documented(self, prompt_md):
# Tags vide → run-all-gates fallback (safe default)
assert "tags vide" in prompt_md.lower() or "run-all-gates" in prompt_md.lower()
def test_dag_dependson_documented(self, prompt_md):
assert "dependsOn" in prompt_md or "DAG" in prompt_md