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>
129 lines
3.5 KiB
Python
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
|