Files
echo-core/src/ralph_flow.py
Marius Mutu 86384b38e3 feat(ralph): interactive UX layer pe Discord și Telegram (W1)
Adaugă straturile interactive peste slash commands flat:

**Discord (`src/adapters/discord_views.py`):**
- `RalphRootView` — listă proiecte workspace cu emoji status + Refresh + Close
- `RalphProjectView` — Propose / Vezi PRD / Aprobă tonight / Status / Stop / Înapoi
- `RalphProposeModal` — TextInput pentru descriere feature
- Critical pattern: `await interaction.response.defer(ephemeral=True)` în orice button
  callback cu I/O (eng review concern #2 — "Discord 3s timeout")
- `/p slug` autocomplete din `~/workspace/`
- `/l` afișează `RalphRootView` ephemeral

**Telegram (`src/adapters/telegram_bot.py`):**
- `cmd_ralph_l` (fără arg) trimite `InlineKeyboardMarkup` cu workspace + active
- `callback_ralph` cu pattern `^ralph:` rutează: project, menu, refresh, close,
  propose, prd, status, approve, stop
- Pentru "Propose feature" → set ralph_flow state cu step=input_description
  + `ForceReply()`; `handle_message` detectează state și rutează la `_ralph_propose`
- Pasează `adapter_name="telegram"` la `route_message`

**State management (`src/ralph_flow.py`):**
- Atomic JSON peste `sessions/ralph_flow.json` (pattern reusat din claude_session)
- Schema per (adapter, chat, user): `{step, project?, expires_at, ...}`
- TTL 10 min default; `cleanup_expired()` și auto-drop la `get_state` pe expirate

**Router (`src/router.py`):**
- `route_message` primește `adapter_name` keyword arg
- `_maybe_whatsapp_redirect` adaugă "💡 Pentru meniu interactiv folosește
  Discord sau Telegram" la mesajele de usage când adapter_name="whatsapp"
- WhatsApp `_handle_chat` pasează `adapter_name="whatsapp"`

**Tests:**
- `test_ralph_flow.py` — 10 teste (round-trip, isolation, expiry, atomic write)
- `test_router.py::TestRalphDispatch` — 3 teste (whatsapp redirect, discord
  no-redirect, usage message)

Foundation pentru W2 (planning agent — STEP_IN_PLANNING reservat).

Spike Step 0 PASS: skill subprocess + AskUserQuestion→text serialization
confirmat empiric (vezi tasks/spike-planning-findings.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:14:24 +00:00

129 lines
3.5 KiB
Python

"""Ralph UX flow state — short-lived per (adapter, chat, user) state for interactive menus.
Tracks state for multi-step flows like "user clicked Propose → next message is description".
Persisted in sessions/ralph_flow.json so it survives Echo Core restart.
TTL: 10 min default; cleanup_expired() drops stale entries.
"""
import json
import logging
import os
import tempfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
log = logging.getLogger(__name__)
PROJECT_ROOT = Path(__file__).resolve().parent.parent
SESSIONS_DIR = PROJECT_ROOT / "sessions"
_STATE_FILE = SESSIONS_DIR / "ralph_flow.json"
DEFAULT_TTL_SECONDS = 600 # 10 minutes
# Step values used across adapters
STEP_INPUT_DESCRIPTION = "input_description"
STEP_IN_PLANNING = "in_planning" # reserved for W2 (planning agent)
def _key(adapter: str, chat_id: str, user_id: str) -> str:
return f"{adapter}:{chat_id}:{user_id}"
def _load() -> dict:
try:
text = _STATE_FILE.read_text(encoding="utf-8")
if not text.strip():
return {}
return json.loads(text)
except (FileNotFoundError, json.JSONDecodeError):
return {}
def _save(data: dict) -> None:
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(
dir=SESSIONS_DIR, prefix=".ralph_flow_", suffix=".json"
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
os.replace(tmp_path, _STATE_FILE)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
def _is_expired(entry: dict, now: datetime | None = None) -> bool:
expires_at = entry.get("expires_at")
if not expires_at:
return False
try:
return datetime.fromisoformat(expires_at) < (now or datetime.now(timezone.utc))
except ValueError:
return True
def get_state(adapter: str, chat_id: str, user_id: str) -> dict | None:
"""Return current state or None if absent/expired. Drops expired entries on read."""
data = _load()
key = _key(adapter, chat_id, user_id)
entry = data.get(key)
if entry is None:
return None
if _is_expired(entry):
del data[key]
_save(data)
return None
return entry
def set_state(
adapter: str,
chat_id: str,
user_id: str,
step: str,
project: str | None = None,
ttl_seconds: int = DEFAULT_TTL_SECONDS,
**extras,
) -> None:
"""Set state for (adapter, chat, user). Overwrites any previous state."""
data = _load()
expires_at = (
datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)
).isoformat()
entry: dict = {"step": step, "expires_at": expires_at}
if project is not None:
entry["project"] = project
entry.update(extras)
data[_key(adapter, chat_id, user_id)] = entry
_save(data)
def clear_state(adapter: str, chat_id: str, user_id: str) -> bool:
"""Clear state. Returns True if anything was cleared."""
data = _load()
key = _key(adapter, chat_id, user_id)
if key in data:
del data[key]
_save(data)
return True
return False
def cleanup_expired() -> int:
"""Drop all expired entries. Returns count dropped."""
data = _load()
now = datetime.now(timezone.utc)
dropped = 0
for k in list(data.keys()):
if _is_expired(data[k], now=now):
del data[k]
dropped += 1
if dropped:
_save(data)
return dropped