Compare commits

...

9 Commits

Author SHA1 Message Date
e771479d67 chore: auto-commit from dashboard 2026-04-28 05:29:52 +00:00
2830bf48f2 fix(dashboard): ralph.html URL prefix /echo/api/ralph (was /api/ralph → 502)
Tailscale Serve mapează /echo/* → 127.0.0.1:8088 (dashboard) și / →
:18789 (alt backend). Browser-ul calling /api/ralph/status (relative cu
absolute path la root domain) ajungea la 18789 care nu are endpoint Ralph
→ 502 Bad Gateway.

Fix: toate cele 6 URL-uri (5x fetch + 1x EventSource) folosesc acum prefix
/echo/api/ralph/* pentru a respecta routing-ul tailscale. Pattern consistent
cu workspace.html și index.html (verificat manual).

Endpoints atinse: /status, /<slug>/log, /<slug>/prd, /<slug>/stop,
/<slug>/rollback, /stream (SSE).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 05:18:01 +00:00
44c9bb4e61 docs(claude): document instrumentation + realtime extras (post-merge)
- ralph_usage.py + usage.jsonl tracking
- /api/ralph/{usage,stream,<slug>/rollback} endpoints
- ralph.html realtime via EventSource (fallback polling)
- WhatsApp text-keyword shortcuts (aprob/stop/stare)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:14:46 +00:00
03d875974b Merge branch 'ralph/dashboard-realtime' — SSE realtime + story rollback
Server-Sent Events (TODO P3):
- GET /api/ralph/stream — signature-based change detection (poll FS 2s, emit
  doar la diff), heartbeat 30s, X-Accel-Buffering:no
- HTTPServer → ThreadingHTTPServer (altfel SSE blochează toate endpoint-urile)
- ralph.html: EventSource cu fallback permanent la polling 5s când CLOSED.
  Badge: 🟢 Live / ⏱ Polling / Offline

Story rollback (TODO P3):
- POST /api/ralph/<slug>/rollback — git revert --no-edit HEAD; fallback
  git reset --hard HEAD~1 doar la conflict
- Decrementează passes pe ultima story complete; clears failed/blocked/retries
  (atomic temp+rename)
- Slug strict regex ^[A-Za-z0-9_-]{1,64}$ + reject path traversal explicit
- Buton ↩️ pe card-uri running; confirm dialog înainte de execuție
- Response: {success, message, reverted_commit, story_reverted, method}

Tests: 39/39 pe test_dashboard_ralph_endpoint (era 19; +20 cazuri noi).

# Conflicts:
#	dashboard/api.py
#	dashboard/handlers/ralph.py
2026-04-26 19:14:17 +00:00
84f304f7be Merge branch 'ralph/instrumentation' — rate limit budget + WhatsApp keywords
Rate limit budget tracking (TODO P2):
- tools/ralph_usage.py — pure functions extract/parse/aggregate; CLI subcomenzi
  append/summarize. Atomic write JSONL.
- tools/ralph/ralph.sh: după fiecare claude -p, append usage entry la
  workspace/<slug>/scripts/ralph/usage.jsonl (best-effort)
- dashboard/handlers/ralph.py: GET /api/ralph/usage[?days=N] cross-project
  aggregation cu today_cost, today_runs, by_project, by_day

WhatsApp text-keyword commands (TODO P3):
- src/router.py: helper _translate_whatsapp_text — `aprob <slug>` → `/a <slug>`,
  `stop <slug>` → `/k <slug>`, `stare`/`stare <slug>` → `/l`/`/l <slug>`. Aplicat
  DOAR pe adapter whatsapp în _try_ralph_dispatch (Discord/TG nu sunt afectate).
  Propose intentionally NOT covered (descrierea fragilă).

Tests: 53 noi (28 ralph_usage + 21 whatsapp_keywords + 4 dashboard endpoint extend)
+ 0 regressions pe modulele atinse.
2026-04-26 19:12:43 +00:00
3c9322ba93 chore: live planning state — romfast-website (Marius testing W2)
approved-tasks.json mutat de start_planning_session cu status='planning'.
Sesiune activă: 14d2d96d-d4eb-4472-9b07-4a869909c564.

Confirmare empirică că flow-ul Discord/Telegram → modal/ForceReply →
PlanningOrchestrator funcționează end-to-end pe production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:12:31 +00:00
6d56356ada feat(dashboard): integrate Ralph nav link + add e2e planning walkthrough test
dashboard/api.py: adaug link "Ralph" (lucide bot icon) în NAV_HTML între
Workspace și KB. Pagina ralph.html se injectează corect cu nav-ul (verificat
live via curl pe :8088/ralph.html).

tests/test_e2e_planning_walkthrough.py (nou): 4 teste integration care
simulează scripted exact ce face un user pe Discord:
- click Planifică pe game-library cu UI scope → 4 faze (incl design-review)
- /office-hours → ceo → eng → design → final-plan.md stub scris pe disk
- "Dau drumul" → status approved + final_plan_path în approved-tasks.json
- description fără UI keywords → 3 faze (skip design)
- /cancel mid-planning → status revert pending, state cleared
- mesaj fără planning state → cade pe Claude main chat (NU orchestrator)

Subprocess `claude -p` mock-uit; testează tot wire-up-ul router → orchestrator
→ session și schema approved-tasks.json. Nu consumă credite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:11:35 +00:00
ff9b9a0d1d feat(dashboard): SSE realtime + story rollback button
Replaces 5s polling on /echo/ralph.html with EventSource streaming and adds
a rollback control for the running Ralph cards.

Server (dashboard/handlers/ralph.py):
- /api/ralph/stream — Server-Sent Events. Emits `event: status` whenever a
  signature over the projects' state changes (poll filesystem at 2s); emits
  `event: heartbeat` every 30s to keep proxies happy. Disables proxy
  buffering via X-Accel-Buffering:no.
- /api/ralph/<slug>/rollback (POST) — runs `git revert --no-edit HEAD` in
  the project; falls back to `git reset --hard HEAD~1` only if revert
  reports conflict. After rolling back the commit, decrements `passes` on
  the last user story marked complete in prd.json (atomic temp+rename
  write, same pattern as ralph_dag.py). Returns
  `{success, message, reverted_commit, story_reverted, method}`.
- _ralph_validate_slug tightened to a strict regex (alphanum + dash +
  underscore, ≤64 chars) plus explicit ../, /, \ rejection. All previously
  accepted slugs still pass; URL-encoded traversal and shell metachars
  now blocked before the filesystem is touched.
- _ralph_collect_status / _ralph_signature factored out of
  handle_ralph_status so the SSE loop can reuse them and detect changes
  cheaply.

Server (dashboard/api.py):
- HTTPServer → ThreadingHTTPServer with daemon_threads=True. SSE is a
  long-lived response; without threading a single client would block all
  other dashboard endpoints.
- /api/ralph/stream (GET) and /api/ralph/<slug>/rollback (POST) wired
  into the dispatch.

Client (dashboard/ralph.html):
- EventSource('/api/ralph/stream') with permanent fallback to 5s polling
  when readyState=CLOSED (no server, CORS blocked, browser without SSE).
- Indicator badge: 🟢 Live (SSE), ⏱ Polling (fallback), Offline.
- Rollback button (undo-2 icon) on running cards; native confirm() with
  message: "Asta va da git revert HEAD pe <slug> și va decrementa ultima
  story trecută. Continui?"

Tests (tests/test_dashboard_ralph_endpoint.py, +20 cases):
- Strict slug validator: underscore allowed, >64 rejected, special chars
  / backslash / URL-encoded traversal rejected.
- _ralph_collect_status + _ralph_signature: stable when nothing changes,
  flips when project added or `passes` toggles.
- Rollback: invalid slug → 400, non-git project → 400, real two-commit
  repo revert succeeds and decrements last passing story (US-002 goes
  passes:false while US-001 stays passes:true), no-passing-stories case
  succeeds with story_reverted=None, response shape contract, atomic
  helper leaves no .tmp file behind.
- API routing smoke: confirms ThreadingHTTPServer + stream + rollback
  references present in dashboard/api.py.

39/39 tests pass on tests/test_dashboard_ralph_endpoint.py. Pre-existing
failures in test_dashboard_constants.py::test_base_dir_is_echo_core (the
worktree dir is `echo-core-realtime`, not `echo-core`) and
test_dashboard_unified_index.py::test_index_has_all_panels are unrelated
to this change and reproduced on master.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:07:13 +00:00
3e7818286b feat(ralph): rate limit budget tracking + whatsapp text-keywords
Task #1 — Rate limit budget tracking MVP:
- tools/ralph_usage.py: pure functions (extract_usage_entry, parse_usage_jsonl,
  aggregate_by_day/_project, filter_by_days, summarize) + CLI append/summarize
  subcommands. Atomic write via temp+rename.
- tools/ralph/ralph.sh: după fiecare claude -p, append usage entry
  derivat din JSON envelope la <project>/scripts/ralph/usage.jsonl. Best-effort,
  niciodată blochează rularea (|| true).
- dashboard/handlers/ralph.py: GET /api/ralph/usage[?days=N] aggregează cross-
  project și returnează {today_cost, today_runs, by_project, by_day, ...}.

Task #2 — WhatsApp text-keyword commands:
- src/router.py: helper _translate_whatsapp_text mapează "aprob"/"stop <slug>"/
  "stare [<slug>]" → /a, /k, /l. Apelat DOAR pe adapter whatsapp în
  _try_ralph_dispatch (Discord/TG nu sunt afectate). NU acoperim propose
  intentionat — descrierea liberă e prea fragilă pentru parsing text-only.

Tests: 49 noi (test_ralph_usage 28 + test_whatsapp_keywords 21) + 4 noi în
test_dashboard_ralph_endpoint pentru /api/ralph/usage. Toate trec; regression
suite (test_router, test_router_planning, test_dashboard_ralph_endpoint,
test_whatsapp) — 90/90 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:05:50 +00:00
14 changed files with 2338 additions and 68 deletions

View File

@@ -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)

View File

@@ -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"
}

View File

@@ -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"
}
]

File diff suppressed because one or more lines are too long

View File

@@ -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()

View File

@@ -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)

View File

@@ -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>

View File

@@ -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 ""

View File

@@ -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

View 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
View 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

View 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

View File

@@ -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
View 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())