Compare commits
9 Commits
dedeedf024
...
e771479d67
| Author | SHA1 | Date | |
|---|---|---|---|
| e771479d67 | |||
| 2830bf48f2 | |||
| 44c9bb4e61 | |||
| 03d875974b | |||
| 84f304f7be | |||
| 3c9322ba93 | |||
| 6d56356ada | |||
| ff9b9a0d1d | |||
| 3e7818286b |
@@ -179,7 +179,7 @@ Live dashboard → /echo/ralph.html (polling 5s) — cards per proiect
|
||||
|
||||
Pe **Discord**: slash commands native cu autocomplete dinamic: `/p <tab>` listează workspace, `/a <tab>` pending, `/k <tab>` running. Modal cu `TextInput` pentru descriere. Critical pattern: `await interaction.response.defer(ephemeral=True)` în orice button callback cu I/O (Discord 3s timeout).
|
||||
Pe **Telegram**: `callback_ralph` cu pattern `^ralph:` rutează acțiuni; `ForceReply` pentru input text descriere.
|
||||
Pe **WhatsApp**: text-only — meniu redirect la Discord/Telegram.
|
||||
Pe **WhatsApp**: text-only — meniu redirect la Discord/Telegram. **Text-keyword shortcuts**: `aprob <slug>` → `/a <slug>`, `stop <slug>` → `/k <slug>`, `stare`/`stare <slug>` → `/l`/`/l <slug>` (case-insensitive, doar pe WhatsApp; Discord/Telegram nu sunt afectate). `propose` intentionally NOT covered — descrierea fragilă.
|
||||
|
||||
**Aliasuri legacy** (funcționează încă pentru backwards compat): `!propose`, `!approve`, `!status`, `!stop`.
|
||||
|
||||
@@ -197,11 +197,13 @@ Pe **WhatsApp**: text-only — meniu redirect la Discord/Telegram.
|
||||
| `tools/ralph/prd-template.json` | Template prd.json: stories cu `acceptanceCriteria[]`, `tags[]`, `dependsOn[]`, `passes`, `retries` |
|
||||
| `tools/ralph_prd_generator.py` | Generează prd.json. Cu `final_plan_path` (de la PlanningOrchestrator) → Opus extrage stories cu acceptance criteria. Fără → backwards-compat description-only |
|
||||
| `tools/ralph_dag.py` | Pure functions Python (testabile): `infer_tags_from_paths`, `force_include_tags`, `topological_eligible`, `mark_failed`, blocked propagation iterativă. CLI subcommands chemate din ralph.sh (`infer-tags`, `next-story`, `mark-failed`, `incr-retry`) |
|
||||
| `tools/ralph_usage.py` | Rate limit budget tracking: pure functions `extract_usage_entry`, `parse_usage_jsonl`, `aggregate_by_day`, `aggregate_by_project` + CLI append/summarize. Atomic write JSONL |
|
||||
| `~/workspace/<name>/scripts/ralph/usage.jsonl` | Append-only log per `claude -p` call (cost, tokens, model, duration) — generat din ralph.sh, agregat de `/api/ralph/usage` |
|
||||
| `~/workspace/<name>/scripts/ralph/final-plan.md` | Output planning agent — citit de PRD generator |
|
||||
| `~/workspace/<name>/scripts/ralph/prd.json` | PRD per proiect cu schema extinsă |
|
||||
| `~/workspace/<name>/scripts/ralph/logs/` | Loguri ralph.sh per rulare |
|
||||
| `dashboard/handlers/ralph.py` | Endpoints `/api/ralph/status`, `/<slug>/log`, `/<slug>/prd`, `/<slug>/stop` |
|
||||
| `dashboard/ralph.html` | UI live cards, polling 5s, status badges, ETA, butoane log/prd/stop |
|
||||
| `dashboard/handlers/ralph.py` | Endpoints `/api/ralph/status`, `/<slug>/log`, `/<slug>/prd`, `/<slug>/stop`, `/<slug>/rollback`, `/usage[?days=N]`, `/stream` (SSE) |
|
||||
| `dashboard/ralph.html` | UI live cards, status badges, ETA, butoane log/prd/stop/rollback. Realtime via EventSource cu fallback la polling 5s; badge 🟢 Live / ⏱ Polling |
|
||||
| `dashboard/.env` | `GITEA_TOKEN` pentru clone HTTPS la `gitea.romfast.ro` |
|
||||
|
||||
**Status flow:** `pending` → (`planning` →) `approved` → `running` → `complete` / `failed` / `stopped` / `blocked` (DAG)
|
||||
|
||||
@@ -1,4 +1,54 @@
|
||||
{
|
||||
"projects": [],
|
||||
"last_updated": null
|
||||
}
|
||||
"projects": [
|
||||
{
|
||||
"name": "romfast-website",
|
||||
"description": "analizeaza paginile din website si propune 1-3 pagini noi sau modificare de pagini cu ce ar face website-ul firmei mele Romfast (vinde ERP ROA) mai util, informational, educativ, cu scopul de a atrage lead-uri informate, calde",
|
||||
"status": "planning",
|
||||
"planning_session_id": "14d2d96d-d4eb-4472-9b07-4a869909c564",
|
||||
"final_plan_path": null,
|
||||
"proposed_at": "2026-04-26T18:53:47.597827+00:00",
|
||||
"approved_at": null,
|
||||
"started_at": null,
|
||||
"pid": null
|
||||
},
|
||||
{
|
||||
"name": "space-booking",
|
||||
"description": "vreau sa pornesti aplicatia si sa testezi frontend in browser",
|
||||
"status": "planning",
|
||||
"planning_session_id": "d9c2f7ea-7e80-4cd3-b569-139b3fd01eb0",
|
||||
"final_plan_path": null,
|
||||
"proposed_at": "2026-04-26T19:12:15.605405+00:00",
|
||||
"approved_at": null,
|
||||
"started_at": null,
|
||||
"pid": null
|
||||
},
|
||||
{
|
||||
"name": "roa2web-anaf-notificari",
|
||||
"description": "Integrare alerte ANAF Monitor direct \u00een roa2web: c\u00e2nd monitor_v2.py detecteaz\u0103 modific\u0103ri la D406/D394/D100/D390/E-Factura, trimite notificare automat\u0103 prin Telegram bot existent \u00een roa2web. UI simplu \u00een dashboard pentru vizualizare modific\u0103ri recente.",
|
||||
"status": "pending",
|
||||
"proposed_at": "2026-04-27T21:01:29.004348",
|
||||
"approved_at": null,
|
||||
"started_at": null,
|
||||
"pid": null
|
||||
},
|
||||
{
|
||||
"name": "roa2web-playwright-qa",
|
||||
"description": "QA automat pentru roa2web cu Playwright CLI + Claude Code: set de teste care verific\u0103 paginile principale (balan\u021b\u0103, facturi, trezorerie), detecteaz\u0103 regresii vizuale \u0219i func\u021bionale, raporteaz\u0103 \u00een dashboard. Rulare automat\u0103 la fiecare deploy.",
|
||||
"status": "pending",
|
||||
"proposed_at": "2026-04-27T21:01:29.004348",
|
||||
"approved_at": null,
|
||||
"started_at": null,
|
||||
"pid": null
|
||||
},
|
||||
{
|
||||
"name": "chatbot-maria-txt-converter",
|
||||
"description": "Script simplu care converte\u0219te fi\u0219iere TXT (scrise de angajatul nou) \u00een Markdown structurat pentru document store Flowise (chatbot Maria). Monitorizeaz\u0103 un folder, converte\u0219te automat \u0219i actualizeaz\u0103 document store.",
|
||||
"status": "pending",
|
||||
"proposed_at": "2026-04-27T21:01:29.004348",
|
||||
"approved_at": null,
|
||||
"started_at": null,
|
||||
"pid": null
|
||||
}
|
||||
],
|
||||
"last_updated": "2026-04-27T21:01:29.004348"
|
||||
}
|
||||
@@ -53,9 +53,9 @@
|
||||
"report_on": "changes",
|
||||
"timeout": 180,
|
||||
"enabled": true,
|
||||
"last_run": "2026-04-26T03:00:00.002050+00:00",
|
||||
"last_run": "2026-04-28T03:00:00.002116+00:00",
|
||||
"last_status": "ok",
|
||||
"next_run": "2026-04-27T03:00:00+00:00"
|
||||
"next_run": "2026-04-29T03:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "kb-index-refresh",
|
||||
@@ -69,9 +69,9 @@
|
||||
"report_on": "never",
|
||||
"timeout": 120,
|
||||
"enabled": true,
|
||||
"last_run": "2026-04-26T03:30:00.002073+00:00",
|
||||
"last_run": "2026-04-28T03:30:00.001308+00:00",
|
||||
"last_status": "ok",
|
||||
"next_run": "2026-04-27T03:30:00+00:00"
|
||||
"next_run": "2026-04-29T03:30:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "archive-tasks-daily",
|
||||
@@ -85,9 +85,9 @@
|
||||
"report_on": "changes",
|
||||
"timeout": 60,
|
||||
"enabled": true,
|
||||
"last_run": "2026-04-26T03:00:00.001722+00:00",
|
||||
"last_run": "2026-04-28T03:00:00.001778+00:00",
|
||||
"last_status": "ok",
|
||||
"next_run": "2026-04-27T03:00:00+00:00"
|
||||
"next_run": "2026-04-29T03:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "backup-config",
|
||||
@@ -101,9 +101,9 @@
|
||||
"report_on": "never",
|
||||
"timeout": 120,
|
||||
"enabled": true,
|
||||
"last_run": "2026-04-26T02:00:00.003364+00:00",
|
||||
"last_run": "2026-04-28T02:00:00.002390+00:00",
|
||||
"last_status": "ok",
|
||||
"next_run": "2026-04-27T02:00:00+00:00"
|
||||
"next_run": "2026-04-29T02:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "insights-extract",
|
||||
@@ -185,9 +185,9 @@
|
||||
"enabled": true,
|
||||
"prompt": "RAPORT DIMINEAȚĂ - trimite pe EMAIL (Gmail: mmarius28@gmail.com)\n\n## CALENDAR\nVerifică calendarul:\n```bash\ncd ~/echo-core && source venv/bin/activate && python3 tools/calendar_check.py today\npython3 tools/calendar_check.py travel\npython3 tools/calendar_check.py week\n```\n\n## CITEȘTE CONTEXT\n- USER.md pentru programul lui Marius (luni-joi 15-16 liber)\n- memory/kb/insights/ pentru propuneri (ultimele 3 zile)\n- /home/moltbot/echo-core/approved-tasks.json pentru status proiecte/features (câmpurile: name, status, started_at, pid)\n\n## FORMAT EMAIL HTML\n- Font: 16px text, 18px titluri\n- Culori: albastru (#dbeafe) DONE, gri (#f3f4f6) PROGRAMAT, verde (#d1fae5) PROJECTS\n- Link-uri vizibile\n\n## STRUCTURA RAPORT\n\n### 1. CALENDAR\n- 📅 **AZI:** [evenimente]\n- 📅 **MÂINE:** [evenimente]\n- 📅 **PESTE 2 ZILE:** [dacă e GRUP, NLP, meeting mare]\n- 🚂 **TRAVEL:** Reminders bilete+cazare\n\n### 2. PROIECTE/FEATURES NOAPTEA 💻\n\nCitesc /home/moltbot/echo-core/approved-tasks.json și raportez ce s-a realizat:\n(statusuri: pending, approved, running, complete, failed, stopped)\nPentru stories done/total: citesc /home/moltbot/workspace/{name}/scripts/ralph/prd.json\n\n**Format pentru fiecare proiect/feature [x]:**\n\n```html\n<div style=\"background: #d1fae5; padding: 15px; margin: 10px 0; border-radius: 8px;\">\n <h3>✅ P1 - Nume Proiect</h3>\n \n <p><strong>Status:</strong> X/Y stories complete</p>\n \n <p><strong>Stories realizate:</strong></p>\n <ul>\n <li>✅ US-001: Titlu story - implementat cu succes</li>\n <li>✅ US-002: Titlu story - quality checks pass</li>\n <li>🔄 US-003: Titlu story - în progres (blocat pe dependency)</li>\n </ul>\n \n <p><strong>Link:</strong> <a href=\"https://gitea.romfast.ro/romfast/PROJECT-NAME\">gitea.romfast.ro/romfast/PROJECT-NAME</a></p>\n \n <p><strong>Learnings:</strong> [din progress.txt - ce patterns am descoperit]</p>\n \n <p><strong>Next steps:</strong> [ce rămâne de făcut]</p>\n</div>\n```\n\n**Dacă NU s-au executat proiecte/features:**\n- Sari peste această secțiune\n\n### 3. STATUS GENERAL\n- Ce s-a făcut ieri (joburi, taskuri)\n- Git status ~/clawd\n- Joburi executate (YouTube, insights, etc.)\n\n### 4. PROPUNERI CU ZI ȘI ORĂ!\n\n**OBLIGATORIU:** Fiecare propunere TU+EU sau FAC TU trebuie să aibă ZI și ORĂ concrete!\n\nCategorii:\n- 🤖 **FAC EU** (0 efort) - execut singur\n- 🤝 **TU+EU** (eu pregătesc) - cu zi/oră!\n- 👤 **FAC TU** (template gata) - cu zi/oră!\n\nExemplu:\n- **A1 - Sesiune Dizolvare Vină** 🤝 TU+EU\n 📅 **Marți 3 feb, 15:00-15:30**\n Context + link sursă\n\nReguli programare:\n- Luni-Joi 15:00-16:00 = slot liber\n- Vineri-Duminică = NLP, evită\n- Verifică calendar să nu fie ocupat\n\n### 5. INSIGHTS DISPONIBILE\n\nListează insights-uri [ ] nepropuse încă (format scurt).\n\n### 6. CUM RĂSPUNZI\n- DA = aprob toate (cu zilele/orele propuse)\n- 1 pentru A1,A2 = execut ACUM\n- 2 pentru A3 = programez noapte\n- 3 pentru A5 = skip\n- Alt orar = \"A1 miercuri nu marți\"\n\n## TRIMITERE\npython3 /home/moltbot/echo-core/tools/email_send.py \"mmarius28@gmail.com\" \"Raport Dimineata DATA\" \"HTML_CONTENT\"\n\nNU trimite pe Discord - doar email.",
|
||||
"allowed_tools": [],
|
||||
"last_run": null,
|
||||
"last_status": null,
|
||||
"next_run": null
|
||||
"last_run": "2026-04-27T08:30:00.003116+00:00",
|
||||
"last_status": "error",
|
||||
"next_run": "2026-04-28T08:30:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "evening-report",
|
||||
@@ -197,9 +197,9 @@
|
||||
"enabled": true,
|
||||
"prompt": "RAPORT SEARĂ - trimite pe EMAIL (Gmail: mmarius28@gmail.com)\n\n## CALENDAR\nVerifică ce ai mâine și săptămâna:\n```bash\ncd ~/echo-core && source venv/bin/activate && python3 tools/calendar_check.py today\npython3 tools/calendar_check.py week\n```\n\n## CITEȘTE CONTEXT\n- USER.md pentru programul lui Marius (luni-joi 15-16 liber, vineri-dum NLP)\n- memory/kb/insights/YYYY-MM-DD.md pentru propuneri insights\n- memory/kb/youtube/ și memory/kb/articole/ pentru inspirație proiecte\n- /home/moltbot/echo-core/approved-tasks.json pentru status proiecte existente (câmpurile: name, status, proposed_at)\n\n## FORMAT EMAIL HTML\n- Font: 16px text, 18px titluri\n- Culori: albastru (#dbeafe) DONE, gri (#f3f4f6) PROGRAMAT, verde (#d1fae5) PROJECTS\n- Link-uri vizibile\n\n## STRUCTURA RAPORT\n\n### 1. MÂINE\n- 📅 Evenimente calendar\n- 🚂 Travel reminders\n\n### 2. STATUS\n- Ce s-a făcut azi\n- Git status\n\n### 3. PROPUNERI CU ZI ȘI ORĂ!\n\n**OBLIGATORIU:** Fiecare propunere TU+EU sau FAC TU trebuie să aibă ZI și ORĂ concrete!\n\nReguli programare:\n- Luni-Joi 15:00-16:00 = slot liber\n- Vineri-Duminică = NLP, evită\n- Verifică calendar să nu fie ocupat\n- Sesiuni scurte: 15-30 min\n\n### 4. PROGRAME/PROIECTE PRACTICE 💻\n\n**CONTEXT OBLIGATORIU - citește înainte de a propune:**\n\n**Proiecte existente (PRIORITARE pentru features):**\n- **roa2web** (gitea.romfast.ro/romfast/roa2web) - FastAPI+Vue.js+Telegram bot\n - Are deja: balanță, facturi, trezorerie\n - Lipsesc: validări declarații ANAF, facturare valută/taxare inversă, notificări\n - Rapoarte ROA noi → FEATURE în roa2web, NU proiect separat!\n- **Chatbot Maria** (Flowise pe LXC 104, ngrok → romfast.ro/chatbot_maria.html)\n - Document store: XML, MD | Groq gratuit + Ollama embeddings + FAISS\n - Problema: răspunsuri nu sunt suficient de bune\n - Angajatul nou poate menține documentația (scrie TXT, trebuie converter)\n - Clientii îl accesează din programele ROA direct\n\n**Întrebări frecvente clienți (surse de proiecte):**\n- Erori validare declarații ANAF (D406, D394, D100 etc.)\n- Cum facturez în valută cu taxare inversă?\n- Probleme la instalări, inițializări firme noi, configurări\n\n**Reguli propuneri (80/20 STRICT):**\n- Impact mare pentru Marius → apoi pentru clienți ERP ROA\n- Inspirat din discovery (YouTube, articole, insights procesate)\n- Features roa2web > proiecte noi (integrare în existent)\n- Proiecte independente doar dacă NU se potrivesc în roa2web/Flowise\n\n**A. FEATURES PROIECTE EXISTENTE (2-3, PRIORITAR):**\n\nFormat:\n```\n### ⚡ F1 - Feature pentru [roa2web/chatbot]\n**Ce face:** Descriere scurtă\n**De ce:** Ce problemă rezolvă (ex: \"clienții întreabă X de 5 ori/săptămână\")\n**Complexitate:** S/M/L\n**Proiect:** roa2web / chatbot-maria\n```\n\n**B. PROIECTE NOI (max 1, doar dacă nu se integrează în existente):**\n\nFormat:\n```\n### 💻 P1 - Nume Proiect\n**De ce:** Cum se leagă de nevoile lui Marius/clienți\n**Impact:** Pentru Marius + pentru clienți\n**Efort:** Ore/zile realist\n**Stack:** Simplu (80/20)\n**Sursă:** [Link nota KB]\n```\n\n**NU propune:**\n- Proiecte complexe fără beneficiu clar\n- Proiecte duplicat cu ce există deja\n- Rapoarte ROA ca proiect separat (→ feature roa2web)\n\n### 5. INSIGHTS DISPONIBILE\nListează insights-uri [ ] nepropuse încă (format scurt).\n\n### 6. CUM RĂSPUNZI\n- DA = aprob toate (cu zilele/orele propuse)\n- 1 pentru A1,A2 = execut ACUM\n- 2 pentru A3 = programez noapte\n- 3 pentru A5 = skip\n- **F pentru F1,F3** = implementează features (joburi noapte)\n- **P pentru P1** = creează proiect nou (job noapte)\n- Alt orar = \"A1 miercuri nu marți\"\n\n## IMPLEMENTARE PROIECTE APROBATE\n\nCând propui features (F) sau proiecte (P), adaugă-le automat în /home/moltbot/echo-core/approved-tasks.json cu status 'pending':\n```bash\npython3 -c \"\nimport json, datetime\nf = open('/home/moltbot/echo-core/approved-tasks.json')\ndata = json.load(f); f.close()\ndata['projects'].append({'name': 'SLUG-PROIECT', 'description': 'DESCRIERE', 'status': 'pending', 'proposed_at': datetime.datetime.utcnow().isoformat(), 'approved_at': None, 'started_at': None, 'pid': None})\ndata['last_updated'] = datetime.datetime.utcnow().isoformat()\nopen('/home/moltbot/echo-core/approved-tasks.json', 'w').write(json.dumps(data, indent=2))\n\"\n```\n\nÎn email, arată lui Marius comanda de aprobare:\n`!approve SLUG-PROIECT` (trimite pe Discord/Telegram la Echo)\n\nNight-execute (23:00) va:\n - genera PRD cu ralph_prd_generator.py dacă nu există prd.json\n - lansa ralph.sh 15 iterații pentru fiecare proiect aprobat\n\n## TRIMITERE\npython3 /home/moltbot/echo-core/tools/email_send.py \"mmarius28@gmail.com\" \"Raport Seara DATA\" \"HTML_CONTENT\"\n\nNU trimite pe Discord - doar email.",
|
||||
"allowed_tools": [],
|
||||
"last_run": null,
|
||||
"last_status": null,
|
||||
"next_run": null
|
||||
"last_run": "2026-04-27T21:00:00.003134+00:00",
|
||||
"last_status": "ok",
|
||||
"next_run": "2026-04-28T21:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "morning-coaching",
|
||||
@@ -269,9 +269,9 @@
|
||||
"prompt": "Heartbeat check. Rulează src/heartbeat.py printr-un scurt raport de status.\nDacă nu e nimic de raportat (email=0, calendar nu are evenimente <2h, kb ok), răspunde doar cu HEARTBEAT_OK și oprește-te — nu trimite mesaj.\nDacă e ceva: raport scurt pe Discord #echo-work.",
|
||||
"allowed_tools": [],
|
||||
"enabled": true,
|
||||
"last_run": "2026-04-26T18:00:00.003601+00:00",
|
||||
"last_run": "2026-04-27T18:00:00.002242+00:00",
|
||||
"last_status": "ok",
|
||||
"next_run": "2026-04-27T06:00:00+00:00"
|
||||
"next_run": "2026-04-28T06:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "night-execute",
|
||||
@@ -285,8 +285,8 @@
|
||||
"Read",
|
||||
"Write"
|
||||
],
|
||||
"last_run": null,
|
||||
"last_status": null,
|
||||
"next_run": null
|
||||
"last_run": "2026-04-27T23:00:00.001665+00:00",
|
||||
"last_status": "ok",
|
||||
"next_run": "2026-04-28T23:00:00+00:00"
|
||||
}
|
||||
]
|
||||
|
||||
276
cron/jobs.json.bak-pre-restore
Normal file
276
cron/jobs.json.bak-pre-restore
Normal file
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
@@ -59,6 +59,10 @@ NAV_HTML = '''<header class="header">
|
||||
<i data-lucide="code"></i>
|
||||
<span>Workspace</span>
|
||||
</a>
|
||||
<a href="/echo/ralph.html" class="nav-item" data-page="ralph">
|
||||
<i data-lucide="bot"></i>
|
||||
<span>Ralph</span>
|
||||
</a>
|
||||
<a href="/echo/notes.html" class="nav-item" data-page="notes">
|
||||
<i data-lucide="file-text"></i>
|
||||
<span>KB</span>
|
||||
@@ -159,6 +163,10 @@ 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/usage' or self.path.startswith('/api/ralph/usage?'):
|
||||
self.handle_ralph_usage()
|
||||
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/<slug>/log or /api/ralph/<slug>/prd
|
||||
parts = self.path.split('?', 1)[0].split('/')
|
||||
@@ -233,11 +241,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/<slug>/{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 +279,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()
|
||||
|
||||
@@ -1,31 +1,53 @@
|
||||
"""Ralph live dashboard endpoints (W3).
|
||||
"""Ralph live dashboard endpoints (W3 + instrumentation + realtime).
|
||||
|
||||
Endpoints:
|
||||
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>/prd — full prd.json content
|
||||
GET /api/ralph/usage[?days=N] — rate limit budget summary (cross-project)
|
||||
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).
|
||||
NU SSE/WebSocket pentru MVP.
|
||||
SSE detail: stream emite `event: status\\ndata: <json>\\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/<slug>/scripts/ralph/`:
|
||||
- prd.json → stories (passes/failed/blocked/retries)
|
||||
- progress.txt → log human-readable
|
||||
- logs/iteration-*.log → mtime ultimului iter
|
||||
- .ralph.pid → PID activ (verificat cu os.kill 0)
|
||||
- usage.jsonl → token/cost log per iter (instrumentation MVP)
|
||||
|
||||
Reuse path constants din `dashboard/constants.py` (WORKSPACE_DIR).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
|
||||
import constants
|
||||
|
||||
# Best-effort import of pure functions for /api/ralph/usage (instrumentation MVP).
|
||||
# Helper lives at <repo>/tools/ralph_usage.py — sibling of `dashboard/`.
|
||||
_TOOLS_DIR = Path(__file__).resolve().parents[2] / "tools"
|
||||
if str(_TOOLS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_TOOLS_DIR))
|
||||
try:
|
||||
import ralph_usage # type: ignore
|
||||
except ImportError: # pragma: no cover — diagnostic only
|
||||
ralph_usage = None # type: ignore
|
||||
|
||||
|
||||
# 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:
|
||||
@@ -41,10 +63,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 +206,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/<slug>/log (GET) ────────────────────────────
|
||||
def handle_ralph_log(self, slug: str):
|
||||
"""Tail progress.txt pentru un slug. Default last 100 lines."""
|
||||
@@ -259,6 +382,58 @@ class RalphHandlers:
|
||||
except Exception as exc:
|
||||
self.send_json({"error": str(exc)}, 500)
|
||||
|
||||
# ── /api/ralph/usage (GET) ─────────────────────────────────
|
||||
def handle_ralph_usage(self):
|
||||
"""Returnează rate limit budget summary cross-project.
|
||||
|
||||
Citește toate `~/workspace/<slug>/scripts/ralph/usage.jsonl`, le concatenează,
|
||||
rulează `ralph_usage.summarize` cu `?days=N` (default 7).
|
||||
|
||||
Răspuns:
|
||||
{
|
||||
"today": "YYYY-MM-DD",
|
||||
"today_cost": float,
|
||||
"today_runs": int,
|
||||
"window_days": N,
|
||||
"window_cost": float,
|
||||
"window_runs": int,
|
||||
"by_project": {...},
|
||||
"by_day": {...},
|
||||
"total_cost": float,
|
||||
"total_runs": int
|
||||
}
|
||||
"""
|
||||
try:
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
qs = parse_qs(urlparse(self.path).query)
|
||||
try:
|
||||
days = int(qs.get("days", ["7"])[0])
|
||||
if days <= 0:
|
||||
days = 7
|
||||
if days > 365:
|
||||
days = 365
|
||||
except ValueError:
|
||||
days = 7
|
||||
|
||||
if ralph_usage is None:
|
||||
self.send_json({"error": "ralph_usage helper unavailable"}, 500)
|
||||
return
|
||||
|
||||
entries: 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
|
||||
usage_path = _ralph_dir(entry) / "usage.jsonl"
|
||||
if usage_path.exists():
|
||||
entries.extend(ralph_usage.parse_usage_jsonl(usage_path))
|
||||
|
||||
summary = ralph_usage.summarize(entries, days=days)
|
||||
summary["fetchedAt"] = datetime.now().isoformat()
|
||||
self.send_json(summary)
|
||||
except Exception as exc:
|
||||
self.send_json({"error": str(exc)}, 500)
|
||||
|
||||
# ── /api/ralph/<slug>/stop (POST) ──────────────────────────
|
||||
def handle_ralph_stop(self, slug: str):
|
||||
"""Trimite SIGTERM la Ralph PID. Verifică că PID-ul e în WORKSPACE_DIR."""
|
||||
@@ -303,3 +478,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/<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;
|
||||
}
|
||||
|
||||
/* 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 @@
|
||||
<i data-lucide="bot" aria-hidden="true"></i>
|
||||
Echo · Ralph
|
||||
</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 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 id="liveLabel">Live</span>
|
||||
<span id="liveLabel">Conectare…</span>
|
||||
<span class="last-fetch" id="lastFetch"></span>
|
||||
</div>
|
||||
</header>
|
||||
@@ -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 @@
|
||||
</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 `
|
||||
<article class="ralph-card" data-status="${escapeHtml(p.status)}">
|
||||
<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">
|
||||
<i data-lucide="file-text" aria-hidden="true"></i>
|
||||
</button>
|
||||
${rollbackBtn}
|
||||
${stopBtn}
|
||||
</div>
|
||||
</footer>
|
||||
@@ -521,23 +556,26 @@
|
||||
</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() {
|
||||
try {
|
||||
const res = await fetch('/api/ralph/status', { cache: 'no-store' });
|
||||
const res = await fetch('/echo/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();
|
||||
renderSnapshot(data);
|
||||
} catch (err) {
|
||||
contentEl.innerHTML = renderError(err.message || String(err));
|
||||
liveLabel.textContent = 'Offline';
|
||||
setMode('offline');
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
}
|
||||
@@ -547,7 +585,7 @@
|
||||
drawerBody.textContent = 'Se încarcă...';
|
||||
drawer.dataset.open = 'true';
|
||||
try {
|
||||
const res = await fetch(`/api/ralph/${encodeURIComponent(slug)}/log?lines=200`);
|
||||
const res = await fetch(`/echo/api/ralph/${encodeURIComponent(slug)}/log?lines=200`);
|
||||
const data = await res.json();
|
||||
drawerBody.textContent = (data.lines || []).join('\n');
|
||||
} catch (err) {
|
||||
@@ -560,7 +598,7 @@
|
||||
drawerBody.textContent = 'Se încarcă...';
|
||||
drawer.dataset.open = 'true';
|
||||
try {
|
||||
const res = await fetch(`/api/ralph/${encodeURIComponent(slug)}/prd`);
|
||||
const res = await fetch(`/echo/api/ralph/${encodeURIComponent(slug)}/prd`);
|
||||
const data = await res.json();
|
||||
drawerBody.textContent = JSON.stringify(data, null, 2);
|
||||
} catch (err) {
|
||||
@@ -571,7 +609,7 @@
|
||||
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 res = await fetch(`/echo/api/ralph/${encodeURIComponent(slug)}/stop`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
alert('Eșec: ' + (data.error || 'unknown'));
|
||||
@@ -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(`/echo/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('/echo/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();
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -241,8 +241,51 @@ def _maybe_whatsapp_redirect(text: str, adapter_name: str | None) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def _translate_whatsapp_text(text: str) -> str | None:
|
||||
"""Translate WhatsApp text-keyword commands to slash equivalents.
|
||||
|
||||
Acoperă **doar** keyword-urile robuste (single-token + opțional slug):
|
||||
- `aprob` → `/a` (listează pending)
|
||||
- `aprob <slug>` → `/a <slug>` (aprobă proiect)
|
||||
- `stop <slug>` → `/k <slug>` (oprește Ralph)
|
||||
- `stare` → `/l` (status global)
|
||||
- `stare <slug>` → `/l <slug>` (status filtrat)
|
||||
|
||||
NU acoperă `propose` — descrierea liberă e prea fragilă pentru parsing
|
||||
text-only (utilizatorii ar trimite descrieri multi-line care s-ar
|
||||
interpreta greșit). Pentru propose, redirecționăm spre Discord/Telegram.
|
||||
|
||||
Returnează slash command translatat sau None dacă text-ul nu match.
|
||||
Case-insensitive pe keyword (slug-ul rămâne ca în input).
|
||||
|
||||
Apelat DOAR pe adapter `whatsapp` în router (nu vrem ca un user pe
|
||||
Discord să zică „stop" și să se întâmple ceva).
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return None
|
||||
|
||||
parts = text.strip().split(None, 1)
|
||||
keyword = parts[0].lower()
|
||||
rest = parts[1].strip() if len(parts) > 1 else ""
|
||||
|
||||
if keyword == "aprob":
|
||||
return f"/a {rest}".rstrip()
|
||||
if keyword == "stop" and rest:
|
||||
# `stop` fără slug ar putea fi colocvial („stop, am uitat ceva") — nu translatăm.
|
||||
return f"/k {rest}"
|
||||
if keyword == "stare":
|
||||
return f"/l {rest}".rstrip()
|
||||
return None
|
||||
|
||||
|
||||
def _try_ralph_dispatch(text: str, adapter_name: str | None = None) -> str | None:
|
||||
"""Parse and dispatch Ralph commands. Returns response string or None if no match."""
|
||||
# WhatsApp keyword preprocessing — doar pe whatsapp, înainte de dispatch.
|
||||
if adapter_name == "whatsapp":
|
||||
translated = _translate_whatsapp_text(text)
|
||||
if translated is not None:
|
||||
text = translated
|
||||
|
||||
low = text.lower()
|
||||
first = low.split(None, 1)[0] if low else ""
|
||||
|
||||
|
||||
@@ -186,6 +186,54 @@ class TestPrd:
|
||||
assert handler.captured_code == 400
|
||||
|
||||
|
||||
# ── /api/ralph/usage ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestUsageEndpoint:
|
||||
def test_usage_empty_workspace(self, handler):
|
||||
handler.path = "/api/ralph/usage"
|
||||
handler.handle_ralph_usage()
|
||||
assert handler.captured_code == 200
|
||||
assert handler.captured["today_runs"] == 0
|
||||
assert handler.captured["total_runs"] == 0
|
||||
assert handler.captured["by_project"] == {}
|
||||
|
||||
def test_usage_aggregates_across_projects(self, handler, tmp_path):
|
||||
# Create two projects, each with usage.jsonl
|
||||
for slug, cost, ts in [("proj-a", 0.5, "2026-04-26T10:00:00+00:00"),
|
||||
("proj-b", 0.3, "2026-04-26T11:00:00+00:00")]:
|
||||
ralph_dir = tmp_path / slug / "scripts" / "ralph"
|
||||
ralph_dir.mkdir(parents=True)
|
||||
(ralph_dir / "usage.jsonl").write_text(
|
||||
json.dumps({"slug": slug, "ts": ts, "total_cost_usd": cost,
|
||||
"input_tokens": 100, "output_tokens": 50, "cache_read": 0}) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
handler.path = "/api/ralph/usage?days=30"
|
||||
handler.handle_ralph_usage()
|
||||
assert handler.captured_code == 200
|
||||
# Should have both projects
|
||||
assert "proj-a" in handler.captured["by_project"]
|
||||
assert "proj-b" in handler.captured["by_project"]
|
||||
assert handler.captured["total_runs"] == 2
|
||||
assert handler.captured["window_runs"] == 2
|
||||
|
||||
def test_usage_invalid_days_falls_back(self, handler):
|
||||
handler.path = "/api/ralph/usage?days=abc"
|
||||
handler.handle_ralph_usage()
|
||||
assert handler.captured_code == 200
|
||||
assert handler.captured["window_days"] == 7
|
||||
|
||||
def test_usage_handles_corrupt_jsonl(self, handler, tmp_path):
|
||||
# Project with corrupt usage.jsonl shouldn't 500
|
||||
ralph_dir = tmp_path / "broken" / "scripts" / "ralph"
|
||||
ralph_dir.mkdir(parents=True)
|
||||
(ralph_dir / "usage.jsonl").write_text("not json\n", encoding="utf-8")
|
||||
handler.path = "/api/ralph/usage"
|
||||
handler.handle_ralph_usage()
|
||||
assert handler.captured_code == 200
|
||||
|
||||
|
||||
# ── _ralph_validate_slug ───────────────────────────────────────
|
||||
|
||||
|
||||
@@ -207,3 +255,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/<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
|
||||
|
||||
259
tests/test_e2e_planning_walkthrough.py
Normal file
259
tests/test_e2e_planning_walkthrough.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""End-to-end scripted walkthrough — simulează exact ce face un user pe Discord:
|
||||
|
||||
1. /l → click Planifică pe game-library (proiect cu UI scope)
|
||||
2. Modal se deschide; user tastează descriere; submit
|
||||
3. start_planning_session creează entry → status='planning'
|
||||
4. Agent răspunde la primul turn (office-hours)
|
||||
5. User răspunde un mesaj normal → router rutează la orchestrator (NU la chat normal)
|
||||
6. User apasă "Continuă faza" → advance la /plan-ceo-review (fresh subprocess)
|
||||
7. Repeat pentru /plan-eng-review și /plan-design-review (UI scope detectat)
|
||||
8. La sfârșitul ultimului phase, advance scrie final-plan.md stub
|
||||
9. User apasă "Dau drumul tonight" → planning_approve
|
||||
10. Status='approved', final_plan_path setat în approved-tasks.json
|
||||
11. Re-citim approved-tasks.json și verificăm că night-execute ar avea
|
||||
toate câmpurile necesare (slug, description, status, final_plan_path)
|
||||
|
||||
Subprocess `claude -p` e mock-uit — nu consumăm credite. Acoperă totul
|
||||
între `start_planning_session` și `planning_approve` ca un single test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src import planning_orchestrator, planning_session, ralph_flow, router
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_e2e(tmp_path, monkeypatch):
|
||||
"""Redirect every state file + workspace into a tmp dir."""
|
||||
sessions = tmp_path / "sessions"
|
||||
sessions.mkdir()
|
||||
monkeypatch.setattr(planning_session, "SESSIONS_DIR", sessions)
|
||||
monkeypatch.setattr(
|
||||
planning_session, "PLANNING_STATE_FILE", sessions / "planning.json"
|
||||
)
|
||||
# Ralph flow state isolation
|
||||
monkeypatch.setattr(ralph_flow, "_STATE_FILE", sessions / "ralph_flow.json")
|
||||
monkeypatch.setattr(ralph_flow, "SESSIONS_DIR", sessions)
|
||||
|
||||
approved = tmp_path / "approved-tasks.json"
|
||||
approved.write_text(json.dumps({"projects": [], "last_updated": None}))
|
||||
monkeypatch.setattr(router, "APPROVED_TASKS_FILE", approved)
|
||||
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
(workspace / "game-library").mkdir()
|
||||
monkeypatch.setattr(planning_session, "WORKSPACE_ROOT", workspace)
|
||||
monkeypatch.setattr(planning_orchestrator, "WORKSPACE_ROOT", workspace)
|
||||
|
||||
yield {"sessions": sessions, "approved": approved, "workspace": workspace}
|
||||
|
||||
|
||||
def _fake_run_claude_factory():
|
||||
"""Return a side-effect function that mocks each subprocess call.
|
||||
|
||||
Tracks calls so the test can verify subprocess was invoked once per phase.
|
||||
Returns realistic-shaped JSON results.
|
||||
"""
|
||||
state = {"calls": 0, "session_ids": []}
|
||||
|
||||
def fake(*args, **kwargs):
|
||||
state["calls"] += 1
|
||||
sid = f"s-{state['calls']}"
|
||||
state["session_ids"].append(sid)
|
||||
# Odd turns ask a question; even turns emit PHASE_READY_MARKER.
|
||||
text = (
|
||||
f"Acesta e turn-ul {state['calls']}. Ce vrei să facem mai concret?"
|
||||
if state["calls"] % 2 == 1
|
||||
else f"Confirm. PHASE_STATUS: ready_to_advance — turn {state['calls']}."
|
||||
)
|
||||
return {
|
||||
"result": text,
|
||||
"session_id": sid,
|
||||
"usage": {"input_tokens": 100, "output_tokens": 80},
|
||||
"total_cost_usd": 0.5,
|
||||
"subtype": "success",
|
||||
"is_error": False,
|
||||
"duration_ms": 12000,
|
||||
}
|
||||
|
||||
return fake, state
|
||||
|
||||
|
||||
def _approved_for(slug, approved_path):
|
||||
data = json.loads(approved_path.read_text())
|
||||
for p in data["projects"]:
|
||||
if p["name"] == slug:
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The walkthrough
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_full_planning_walkthrough_with_ui_scope(tmp_e2e):
|
||||
slug = "game-library"
|
||||
description = "Adaug un filtru de genuri pe pagina principală a game-library"
|
||||
channel = "discord-channel-1"
|
||||
adapter = "discord"
|
||||
|
||||
fake, calls = _fake_run_claude_factory()
|
||||
|
||||
with patch.object(planning_session, "_run_claude", fake):
|
||||
# Step 1+2+3: user clicks Planifică and types description (modal submit) →
|
||||
# Discord/Telegram callback invokes start_planning_session.
|
||||
first_text = router.start_planning_session(slug, description, channel, adapter)
|
||||
|
||||
assert "turn" in first_text.lower()
|
||||
# Status moved to "planning"
|
||||
entry = _approved_for(slug, tmp_e2e["approved"])
|
||||
assert entry is not None
|
||||
assert entry["status"] == "planning"
|
||||
assert entry["planning_session_id"] is not None
|
||||
|
||||
# 4 phases planned because description has UI scope
|
||||
state = planning_session.get_planning_state(adapter, channel)
|
||||
assert state is not None
|
||||
assert state["phases_planned"] == [
|
||||
"/office-hours",
|
||||
"/plan-ceo-review",
|
||||
"/plan-eng-review",
|
||||
"/plan-design-review",
|
||||
]
|
||||
assert state["phase"] == "/office-hours"
|
||||
|
||||
# Step 5: user replies with a plain message → route_message detects
|
||||
# planning state and routes to orchestrator (not chat fallback). Plain
|
||||
# planning messages return is_cmd=False (still a "Claude response"-style)
|
||||
# but they MUST hit the orchestrator subprocess, not the main chat path.
|
||||
prior_calls = calls["calls"]
|
||||
response, _is_cmd = router.route_message(
|
||||
channel, "user-1",
|
||||
"Vreau filtru pe pagina principală cu RPG/FPS/MMO checkboxes",
|
||||
adapter_name=adapter,
|
||||
)
|
||||
assert calls["calls"] == prior_calls + 1, "respond should spawn 1 subprocess"
|
||||
assert response # non-empty response from agent
|
||||
|
||||
# Step 6+7: walk through the 3 remaining phases via advance().
|
||||
# Each advance kicks off a fresh subprocess.
|
||||
prev_calls = calls["calls"]
|
||||
for expected_phase in ("/plan-ceo-review", "/plan-eng-review", "/plan-design-review"):
|
||||
session, text, completed = planning_orchestrator.PlanningOrchestrator.advance(
|
||||
adapter, channel,
|
||||
)
|
||||
assert completed is False, f"phase {expected_phase} marked complete prematurely"
|
||||
state = planning_session.get_planning_state(adapter, channel)
|
||||
assert state["phase"] == expected_phase
|
||||
assert calls["calls"] == prev_calls + 1, "advance should spawn 1 fresh subprocess"
|
||||
prev_calls = calls["calls"]
|
||||
|
||||
# Step 8: one more advance — pipeline complete; orchestrator writes final-plan.md stub
|
||||
session, summary, completed = planning_orchestrator.PlanningOrchestrator.advance(
|
||||
adapter, channel,
|
||||
)
|
||||
assert completed is True
|
||||
final_plan = tmp_e2e["workspace"] / slug / "scripts" / "ralph" / "final-plan.md"
|
||||
assert final_plan.exists(), "final-plan.md stub trebuie scris la pipeline complet"
|
||||
body = final_plan.read_text(encoding="utf-8")
|
||||
assert slug in body # stub mentions project
|
||||
# All 4 phases recorded as completed
|
||||
state = planning_session.get_planning_state(adapter, channel)
|
||||
assert set(state["phases_completed"]) == {
|
||||
"/office-hours",
|
||||
"/plan-ceo-review",
|
||||
"/plan-eng-review",
|
||||
"/plan-design-review",
|
||||
}
|
||||
assert state["final_plan_path"] == str(final_plan)
|
||||
|
||||
# Step 9+10: user clicks "Dau drumul tonight" → planning_approve.
|
||||
approval_msg = router._approve_from_planning(channel, adapter)
|
||||
assert "aprobat" in approval_msg.lower() or "tonight" in approval_msg.lower()
|
||||
|
||||
# Step 11: approved-tasks.json has all the fields night-execute needs.
|
||||
entry = _approved_for(slug, tmp_e2e["approved"])
|
||||
assert entry["status"] == "approved"
|
||||
assert entry["approved_at"] is not None
|
||||
assert entry["final_plan_path"] == str(final_plan)
|
||||
assert entry["description"] == description
|
||||
# planning_session_id is cleared once approved (no longer needed)
|
||||
assert entry.get("planning_session_id") in (None, "")
|
||||
|
||||
|
||||
def test_full_walkthrough_no_ui_scope_skips_design_phase(tmp_e2e):
|
||||
"""Description without UI keywords should plan only 3 phases."""
|
||||
slug = "game-library"
|
||||
description = "Refactor utility helpers — split string parsing into a separate module"
|
||||
channel = "discord-channel-2"
|
||||
adapter = "discord"
|
||||
|
||||
fake, calls = _fake_run_claude_factory()
|
||||
|
||||
with patch.object(planning_session, "_run_claude", fake):
|
||||
router.start_planning_session(slug, description, channel, adapter)
|
||||
state = planning_session.get_planning_state(adapter, channel)
|
||||
|
||||
assert state["phases_planned"] == [
|
||||
"/office-hours",
|
||||
"/plan-ceo-review",
|
||||
"/plan-eng-review",
|
||||
]
|
||||
assert "/plan-design-review" not in state["phases_planned"]
|
||||
|
||||
|
||||
def test_walkthrough_cancel_mid_planning_reverts_to_pending(tmp_e2e):
|
||||
"""User abandons planning via /cancel → status reverts to pending, state cleared."""
|
||||
slug = "game-library"
|
||||
description = "Adaug pagina de profile cu avatar editing"
|
||||
channel = "discord-channel-3"
|
||||
adapter = "discord"
|
||||
|
||||
fake, _calls = _fake_run_claude_factory()
|
||||
|
||||
with patch.object(planning_session, "_run_claude", fake):
|
||||
router.start_planning_session(slug, description, channel, adapter)
|
||||
|
||||
# Verify planning is active
|
||||
state = planning_session.get_planning_state(adapter, channel)
|
||||
assert state is not None
|
||||
entry = _approved_for(slug, tmp_e2e["approved"])
|
||||
assert entry["status"] == "planning"
|
||||
|
||||
# User types /cancel (router routes to cancel handler)
|
||||
response, is_cmd = router.route_message(
|
||||
channel, "user-1", "/cancel", adapter_name=adapter,
|
||||
)
|
||||
assert is_cmd is True
|
||||
|
||||
# State cleared
|
||||
assert planning_session.get_planning_state(adapter, channel) is None
|
||||
# Status reverted to pending
|
||||
entry = _approved_for(slug, tmp_e2e["approved"])
|
||||
assert entry["status"] == "pending"
|
||||
assert entry.get("planning_session_id") in (None, "")
|
||||
|
||||
|
||||
def test_walkthrough_no_planning_state_falls_through_to_normal_chat(tmp_e2e):
|
||||
"""Plain message without active planning should NOT touch orchestrator."""
|
||||
fake, calls = _fake_run_claude_factory()
|
||||
|
||||
with patch.object(planning_session, "_run_claude", fake), \
|
||||
patch("src.router.send_message") as mock_send:
|
||||
mock_send.return_value = "(claude main session response)"
|
||||
# No prior start_planning_session — plain message goes to normal Claude
|
||||
response, is_cmd = router.route_message(
|
||||
"channel-no-plan", "user-1", "salut, ce mai faci?",
|
||||
adapter_name="discord",
|
||||
)
|
||||
assert is_cmd is False # normal chat, not a command
|
||||
# Orchestrator subprocess NOT invoked
|
||||
assert calls["calls"] == 0
|
||||
# Normal send_message WAS invoked
|
||||
mock_send.assert_called_once()
|
||||
366
tests/test_ralph_usage.py
Normal file
366
tests/test_ralph_usage.py
Normal file
@@ -0,0 +1,366 @@
|
||||
"""Tests for tools/ralph_usage.py — rate limit budget tracking.
|
||||
|
||||
Acoperă:
|
||||
- extract_usage_entry: shape corect, missing fields, JSON corupt → None
|
||||
- parse_usage_jsonl: skip linii corupte, file lipsă → []
|
||||
- aggregate_by_day / aggregate_by_project: sume corecte, deduplicare
|
||||
- filter_by_days: window inclusiv vs exclusiv
|
||||
- summarize: today_cost/today_runs corecte
|
||||
- append_entry: atomic write, JSONL roundtrip
|
||||
- CLI append: idempotent la JSON corupt (no-op + exit 0)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
TOOLS = PROJECT_ROOT / "tools"
|
||||
if str(TOOLS) not in sys.path:
|
||||
sys.path.insert(0, str(TOOLS))
|
||||
|
||||
import ralph_usage # noqa: E402
|
||||
|
||||
|
||||
# ── Sample claude -p --output-format json envelopes ────────────────
|
||||
|
||||
|
||||
def _claude_envelope(
|
||||
*,
|
||||
cost: float = 0.55,
|
||||
input_tokens: int = 1234,
|
||||
output_tokens: int = 567,
|
||||
cache_read: int = 890,
|
||||
duration_ms: int = 49000,
|
||||
model: str = "claude-opus-4-7-20260101",
|
||||
) -> dict:
|
||||
return {
|
||||
"type": "result",
|
||||
"subtype": "completed",
|
||||
"session_id": "abc123",
|
||||
"result": "Story implementat",
|
||||
"is_error": False,
|
||||
"total_cost_usd": cost,
|
||||
"duration_ms": duration_ms,
|
||||
"num_turns": 5,
|
||||
"usage": {
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": cache_read,
|
||||
},
|
||||
"model": model,
|
||||
}
|
||||
|
||||
|
||||
# ── extract_usage_entry ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExtractEntry:
|
||||
def test_full_envelope_extracts_all_fields(self):
|
||||
env = _claude_envelope()
|
||||
entry = ralph_usage.extract_usage_entry(
|
||||
env, slug="proj-a", story_id="US-001", iter_n=3,
|
||||
ts="2026-04-26T12:00:00+00:00",
|
||||
)
|
||||
assert entry == {
|
||||
"ts": "2026-04-26T12:00:00+00:00",
|
||||
"slug": "proj-a",
|
||||
"story_id": "US-001",
|
||||
"iter": 3,
|
||||
"total_cost_usd": 0.55,
|
||||
"input_tokens": 1234,
|
||||
"output_tokens": 567,
|
||||
"cache_read": 890,
|
||||
"model": "claude-opus-4-7-20260101",
|
||||
"duration_ms": 49000,
|
||||
}
|
||||
|
||||
def test_accepts_raw_string(self):
|
||||
env = _claude_envelope()
|
||||
entry = ralph_usage.extract_usage_entry(
|
||||
json.dumps(env), slug="x", story_id=None, iter_n=None,
|
||||
ts="2026-04-26T00:00:00+00:00",
|
||||
)
|
||||
assert entry is not None
|
||||
assert entry["story_id"] is None
|
||||
assert entry["iter"] is None
|
||||
assert entry["total_cost_usd"] == 0.55
|
||||
|
||||
def test_corrupt_json_returns_none(self):
|
||||
assert ralph_usage.extract_usage_entry("{not json", slug="x") is None
|
||||
assert ralph_usage.extract_usage_entry("", slug="x") is None
|
||||
assert ralph_usage.extract_usage_entry("null", slug="x") is None
|
||||
|
||||
def test_missing_usage_field_zeros(self):
|
||||
env = {"total_cost_usd": 0.1, "duration_ms": 1000}
|
||||
entry = ralph_usage.extract_usage_entry(env, slug="x")
|
||||
assert entry["input_tokens"] == 0
|
||||
assert entry["output_tokens"] == 0
|
||||
assert entry["cache_read"] == 0
|
||||
assert entry["model"] is None
|
||||
|
||||
def test_missing_cost_defaults_zero(self):
|
||||
env = {"usage": {"input_tokens": 100}}
|
||||
entry = ralph_usage.extract_usage_entry(env, slug="x")
|
||||
assert entry["total_cost_usd"] == 0.0
|
||||
assert entry["input_tokens"] == 100
|
||||
|
||||
def test_non_dict_returns_none(self):
|
||||
assert ralph_usage.extract_usage_entry([], slug="x") is None
|
||||
assert ralph_usage.extract_usage_entry(123, slug="x") is None
|
||||
|
||||
def test_alternative_cache_field_name(self):
|
||||
# Defensive: dacă viitor schema folosește `cache_read`
|
||||
env = {"usage": {"cache_read": 42}, "total_cost_usd": 0.1}
|
||||
entry = ralph_usage.extract_usage_entry(env, slug="x")
|
||||
assert entry["cache_read"] == 42
|
||||
|
||||
|
||||
# ── parse_usage_jsonl ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestParseJsonl:
|
||||
def test_file_missing_returns_empty(self, tmp_path):
|
||||
assert ralph_usage.parse_usage_jsonl(tmp_path / "ghost.jsonl") == []
|
||||
|
||||
def test_skips_corrupt_lines(self, tmp_path):
|
||||
p = tmp_path / "u.jsonl"
|
||||
p.write_text(
|
||||
'{"slug": "a", "ts": "2026-04-26T00:00:00+00:00", "total_cost_usd": 0.1}\n'
|
||||
"{not json}\n"
|
||||
'{"slug": "b", "ts": "2026-04-26T01:00:00+00:00", "total_cost_usd": 0.2}\n'
|
||||
"\n"
|
||||
"[]\n", # not a dict
|
||||
encoding="utf-8",
|
||||
)
|
||||
entries = ralph_usage.parse_usage_jsonl(p)
|
||||
slugs = [e["slug"] for e in entries]
|
||||
assert slugs == ["a", "b"]
|
||||
|
||||
def test_empty_file_returns_empty(self, tmp_path):
|
||||
p = tmp_path / "u.jsonl"
|
||||
p.write_text("", encoding="utf-8")
|
||||
assert ralph_usage.parse_usage_jsonl(p) == []
|
||||
|
||||
|
||||
# ── aggregate_by_day / aggregate_by_project ───────────────────────
|
||||
|
||||
|
||||
class TestAggregate:
|
||||
@pytest.fixture
|
||||
def entries(self):
|
||||
return [
|
||||
{"slug": "proj-a", "ts": "2026-04-26T10:00:00+00:00",
|
||||
"total_cost_usd": 0.5, "input_tokens": 100, "output_tokens": 50, "cache_read": 200},
|
||||
{"slug": "proj-a", "ts": "2026-04-26T11:00:00+00:00",
|
||||
"total_cost_usd": 0.3, "input_tokens": 80, "output_tokens": 30, "cache_read": 100},
|
||||
{"slug": "proj-b", "ts": "2026-04-25T22:00:00+00:00",
|
||||
"total_cost_usd": 1.2, "input_tokens": 500, "output_tokens": 200, "cache_read": 0},
|
||||
]
|
||||
|
||||
def test_aggregate_by_day(self, entries):
|
||||
result = ralph_usage.aggregate_by_day(entries)
|
||||
assert result["2026-04-26"]["cost_usd"] == 0.8
|
||||
assert result["2026-04-26"]["runs"] == 2
|
||||
assert result["2026-04-26"]["input_tokens"] == 180
|
||||
assert result["2026-04-26"]["output_tokens"] == 80
|
||||
assert result["2026-04-26"]["cache_read"] == 300
|
||||
assert result["2026-04-25"]["cost_usd"] == 1.2
|
||||
assert result["2026-04-25"]["runs"] == 1
|
||||
# Sortare descrescătoare în iteration order
|
||||
keys = list(result.keys())
|
||||
assert keys == ["2026-04-26", "2026-04-25"]
|
||||
|
||||
def test_aggregate_by_project(self, entries):
|
||||
result = ralph_usage.aggregate_by_project(entries)
|
||||
assert result["proj-a"]["cost_usd"] == 0.8
|
||||
assert result["proj-a"]["runs"] == 2
|
||||
assert result["proj-b"]["cost_usd"] == 1.2
|
||||
assert result["proj-b"]["runs"] == 1
|
||||
|
||||
def test_aggregate_handles_missing_slug(self):
|
||||
entries = [{"ts": "2026-04-26T00:00:00+00:00", "total_cost_usd": 0.1}]
|
||||
result = ralph_usage.aggregate_by_project(entries)
|
||||
assert "unknown" in result
|
||||
|
||||
def test_aggregate_handles_missing_ts(self):
|
||||
entries = [{"slug": "x", "total_cost_usd": 0.1}]
|
||||
# Missing ts → skipped from by_day
|
||||
result = ralph_usage.aggregate_by_day(entries)
|
||||
assert result == {}
|
||||
|
||||
def test_aggregate_empty_entries(self):
|
||||
assert ralph_usage.aggregate_by_day([]) == {}
|
||||
assert ralph_usage.aggregate_by_project([]) == {}
|
||||
|
||||
|
||||
# ── filter_by_days ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFilterByDays:
|
||||
def test_window_inclusive_today(self):
|
||||
entries = [
|
||||
{"ts": "2026-04-26T00:00:00+00:00", "slug": "a"},
|
||||
{"ts": "2026-04-25T00:00:00+00:00", "slug": "a"},
|
||||
{"ts": "2026-04-20T00:00:00+00:00", "slug": "a"},
|
||||
]
|
||||
kept = ralph_usage.filter_by_days(entries, 7, today="2026-04-26")
|
||||
# 7-day window inclusiv de la today: 2026-04-20 .. 2026-04-26
|
||||
slugs = [e["ts"][:10] for e in kept]
|
||||
assert slugs == ["2026-04-26", "2026-04-25", "2026-04-20"]
|
||||
|
||||
def test_window_exclusive_older(self):
|
||||
entries = [
|
||||
{"ts": "2026-04-26T00:00:00+00:00"},
|
||||
{"ts": "2026-04-19T00:00:00+00:00"}, # 7 days before today → exclus
|
||||
]
|
||||
kept = ralph_usage.filter_by_days(entries, 7, today="2026-04-26")
|
||||
assert len(kept) == 1
|
||||
assert kept[0]["ts"] == "2026-04-26T00:00:00+00:00"
|
||||
|
||||
def test_zero_days_empty(self):
|
||||
entries = [{"ts": "2026-04-26T00:00:00+00:00"}]
|
||||
assert ralph_usage.filter_by_days(entries, 0, today="2026-04-26") == []
|
||||
|
||||
def test_corrupt_ts_skipped(self):
|
||||
entries = [{"ts": "garbage"}]
|
||||
assert ralph_usage.filter_by_days(entries, 7, today="2026-04-26") == []
|
||||
|
||||
|
||||
# ── summarize ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSummarize:
|
||||
def test_summary_shape_and_today_split(self):
|
||||
entries = [
|
||||
{"ts": "2026-04-26T10:00:00+00:00", "slug": "a", "total_cost_usd": 0.5,
|
||||
"input_tokens": 100, "output_tokens": 50, "cache_read": 0},
|
||||
{"ts": "2026-04-26T11:00:00+00:00", "slug": "a", "total_cost_usd": 0.3,
|
||||
"input_tokens": 80, "output_tokens": 30, "cache_read": 0},
|
||||
{"ts": "2026-04-25T00:00:00+00:00", "slug": "b", "total_cost_usd": 1.0,
|
||||
"input_tokens": 0, "output_tokens": 0, "cache_read": 0},
|
||||
]
|
||||
s = ralph_usage.summarize(entries, days=7, today="2026-04-26")
|
||||
assert s["today"] == "2026-04-26"
|
||||
assert s["today_cost"] == 0.8
|
||||
assert s["today_runs"] == 2
|
||||
assert s["window_days"] == 7
|
||||
assert s["window_runs"] == 3
|
||||
assert "by_project" in s
|
||||
assert "by_day" in s
|
||||
assert s["total_runs"] == 3
|
||||
assert s["by_project"]["a"]["runs"] == 2
|
||||
assert s["by_project"]["b"]["runs"] == 1
|
||||
|
||||
def test_summary_empty_entries(self):
|
||||
s = ralph_usage.summarize([], days=7, today="2026-04-26")
|
||||
assert s["today_cost"] == 0
|
||||
assert s["today_runs"] == 0
|
||||
assert s["by_project"] == {}
|
||||
assert s["by_day"] == {}
|
||||
assert s["total_runs"] == 0
|
||||
|
||||
|
||||
# ── append_entry ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAppendEntry:
|
||||
def test_append_creates_file_with_jsonl_format(self, tmp_path):
|
||||
usage = tmp_path / "usage.jsonl"
|
||||
entry = {"slug": "x", "ts": "2026-04-26T00:00:00+00:00", "total_cost_usd": 0.1}
|
||||
ralph_usage.append_entry(usage, entry)
|
||||
text = usage.read_text(encoding="utf-8")
|
||||
assert text.endswith("\n")
|
||||
loaded = json.loads(text.strip())
|
||||
assert loaded == entry
|
||||
|
||||
def test_append_preserves_existing_entries(self, tmp_path):
|
||||
usage = tmp_path / "usage.jsonl"
|
||||
usage.write_text(
|
||||
'{"slug": "a", "ts": "2026-04-25T00:00:00+00:00", "total_cost_usd": 0.5}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
ralph_usage.append_entry(usage, {"slug": "b", "ts": "2026-04-26T00:00:00+00:00",
|
||||
"total_cost_usd": 0.3})
|
||||
entries = ralph_usage.parse_usage_jsonl(usage)
|
||||
assert len(entries) == 2
|
||||
assert entries[0]["slug"] == "a"
|
||||
assert entries[1]["slug"] == "b"
|
||||
|
||||
def test_append_handles_missing_trailing_newline(self, tmp_path):
|
||||
usage = tmp_path / "usage.jsonl"
|
||||
usage.write_text(
|
||||
'{"slug": "a", "ts": "2026-04-25T00:00:00+00:00"}', # no trailing \n
|
||||
encoding="utf-8",
|
||||
)
|
||||
ralph_usage.append_entry(usage, {"slug": "b", "ts": "2026-04-26T00:00:00+00:00"})
|
||||
entries = ralph_usage.parse_usage_jsonl(usage)
|
||||
assert [e["slug"] for e in entries] == ["a", "b"]
|
||||
|
||||
|
||||
# ── CLI: append subcommand ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCliAppend:
|
||||
def test_append_from_log_file(self, tmp_path):
|
||||
log = tmp_path / "iter.log"
|
||||
log.write_text(json.dumps(_claude_envelope(cost=0.42)), encoding="utf-8")
|
||||
usage = tmp_path / "usage.jsonl"
|
||||
|
||||
rc = ralph_usage.main([
|
||||
"append", str(usage), str(log),
|
||||
"--slug", "proj-a",
|
||||
"--story-id", "US-001",
|
||||
"--iter", "3",
|
||||
])
|
||||
assert rc == 0
|
||||
entries = ralph_usage.parse_usage_jsonl(usage)
|
||||
assert len(entries) == 1
|
||||
e = entries[0]
|
||||
assert e["slug"] == "proj-a"
|
||||
assert e["story_id"] == "US-001"
|
||||
assert e["iter"] == 3
|
||||
assert e["total_cost_usd"] == 0.42
|
||||
|
||||
def test_append_corrupt_log_no_op(self, tmp_path):
|
||||
log = tmp_path / "iter.log"
|
||||
log.write_text("not json", encoding="utf-8")
|
||||
usage = tmp_path / "usage.jsonl"
|
||||
|
||||
rc = ralph_usage.main([
|
||||
"append", str(usage), str(log),
|
||||
"--slug", "proj-a",
|
||||
])
|
||||
# Idempotent: corrupt JSON → exit 0, no entry written
|
||||
assert rc == 0
|
||||
assert not usage.exists() or ralph_usage.parse_usage_jsonl(usage) == []
|
||||
|
||||
def test_append_missing_log_no_op(self, tmp_path):
|
||||
usage = tmp_path / "usage.jsonl"
|
||||
rc = ralph_usage.main([
|
||||
"append", str(usage), str(tmp_path / "missing.log"),
|
||||
"--slug", "x",
|
||||
])
|
||||
assert rc == 0
|
||||
|
||||
|
||||
# ── CLI: summarize subcommand ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestCliSummarize:
|
||||
def test_summarize_outputs_json(self, tmp_path, capsys):
|
||||
usage = tmp_path / "usage.jsonl"
|
||||
usage.write_text(
|
||||
json.dumps({"slug": "x", "ts": "2026-04-26T00:00:00+00:00", "total_cost_usd": 0.5}) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
rc = ralph_usage.main(["summarize", str(usage), "--days", "7"])
|
||||
assert rc == 0
|
||||
out = json.loads(capsys.readouterr().out)
|
||||
assert "today" in out
|
||||
assert "by_project" in out
|
||||
assert "by_day" in out
|
||||
139
tests/test_whatsapp_keywords.py
Normal file
139
tests/test_whatsapp_keywords.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Tests for WhatsApp text-keyword commands → slash translation.
|
||||
|
||||
Acoperă `_translate_whatsapp_text` și integrarea cu `_try_ralph_dispatch`:
|
||||
- aprob / aprob <slug>
|
||||
- stop <slug>
|
||||
- stare / stare <slug>
|
||||
- case-insensitive pe keyword
|
||||
- Discord/Telegram NU sunt afectate
|
||||
- propose intentionally NOT supported
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.router import _translate_whatsapp_text, _try_ralph_dispatch
|
||||
|
||||
|
||||
# ── _translate_whatsapp_text (pure helper) ────────────────────────
|
||||
|
||||
|
||||
class TestTranslate:
|
||||
def test_aprob_alone_lists_pending(self):
|
||||
# `aprob` fără slug → /a (listează pending)
|
||||
assert _translate_whatsapp_text("aprob") == "/a"
|
||||
|
||||
def test_aprob_with_slug(self):
|
||||
assert _translate_whatsapp_text("aprob roa2web") == "/a roa2web"
|
||||
|
||||
def test_aprob_case_insensitive(self):
|
||||
assert _translate_whatsapp_text("APROB roa2web") == "/a roa2web"
|
||||
assert _translate_whatsapp_text("Aprob roa2web") == "/a roa2web"
|
||||
|
||||
def test_stop_with_slug(self):
|
||||
assert _translate_whatsapp_text("stop roa2web") == "/k roa2web"
|
||||
|
||||
def test_stop_case_insensitive(self):
|
||||
assert _translate_whatsapp_text("STOP roa2web") == "/k roa2web"
|
||||
|
||||
def test_stop_alone_not_translated(self):
|
||||
# `stop` fără slug poate fi colocvial → nu translatăm
|
||||
assert _translate_whatsapp_text("stop") is None
|
||||
|
||||
def test_stare_alone(self):
|
||||
assert _translate_whatsapp_text("stare") == "/l"
|
||||
|
||||
def test_stare_with_slug(self):
|
||||
assert _translate_whatsapp_text("stare roa2web") == "/l roa2web"
|
||||
|
||||
def test_stare_case_insensitive(self):
|
||||
assert _translate_whatsapp_text("STARE") == "/l"
|
||||
|
||||
def test_other_text_not_translated(self):
|
||||
assert _translate_whatsapp_text("hello") is None
|
||||
assert _translate_whatsapp_text("ce mai faci") is None
|
||||
assert _translate_whatsapp_text("propose roa2web descriere") is None
|
||||
# Slash commands pass through unchanged (None — don't override)
|
||||
assert _translate_whatsapp_text("/a") is None
|
||||
|
||||
def test_empty_input(self):
|
||||
assert _translate_whatsapp_text("") is None
|
||||
assert _translate_whatsapp_text(" ") is None
|
||||
|
||||
def test_propose_not_covered(self):
|
||||
# Verifică explicit că nu acoperim propose (descrierea fragilă)
|
||||
assert _translate_whatsapp_text("propose foo bar baz") is None
|
||||
assert _translate_whatsapp_text("propune foo bar baz") is None
|
||||
|
||||
|
||||
# ── Integration: _try_ralph_dispatch with adapter_name ────────────
|
||||
|
||||
|
||||
class TestDispatchIntegration:
|
||||
def test_whatsapp_aprob_routes_to_approve(self):
|
||||
# `aprob` pe whatsapp → trebuie să intre în Ralph dispatch
|
||||
with patch("src.router._ralph_approve") as mock:
|
||||
mock.return_value = "ok"
|
||||
result = _try_ralph_dispatch("aprob foo", adapter_name="whatsapp")
|
||||
assert result == "ok"
|
||||
mock.assert_called_once_with(["foo"])
|
||||
|
||||
def test_whatsapp_stop_routes_to_stop(self):
|
||||
with patch("src.router._ralph_stop") as mock:
|
||||
mock.return_value = "stopped"
|
||||
result = _try_ralph_dispatch("stop foo", adapter_name="whatsapp")
|
||||
assert result == "stopped"
|
||||
mock.assert_called_once_with("foo")
|
||||
|
||||
def test_whatsapp_stare_routes_to_status(self):
|
||||
with patch("src.router._ralph_status") as mock:
|
||||
mock.return_value = "status"
|
||||
result = _try_ralph_dispatch("stare", adapter_name="whatsapp")
|
||||
# Status returnează cu redirect hint pe whatsapp
|
||||
assert "status" in result
|
||||
mock.assert_called_once_with(None)
|
||||
|
||||
def test_whatsapp_stare_with_slug(self):
|
||||
with patch("src.router._ralph_status") as mock:
|
||||
mock.return_value = "status"
|
||||
_try_ralph_dispatch("stare roa2web", adapter_name="whatsapp")
|
||||
mock.assert_called_once_with("roa2web")
|
||||
|
||||
def test_discord_keyword_not_translated(self):
|
||||
# Pe Discord, "stop foo" NU ar trebui să match — nu e adapter whatsapp
|
||||
with patch("src.router._ralph_stop") as mock:
|
||||
result = _try_ralph_dispatch("stop foo", adapter_name="discord")
|
||||
assert result is None
|
||||
mock.assert_not_called()
|
||||
|
||||
def test_telegram_keyword_not_translated(self):
|
||||
with patch("src.router._ralph_approve") as mock:
|
||||
result = _try_ralph_dispatch("aprob foo", adapter_name="telegram")
|
||||
assert result is None
|
||||
mock.assert_not_called()
|
||||
|
||||
def test_no_adapter_keyword_not_translated(self):
|
||||
# adapter_name=None → nu e whatsapp → no translation
|
||||
with patch("src.router._ralph_approve") as mock:
|
||||
result = _try_ralph_dispatch("aprob foo", adapter_name=None)
|
||||
assert result is None
|
||||
mock.assert_not_called()
|
||||
|
||||
def test_whatsapp_slash_command_still_works(self):
|
||||
# Slash-uri normale pe WhatsApp NU trebuie sparte de translation
|
||||
with patch("src.router._ralph_approve") as mock:
|
||||
mock.return_value = "ok"
|
||||
result = _try_ralph_dispatch("/a foo", adapter_name="whatsapp")
|
||||
assert result == "ok"
|
||||
mock.assert_called_once_with(["foo"])
|
||||
|
||||
def test_whatsapp_chat_message_passthrough(self):
|
||||
# Mesajul normal pe whatsapp (fără keyword) → None (cade pe Claude)
|
||||
result = _try_ralph_dispatch("hello echo, ce mai faci", adapter_name="whatsapp")
|
||||
assert result is None
|
||||
@@ -39,6 +39,21 @@ else
|
||||
DAG_HELPER=""
|
||||
fi
|
||||
|
||||
# Usage helper auto-detect (rate limit budget tracking — best effort, niciodată
|
||||
# blochează rularea Ralph dacă lipsește)
|
||||
if [ -n "$RALPH_USAGE_HELPER" ] && [ -f "$RALPH_USAGE_HELPER" ]; then
|
||||
USAGE_HELPER="$RALPH_USAGE_HELPER"
|
||||
elif [ -f "/home/moltbot/echo-core/tools/ralph_usage.py" ]; then
|
||||
USAGE_HELPER="/home/moltbot/echo-core/tools/ralph_usage.py"
|
||||
elif [ -f "/home/moltbot/echo-core-instr/tools/ralph_usage.py" ]; then
|
||||
USAGE_HELPER="/home/moltbot/echo-core-instr/tools/ralph_usage.py"
|
||||
elif [ -f "$SCRIPT_DIR/ralph_usage.py" ]; then
|
||||
USAGE_HELPER="$SCRIPT_DIR/ralph_usage.py"
|
||||
else
|
||||
USAGE_HELPER=""
|
||||
fi
|
||||
USAGE_FILE="$SCRIPT_DIR/usage.jsonl"
|
||||
|
||||
# Verifică că jq este instalat
|
||||
if ! command -v jq &> /dev/null; then
|
||||
echo "Eroare: jq nu este instalat. Rulează: apt install jq"
|
||||
@@ -292,6 +307,15 @@ EOF
|
||||
set -e
|
||||
OUTPUT=$(cat "$LOG_FILE")
|
||||
|
||||
# Rate limit budget tracking (best-effort, never blocks Ralph)
|
||||
if [ -n "$USAGE_HELPER" ]; then
|
||||
"$RALPH_PYTHON" "$USAGE_HELPER" append \
|
||||
"$USAGE_FILE" "$LOG_FILE" \
|
||||
--slug "$PROJECT_NAME" \
|
||||
--story-id "$CURRENT_STORY" \
|
||||
--iter "$i" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# W3: rate limit detection (max 1 retry per rulare)
|
||||
if is_rate_limited "$OUTPUT" || [ "$CLAUDE_EXIT" = "29" ]; then
|
||||
if [ "$RATE_LIMIT_RETRY_USED" = "0" ]; then
|
||||
|
||||
384
tools/ralph_usage.py
Executable file
384
tools/ralph_usage.py
Executable file
@@ -0,0 +1,384 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ralph usage tracking — rate limit budget MVP.
|
||||
|
||||
Two responsabilități:
|
||||
1. **Pure functions** (testable, no side-effects): parse usage JSONL, aggregate
|
||||
by day / by project, summarize for dashboard.
|
||||
2. **CLI subcommands** (chemate din `tools/ralph/ralph.sh` după fiecare iter):
|
||||
atomic append usage entry derivat din `claude -p --output-format json`.
|
||||
|
||||
JSON envelope produs de `claude -p --output-format json`:
|
||||
|
||||
{
|
||||
"type": "result",
|
||||
"subtype": "completed" | "error_max_turns" | ...,
|
||||
"session_id": "...",
|
||||
"result": "...",
|
||||
"is_error": false,
|
||||
"total_cost_usd": 0.55,
|
||||
"duration_ms": 49000,
|
||||
"num_turns": 5,
|
||||
"usage": {
|
||||
"input_tokens": 1234,
|
||||
"output_tokens": 567,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 890
|
||||
},
|
||||
"model": "claude-opus-4-7-...", // poate lipsi
|
||||
...
|
||||
}
|
||||
|
||||
Usage entry shape (one per JSONL line):
|
||||
|
||||
{
|
||||
"ts": "2026-04-26T12:00:00+00:00",
|
||||
"slug": "roa2web",
|
||||
"story_id": "US-001", // null dacă necunoscut
|
||||
"iter": 3, // null dacă necunoscut
|
||||
"total_cost_usd": 0.55,
|
||||
"input_tokens": 1234,
|
||||
"output_tokens": 567,
|
||||
"cache_read": 890,
|
||||
"model": "claude-opus-4-7-...",
|
||||
"duration_ms": 49000
|
||||
}
|
||||
|
||||
CLI subcommands:
|
||||
|
||||
python3 ralph_usage.py append <usage_jsonl> <claude_log> \\
|
||||
--slug <slug> [--story-id <id>] [--iter <N>]
|
||||
→ parse claude_log, atomic append entry in usage_jsonl. Idempotent
|
||||
la JSON corupt (no-op + exit 0).
|
||||
|
||||
python3 ralph_usage.py summarize <usage_jsonl> [--days N]
|
||||
→ print JSON summary {today_cost, today_runs, by_project, by_day, ...}.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure functions — extract / parse / aggregate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_usage_entry(
|
||||
claude_json: dict | str,
|
||||
*,
|
||||
slug: str,
|
||||
story_id: str | None = None,
|
||||
iter_n: int | None = None,
|
||||
ts: str | None = None,
|
||||
) -> dict | None:
|
||||
"""Build a usage entry from a claude -p JSON envelope.
|
||||
|
||||
Acceptă dict deja parsat sau raw string. Returnează None dacă inputul
|
||||
nu poate fi parsat sau nu e un dict (anti-corruption).
|
||||
|
||||
Pure: no I/O, no side effects.
|
||||
"""
|
||||
if isinstance(claude_json, str):
|
||||
try:
|
||||
claude_json = json.loads(claude_json)
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
return None
|
||||
if not isinstance(claude_json, dict):
|
||||
return None
|
||||
|
||||
usage = claude_json.get("usage") or {}
|
||||
if not isinstance(usage, dict):
|
||||
usage = {}
|
||||
|
||||
return {
|
||||
"ts": ts or datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"slug": slug,
|
||||
"story_id": story_id if story_id else None,
|
||||
"iter": int(iter_n) if iter_n is not None else None,
|
||||
"total_cost_usd": _coerce_float(claude_json.get("total_cost_usd")),
|
||||
"input_tokens": _coerce_int(usage.get("input_tokens")),
|
||||
"output_tokens": _coerce_int(usage.get("output_tokens")),
|
||||
"cache_read": _coerce_int(
|
||||
usage.get("cache_read_input_tokens") or usage.get("cache_read") or 0
|
||||
),
|
||||
"model": str(claude_json.get("model") or "") or None,
|
||||
"duration_ms": _coerce_int(claude_json.get("duration_ms")),
|
||||
}
|
||||
|
||||
|
||||
def _coerce_float(v: Any) -> float:
|
||||
try:
|
||||
return float(v) if v is not None else 0.0
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _coerce_int(v: Any) -> int:
|
||||
try:
|
||||
return int(v) if v is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def parse_usage_jsonl(path: Path | str) -> list[dict]:
|
||||
"""Read a JSONL file of usage entries. Skip corrupt lines silently.
|
||||
|
||||
Pure-ish (file I/O scoped to the path; no global state mutation).
|
||||
Întoarce listă goală dacă fișierul lipsește.
|
||||
"""
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
return []
|
||||
entries: list[dict] = []
|
||||
try:
|
||||
with p.open(encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(obj, dict):
|
||||
entries.append(obj)
|
||||
except OSError:
|
||||
return []
|
||||
return entries
|
||||
|
||||
|
||||
def _entry_day(entry: dict) -> str:
|
||||
"""Extract YYYY-MM-DD from entry.ts. Robust la formate timezone-aware/naive."""
|
||||
ts = entry.get("ts") or ""
|
||||
if not ts:
|
||||
return ""
|
||||
# Accept both `+00:00` and `Z`; the date prefix is the same.
|
||||
return ts[:10] if len(ts) >= 10 else ""
|
||||
|
||||
|
||||
def aggregate_by_day(entries: Iterable[dict]) -> dict[str, dict]:
|
||||
"""Aggregate usage by YYYY-MM-DD.
|
||||
|
||||
Returnează `{"2026-04-26": {cost_usd, runs, input_tokens, output_tokens, cache_read}}`.
|
||||
Stabilizat sortat cronologic (descending) când dict-urile sunt iterate.
|
||||
"""
|
||||
buckets: dict[str, dict] = defaultdict(
|
||||
lambda: {"cost_usd": 0.0, "runs": 0, "input_tokens": 0,
|
||||
"output_tokens": 0, "cache_read": 0}
|
||||
)
|
||||
for e in entries:
|
||||
day = _entry_day(e)
|
||||
if not day:
|
||||
continue
|
||||
b = buckets[day]
|
||||
b["cost_usd"] += _coerce_float(e.get("total_cost_usd"))
|
||||
b["runs"] += 1
|
||||
b["input_tokens"] += _coerce_int(e.get("input_tokens"))
|
||||
b["output_tokens"] += _coerce_int(e.get("output_tokens"))
|
||||
b["cache_read"] += _coerce_int(e.get("cache_read"))
|
||||
# round cost to 4 decimals to reduce float noise in JSON dump
|
||||
return {
|
||||
d: {**v, "cost_usd": round(v["cost_usd"], 4)}
|
||||
for d, v in sorted(buckets.items(), reverse=True)
|
||||
}
|
||||
|
||||
|
||||
def aggregate_by_project(entries: Iterable[dict]) -> dict[str, dict]:
|
||||
"""Aggregate usage by slug.
|
||||
|
||||
Returnează `{"roa2web": {cost_usd, runs, input_tokens, output_tokens, cache_read}}`.
|
||||
"""
|
||||
buckets: dict[str, dict] = defaultdict(
|
||||
lambda: {"cost_usd": 0.0, "runs": 0, "input_tokens": 0,
|
||||
"output_tokens": 0, "cache_read": 0}
|
||||
)
|
||||
for e in entries:
|
||||
slug = e.get("slug") or "unknown"
|
||||
b = buckets[slug]
|
||||
b["cost_usd"] += _coerce_float(e.get("total_cost_usd"))
|
||||
b["runs"] += 1
|
||||
b["input_tokens"] += _coerce_int(e.get("input_tokens"))
|
||||
b["output_tokens"] += _coerce_int(e.get("output_tokens"))
|
||||
b["cache_read"] += _coerce_int(e.get("cache_read"))
|
||||
return {
|
||||
s: {**v, "cost_usd": round(v["cost_usd"], 4)}
|
||||
for s, v in sorted(buckets.items())
|
||||
}
|
||||
|
||||
|
||||
def filter_by_days(entries: Iterable[dict], days: int, *, today: str | None = None) -> list[dict]:
|
||||
"""Keep only entries with ts within last `days` days (today inclusive).
|
||||
|
||||
`today` defaults to UTC current date (testabil prin override).
|
||||
`days <= 0` → entries goale.
|
||||
"""
|
||||
if days <= 0:
|
||||
return []
|
||||
today = today or datetime.now(timezone.utc).date().isoformat()
|
||||
try:
|
||||
today_dt = datetime.fromisoformat(today).date()
|
||||
except ValueError:
|
||||
return list(entries)
|
||||
out = []
|
||||
for e in entries:
|
||||
d = _entry_day(e)
|
||||
if not d:
|
||||
continue
|
||||
try:
|
||||
d_dt = datetime.fromisoformat(d).date()
|
||||
except ValueError:
|
||||
continue
|
||||
delta = (today_dt - d_dt).days
|
||||
if 0 <= delta < days:
|
||||
out.append(e)
|
||||
return out
|
||||
|
||||
|
||||
def summarize(
|
||||
entries: list[dict],
|
||||
*,
|
||||
days: int = 7,
|
||||
today: str | None = None,
|
||||
) -> dict:
|
||||
"""Build summary {today_cost, today_runs, by_project, by_day, total_cost, total_runs}.
|
||||
|
||||
`today` defaults la UTC date curentă (override pentru teste). `by_day`
|
||||
și `by_project` se calculează DOAR pe fereastra `days` (cele mai recente).
|
||||
"""
|
||||
today_str = today or datetime.now(timezone.utc).date().isoformat()
|
||||
windowed = filter_by_days(entries, days, today=today_str)
|
||||
today_entries = [e for e in entries if _entry_day(e) == today_str]
|
||||
return {
|
||||
"today": today_str,
|
||||
"today_cost": round(sum(_coerce_float(e.get("total_cost_usd")) for e in today_entries), 4),
|
||||
"today_runs": len(today_entries),
|
||||
"window_days": days,
|
||||
"window_cost": round(sum(_coerce_float(e.get("total_cost_usd")) for e in windowed), 4),
|
||||
"window_runs": len(windowed),
|
||||
"by_project": aggregate_by_project(windowed),
|
||||
"by_day": aggregate_by_day(windowed),
|
||||
"total_runs": len(entries),
|
||||
"total_cost": round(sum(_coerce_float(e.get("total_cost_usd")) for e in entries), 4),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Atomic append (CLI side) — used from ralph.sh
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def append_entry(usage_path: Path | str, entry: dict) -> None:
|
||||
"""Append a single entry as JSONL with atomic write semantics.
|
||||
|
||||
Uses temp file rename to avoid concurrent-writer corruption (read-existing,
|
||||
write-existing+new, atomic replace). NU folosim `open(..., 'a')` direct pentru
|
||||
că poate fi tăiat la mijloc dacă procesul e killed.
|
||||
"""
|
||||
p = Path(usage_path)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
existing = ""
|
||||
if p.exists():
|
||||
try:
|
||||
existing = p.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
existing = ""
|
||||
if existing and not existing.endswith("\n"):
|
||||
existing += "\n"
|
||||
|
||||
new_line = json.dumps(entry, ensure_ascii=False) + "\n"
|
||||
|
||||
fd, tmp_path = tempfile.mkstemp(prefix=".usage_", suffix=".jsonl.tmp", dir=str(p.parent))
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(existing)
|
||||
f.write(new_line)
|
||||
os.replace(tmp_path, p)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cmd_append(args: argparse.Namespace) -> int:
|
||||
"""Read claude JSON log, derive entry, atomic append.
|
||||
|
||||
Idempotent la JSON corupt: dacă fișierul nu poate fi parsat, exit 0
|
||||
(nu vrem să spargem ralph.sh pentru un parse warning).
|
||||
"""
|
||||
log_path = Path(args.claude_log)
|
||||
if not log_path.exists():
|
||||
print(f"warn: claude log missing: {log_path}", file=sys.stderr)
|
||||
return 0
|
||||
try:
|
||||
text = log_path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
print(f"warn: read failed: {exc}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
entry = extract_usage_entry(
|
||||
text,
|
||||
slug=args.slug,
|
||||
story_id=args.story_id or None,
|
||||
iter_n=args.iter if args.iter is not None else None,
|
||||
)
|
||||
if entry is None:
|
||||
print(f"warn: claude log not parseable as JSON envelope; no usage entry written", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
try:
|
||||
append_entry(args.usage_jsonl, entry)
|
||||
except OSError as exc:
|
||||
print(f"error: append failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_summarize(args: argparse.Namespace) -> int:
|
||||
entries = parse_usage_jsonl(args.usage_jsonl)
|
||||
summary = summarize(entries, days=args.days)
|
||||
print(json.dumps(summary, indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="ralph_usage", description=__doc__)
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
sp_app = sub.add_parser("append", help="Atomic append usage entry from claude JSON log")
|
||||
sp_app.add_argument("usage_jsonl", help="Path to usage.jsonl (will be created)")
|
||||
sp_app.add_argument("claude_log", help="Path to claude -p JSON output log")
|
||||
sp_app.add_argument("--slug", required=True)
|
||||
sp_app.add_argument("--story-id", default="", dest="story_id")
|
||||
sp_app.add_argument("--iter", type=int, default=None)
|
||||
|
||||
sp_sum = sub.add_parser("summarize", help="Print JSON summary of usage")
|
||||
sp_sum.add_argument("usage_jsonl", help="Path to usage.jsonl")
|
||||
sp_sum.add_argument("--days", type=int, default=7)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
if args.cmd == "append":
|
||||
return cmd_append(args)
|
||||
if args.cmd == "summarize":
|
||||
return cmd_summarize(args)
|
||||
parser.print_help()
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user