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

615
dashboard/ralph.html Normal file
View File

@@ -0,0 +1,615 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="/echo/favicon.svg">
<title>Echo · Ralph</title>
<link rel="stylesheet" href="/echo/common.css">
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<script src="/echo/swipe-nav.js"></script>
<style>
/* ==========================================
Ralph status extension tokens
(existing common.css NU declară --status-*)
========================================== */
:root {
--status-running: rgb(34, 197, 94); /* green */
--status-blocked: rgb(245, 158, 11); /* amber */
--status-failed: rgb(239, 68, 68); /* red */
--status-complete: rgb(156, 163, 175); /* slate (done = neutral) */
--status-idle: var(--text-muted);
}
/* ==========================================
Layout
========================================== */
.main {
max-width: 1400px;
margin: 0 auto;
padding: var(--space-5);
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-5);
}
.page-title {
font-size: var(--text-xl);
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: var(--space-2);
}
.page-subtitle {
font-size: var(--text-sm);
color: var(--text-muted);
}
/* Live indicator pulse */
.live-indicator {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
color: var(--text-muted);
padding: var(--space-1) var(--space-3);
background: var(--bg-surface);
border-radius: var(--radius-full);
}
.live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--status-running);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.2); }
}
.last-fetch {
font-size: var(--text-xs);
color: var(--text-muted);
}
/* ==========================================
Cards grid
========================================== */
.ralph-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-4);
}
@media (max-width: 1024px) {
.ralph-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
.ralph-grid { grid-template-columns: 1fr; }
}
.ralph-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-4);
min-height: 180px;
display: flex;
flex-direction: column;
gap: var(--space-3);
transition: border-color var(--transition-fast);
}
.ralph-card:hover {
border-color: var(--border-focus);
}
.ralph-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.ralph-slug {
font-size: var(--text-base);
font-weight: 600;
color: var(--text-primary);
font-family: var(--font-mono);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ralph-status {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 2px 10px;
font-size: var(--text-xs);
font-weight: 600;
border-radius: var(--radius-full);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ralph-status[data-status="running"] { background: rgba(34, 197, 94, 0.18); color: var(--status-running); }
.ralph-status[data-status="blocked"] { background: rgba(245, 158, 11, 0.18); color: var(--status-blocked); }
.ralph-status[data-status="failed"] { background: rgba(239, 68, 68, 0.18); color: var(--status-failed); }
.ralph-status[data-status="complete"] { background: rgba(156, 163, 175, 0.18); color: var(--status-complete); }
.ralph-status[data-status="idle"] { background: var(--bg-surface-active); color: var(--status-idle); }
.ralph-status[data-status="error"] { background: rgba(239, 68, 68, 0.18); color: var(--status-failed); }
.ralph-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.ralph-card-body {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.ralph-current {
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: 1.4;
}
.ralph-current-id {
font-family: var(--font-mono);
color: var(--text-primary);
font-weight: 600;
}
.ralph-tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.ralph-tag {
font-size: var(--text-xs);
padding: 1px 8px;
background: var(--accent-subtle);
color: var(--accent);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
}
/* Progress bar */
.ralph-progress {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.ralph-progress-meta {
display: flex;
justify-content: space-between;
font-size: var(--text-xs);
color: var(--text-muted);
}
.ralph-progress-bar {
height: 6px;
background: var(--bg-surface-active);
border-radius: var(--radius-full);
overflow: hidden;
position: relative;
}
.ralph-progress-fill {
height: 100%;
background: var(--status-complete);
transition: width var(--transition-base);
}
.ralph-card[data-status="running"] .ralph-progress-fill { background: var(--status-running); }
.ralph-card[data-status="failed"] .ralph-progress-fill { background: var(--status-failed); }
.ralph-card[data-status="blocked"] .ralph-progress-fill { background: var(--status-blocked); }
.ralph-card-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
font-size: var(--text-xs);
color: var(--text-muted);
}
.ralph-actions {
display: flex;
gap: var(--space-1);
}
.ralph-icon-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--text-muted);
border-radius: var(--radius-sm);
cursor: pointer;
padding: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
min-height: 32px;
transition: all var(--transition-fast);
}
.ralph-icon-btn:hover {
color: var(--text-primary);
background: var(--bg-surface-hover);
}
.ralph-icon-btn.danger {
color: var(--status-failed);
border-color: rgba(239, 68, 68, 0.4);
}
.ralph-icon-btn.danger:hover {
background: rgba(239, 68, 68, 0.12);
}
.ralph-icon-btn svg {
width: 14px;
height: 14px;
}
@media (max-width: 640px) {
.ralph-icon-btn {
min-width: 44px;
min-height: 44px;
}
.ralph-icon-btn svg {
width: 18px;
height: 18px;
}
}
/* Empty / loading / error states */
.ralph-empty,
.ralph-loading,
.ralph-error {
text-align: center;
padding: var(--space-10) var(--space-5);
color: var(--text-muted);
}
.ralph-empty svg,
.ralph-loading svg,
.ralph-error svg {
width: 32px;
height: 32px;
margin-bottom: var(--space-3);
opacity: 0.6;
}
.ralph-empty-title {
font-size: var(--text-base);
color: var(--text-secondary);
margin-bottom: var(--space-1);
}
/* ==========================================
Drawer (log + PRD viewer)
========================================== */
.ralph-drawer {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: none;
align-items: center;
justify-content: center;
z-index: 200;
padding: var(--space-4);
}
.ralph-drawer[data-open="true"] {
display: flex;
}
.ralph-drawer-content {
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
max-width: 900px;
width: 100%;
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.ralph-drawer-head {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.ralph-drawer-title {
font-size: var(--text-base);
font-weight: 600;
color: var(--text-primary);
font-family: var(--font-mono);
}
.ralph-drawer-body {
flex: 1;
overflow: auto;
padding: var(--space-4);
}
.ralph-drawer-pre {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
}
</style>
</head>
<body>
<!--NAV-->
<main class="main">
<header class="page-header">
<div>
<div class="page-title">
<i data-lucide="bot" aria-hidden="true"></i>
Echo · Ralph
</div>
<div class="page-subtitle">Live status pe proiectele autonome (polling 5s)</div>
</div>
<div class="live-indicator" aria-live="polite">
<span class="live-dot" aria-hidden="true"></span>
<span id="liveLabel">Live</span>
<span class="last-fetch" id="lastFetch"></span>
</div>
</header>
<section id="ralphContent" aria-live="polite">
<div class="ralph-loading">
<i data-lucide="loader" aria-hidden="true"></i>
<div>Se încarcă proiectele Ralph...</div>
</div>
</section>
</main>
<!-- Drawer pentru log / PRD viewer -->
<div class="ralph-drawer" id="ralphDrawer" data-open="false" role="dialog" aria-modal="true" aria-labelledby="drawerTitle">
<div class="ralph-drawer-content">
<div class="ralph-drawer-head">
<div class="ralph-drawer-title" id="drawerTitle"></div>
<button type="button" class="ralph-icon-btn" id="drawerClose" aria-label="Închide drawer">
<i data-lucide="x" aria-hidden="true"></i>
</button>
</div>
<div class="ralph-drawer-body">
<pre class="ralph-drawer-pre" id="drawerBody"></pre>
</div>
</div>
</div>
<script>
(function () {
const POLL_MS = 5000;
const contentEl = document.getElementById('ralphContent');
const lastFetchEl = document.getElementById('lastFetch');
const liveLabel = document.getElementById('liveLabel');
const drawer = document.getElementById('ralphDrawer');
const drawerTitle = document.getElementById('drawerTitle');
const drawerBody = document.getElementById('drawerBody');
const drawerClose = document.getElementById('drawerClose');
function fmtAgo(iso) {
if (!iso) return '—';
const t = new Date(iso).getTime();
if (isNaN(t)) return '—';
const diff = Math.max(0, Date.now() - t);
const sec = Math.floor(diff / 1000);
if (sec < 60) return `acum ${sec}s`;
const min = Math.floor(sec / 60);
if (min < 60) return `acum ${min}m`;
const hr = Math.floor(min / 60);
if (hr < 24) return `acum ${hr}h`;
const day = Math.floor(hr / 24);
return `acum ${day}z`;
}
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function renderCard(p) {
const total = p.storiesTotal || 0;
const done = p.storiesComplete || 0;
const failed = p.storiesFailed || 0;
const blocked = p.storiesBlocked || 0;
const pct = total > 0 ? Math.round(((done + failed + blocked) / total) * 100) : 0;
const current = p.currentStory
? `<div class="ralph-current"><span class="ralph-current-id">${escapeHtml(p.currentStory.id)}</span> · ${escapeHtml(p.currentStory.title || '')} ` +
(p.currentStory.retries ? `<span title="retries">(${p.currentStory.retries}/3)</span>` : '') + `</div>` +
(p.currentStory.tags && p.currentStory.tags.length
? `<div class="ralph-tags">${p.currentStory.tags.map(t => `<span class="ralph-tag">${escapeHtml(t)}</span>`).join('')}</div>`
: '')
: (p.status === 'complete'
? `<div class="ralph-current">Toate stories complete (${done}/${total}).</div>`
: `<div class="ralph-current" style="color:var(--text-muted)">Nu rulează acum.</div>`);
const eta = (p.etaMinutes != null && p.status === 'running')
? `~${p.etaMinutes}min`
: '';
const stopBtn = p.running
? `<button type="button" class="ralph-icon-btn danger" data-action="stop" data-slug="${escapeHtml(p.slug)}" aria-label="Oprește Ralph">
<i data-lucide="square" aria-hidden="true"></i>
</button>`
: '';
return `
<article class="ralph-card" data-status="${escapeHtml(p.status)}">
<header class="ralph-card-head">
<div class="ralph-slug" title="${escapeHtml(p.slug)}">${escapeHtml(p.slug)}</div>
<span class="ralph-status" data-status="${escapeHtml(p.status)}" aria-label="Status: ${escapeHtml(p.status)}">
<span class="ralph-status-dot" aria-hidden="true"></span>${escapeHtml(p.status)}
</span>
</header>
<div class="ralph-card-body">
${current}
<div class="ralph-progress">
<div class="ralph-progress-meta">
<span>${done}/${total} done${failed ? ` · ${failed} failed` : ''}${blocked ? ` · ${blocked} blocked` : ''}</span>
<span>${eta}</span>
</div>
<div class="ralph-progress-bar" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100">
<div class="ralph-progress-fill" style="width:${pct}%"></div>
</div>
</div>
</div>
<footer class="ralph-card-foot">
<span title="Ultima iterație">${fmtAgo(p.lastIterAt)}</span>
<div class="ralph-actions">
<button type="button" class="ralph-icon-btn" data-action="log" data-slug="${escapeHtml(p.slug)}" aria-label="Vezi log">
<i data-lucide="terminal" aria-hidden="true"></i>
</button>
<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>
</button>
${stopBtn}
</div>
</footer>
</article>`;
}
function renderEmpty() {
return `
<div class="ralph-empty">
<i data-lucide="inbox" aria-hidden="true"></i>
<div class="ralph-empty-title">Niciun proiect aprobat.</div>
<div>Aprobă ceva pe Discord/Telegram cu <code>/a &lt;slug&gt;</code>.</div>
</div>`;
}
function renderError(msg) {
return `
<div class="ralph-error">
<i data-lucide="alert-triangle" aria-hidden="true"></i>
<div>Cannot reach Echo Core: ${escapeHtml(msg)}</div>
</div>`;
}
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 = `<div class="ralph-grid">${projects.map(renderCard).join('')}</div>`;
}
lastFetchEl.textContent = '· ' + fmtAgo(data.fetchedAt);
liveLabel.textContent = 'Live';
if (window.lucide) lucide.createIcons();
} catch (err) {
contentEl.innerHTML = renderError(err.message || String(err));
liveLabel.textContent = 'Offline';
if (window.lucide) lucide.createIcons();
}
}
async function openLog(slug) {
drawerTitle.textContent = `${slug} · progress.txt`;
drawerBody.textContent = 'Se încarcă...';
drawer.dataset.open = 'true';
try {
const res = await fetch(`/api/ralph/${encodeURIComponent(slug)}/log?lines=200`);
const data = await res.json();
drawerBody.textContent = (data.lines || []).join('\n');
} catch (err) {
drawerBody.textContent = `Error: ${err.message || err}`;
}
}
async function openPrd(slug) {
drawerTitle.textContent = `${slug} · prd.json`;
drawerBody.textContent = 'Se încarcă...';
drawer.dataset.open = 'true';
try {
const res = await fetch(`/api/ralph/${encodeURIComponent(slug)}/prd`);
const data = await res.json();
drawerBody.textContent = JSON.stringify(data, null, 2);
} catch (err) {
drawerBody.textContent = `Error: ${err.message || err}`;
}
}
async function stopRalph(slug) {
if (!confirm(`Oprești Ralph pe ${slug}?`)) return;
try {
const res = await fetch(`/api/ralph/${encodeURIComponent(slug)}/stop`, { method: 'POST' });
const data = await res.json();
if (!data.success) {
alert('Eșec: ' + (data.error || 'unknown'));
} else {
fetchStatus();
}
} catch (err) {
alert('Eroare: ' + (err.message || err));
}
}
contentEl.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const slug = btn.dataset.slug;
const action = btn.dataset.action;
if (action === 'log') openLog(slug);
else if (action === 'prd') openPrd(slug);
else if (action === 'stop') stopRalph(slug);
});
drawerClose.addEventListener('click', () => {
drawer.dataset.open = 'false';
});
drawer.addEventListener('click', (e) => {
if (e.target === drawer) drawer.dataset.open = 'false';
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') drawer.dataset.open = 'false';
});
// Boot + poll
fetchStatus();
setInterval(fetchStatus, POLL_MS);
if (window.lucide) lucide.createIcons();
})();
</script>
</body>
</html>