Merges workspace.html + ralph.html into a single unified project hub with: - Cookie-based auth (DASHBOARD_TOKEN, HttpOnly, SameSite=Strict) - 9-state project badge system (running-ralph/manual, planning, approved, pending, blocked, failed, complete, idle) with BUTTONS_FOR_STATE matrix - SSE realtime + polling fallback, version-based optimistic concurrency (If-Match) - Planning chat modal (phase stepper, markdown bubbles, 50s+ wait state, auto-resume) - Propose modal (Variant B: inline Plan-with-Echo checkbox) - 5-type toast taxonomy (success/info/warning/busy/error, 3px colored left-bar) - Inter font self-hosted + shared tokens.css design system + DESIGN.md - src/jsonlock.py (flock helper, sidecar .lock for stable inode) - src/approved_tasks_cli.py (shell-safe wrapper for cron/ralph.sh) - 55 new tests (T#1–T#30) + real jsonlock bug fix caught by T#16/T#28 - No emoji anywhere (enforced by test_dashboard_no_emoji.py) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
638 lines
23 KiB
Python
638 lines
23 KiB
Python
"""Echo Core message router — routes messages to Claude or commands."""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import signal
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
|
|
from src.config import Config
|
|
from src.fast_commands import dispatch as fast_dispatch
|
|
from src.claude_session import (
|
|
send_message,
|
|
clear_session,
|
|
get_active_session,
|
|
list_sessions,
|
|
set_session_model,
|
|
VALID_MODELS,
|
|
)
|
|
from src.jsonlock import read_locked, write_locked
|
|
from src.planning_orchestrator import PlanningOrchestrator
|
|
from src.planning_session import (
|
|
clear_planning_state,
|
|
get_planning_state,
|
|
is_in_planning,
|
|
)
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
APPROVED_TASKS_FILE = Path(__file__).parent.parent / "approved-tasks.json"
|
|
|
|
# Module-level config instance (lazy singleton)
|
|
_config: Config | None = None
|
|
|
|
|
|
def _get_config() -> Config:
|
|
"""Return the module-level config, creating it on first access."""
|
|
global _config
|
|
if _config is None:
|
|
_config = Config()
|
|
return _config
|
|
|
|
|
|
def route_message(
|
|
channel_id: str,
|
|
user_id: str,
|
|
text: str,
|
|
model: str | None = None,
|
|
on_text: Callable[[str], None] | None = None,
|
|
adapter_name: str | None = None,
|
|
) -> tuple[str, bool]:
|
|
"""Route an incoming message. Returns (response_text, is_command).
|
|
|
|
If text starts with / it's a command (handled here for text-based commands).
|
|
Otherwise it goes to Claude via send_message (auto start/resume).
|
|
|
|
*on_text* — optional callback invoked with each intermediate text block
|
|
from Claude, enabling real-time streaming to the adapter.
|
|
|
|
*adapter_name* — "discord" / "telegram" / "whatsapp" / None. Used for
|
|
adapter-specific response shaping (e.g., redirect line on WhatsApp).
|
|
"""
|
|
text = text.strip()
|
|
|
|
# ---- Planning state-aware routing -----------------------------------
|
|
# If the channel is in an active planning session, the user's message is
|
|
# part of that conversation — route it to the orchestrator (NOT Claude
|
|
# main session, NOT slash commands except explicit /cancel and /advance).
|
|
in_planning = is_in_planning(adapter_name or "echo", channel_id)
|
|
if in_planning:
|
|
low = text.lower().strip()
|
|
if low in ("/cancel", "/anuleaza", "/anulează", "anulează planning", "anuleaza planning"):
|
|
# Capture slug BEFORE clearing state so we can revert approved-tasks status.
|
|
adapter_key = adapter_name or "echo"
|
|
state_snapshot = get_planning_state(adapter_key, channel_id)
|
|
cleared = PlanningOrchestrator.cancel(adapter_key, channel_id)
|
|
if state_snapshot and state_snapshot.get("slug"):
|
|
_revert_status_for_slug(state_snapshot["slug"], to="pending")
|
|
if cleared:
|
|
return "Planning anulat. Status revenit la pending.", True
|
|
return "Nu era nicio sesiune activă.", True
|
|
if low in ("/advance", "/continua", "/continuă", "continuă faza", "continua faza"):
|
|
session, response, completed = PlanningOrchestrator.advance(
|
|
adapter_name or "echo", channel_id, on_text=on_text,
|
|
)
|
|
return response, True
|
|
if low in ("/finalize", "/dau drumul", "dau drumul"):
|
|
return _approve_from_planning(channel_id, adapter_name or "echo"), True
|
|
if text.startswith("/"):
|
|
# Allow other commands to fall through (e.g. /status, /clear),
|
|
# but skip Ralph dispatch and Claude routing below.
|
|
pass
|
|
else:
|
|
# Plain message → planning conversation.
|
|
try:
|
|
session, response, phase_ready = PlanningOrchestrator.respond(
|
|
adapter_name or "echo", channel_id, text, on_text=on_text,
|
|
)
|
|
if session is None:
|
|
# State raced — drop planning marker, fall through.
|
|
log.warning(
|
|
"planning state vanished mid-respond for channel=%s", channel_id
|
|
)
|
|
else:
|
|
if phase_ready:
|
|
response = (
|
|
response
|
|
+ "\n\n— Apasă **Continuă faza** ca să trec la următoarea, "
|
|
"sau **Anulează** dacă te-ai răzgândit."
|
|
)
|
|
return response, False
|
|
except Exception as e:
|
|
log.error("Planning respond failed for %s: %s", channel_id, e)
|
|
return f"Planning blocat: {e}", False
|
|
|
|
# Ralph commands — short form (/p /a /l /k) and legacy aliases (!propose !approve !status !stop)
|
|
ralph_response = _try_ralph_dispatch(text, adapter_name=adapter_name)
|
|
if ralph_response is not None:
|
|
return ralph_response, True
|
|
|
|
# Text-based commands (not slash commands — these work in any adapter)
|
|
if text.lower() == "/clear":
|
|
default_model = _get_config().get("bot.default_model", "sonnet")
|
|
cleared = clear_session(channel_id)
|
|
if cleared:
|
|
return f"Session cleared. Model reset to {default_model}.", True
|
|
return "No active session.", True
|
|
|
|
if text.lower() == "/status":
|
|
return _status(channel_id), True
|
|
|
|
if text.lower().startswith("/model"):
|
|
return _model_command(channel_id, text), True
|
|
|
|
if text.startswith("/"):
|
|
parts = text[1:].split()
|
|
cmd_name = parts[0].lower()
|
|
cmd_args = parts[1:]
|
|
result = fast_dispatch(cmd_name, cmd_args)
|
|
if result is not None:
|
|
return result, True
|
|
return f"Unknown command: /{cmd_name}", True
|
|
|
|
# Regular message → Claude
|
|
if not model:
|
|
# Check session model first, then channel default, then global default
|
|
session = get_active_session(channel_id)
|
|
if session and session.get("model"):
|
|
model = session["model"]
|
|
else:
|
|
channel_cfg = _get_channel_config(channel_id)
|
|
model = (channel_cfg or {}).get("default_model") or _get_config().get("bot.default_model", "sonnet")
|
|
|
|
try:
|
|
response = send_message(channel_id, text, model=model, on_text=on_text)
|
|
return response, False
|
|
except Exception as e:
|
|
log.error("Claude error for channel %s: %s", channel_id, e)
|
|
return f"Error: {e}", False
|
|
|
|
|
|
def _status(channel_id: str) -> str:
|
|
"""Build status message for a channel."""
|
|
session = get_active_session(channel_id)
|
|
if not session:
|
|
return "No active session."
|
|
|
|
model = session.get("model", "unknown")
|
|
sid = session.get("session_id", "unknown")[:12]
|
|
count = session.get("message_count", 0)
|
|
|
|
return f"Model: {model} | Session: {sid}... | Messages: {count}"
|
|
|
|
|
|
def _model_command(channel_id: str, text: str) -> str:
|
|
"""Handle /model [choice] text command."""
|
|
parts = text.strip().split()
|
|
if len(parts) == 1:
|
|
# /model — show current
|
|
session = get_active_session(channel_id)
|
|
if session:
|
|
current = session.get("model", "unknown")
|
|
else:
|
|
channel_cfg = _get_channel_config(channel_id)
|
|
current = (channel_cfg or {}).get("default_model") or _get_config().get("bot.default_model", "sonnet")
|
|
available = ", ".join(sorted(VALID_MODELS))
|
|
return f"Current model: {current}\nAvailable: {available}"
|
|
|
|
choice = parts[1].lower()
|
|
if choice not in VALID_MODELS:
|
|
return f"Invalid model '{choice}'. Choose from: {', '.join(sorted(VALID_MODELS))}"
|
|
|
|
session = get_active_session(channel_id)
|
|
if session:
|
|
set_session_model(channel_id, choice)
|
|
else:
|
|
# Pre-set for next message
|
|
from src.claude_session import _load_sessions, _save_sessions
|
|
from datetime import datetime, timezone
|
|
sessions = _load_sessions()
|
|
sessions[channel_id] = {
|
|
"session_id": "",
|
|
"model": choice,
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
"last_message_at": datetime.now(timezone.utc).isoformat(),
|
|
"message_count": 0,
|
|
}
|
|
_save_sessions(sessions)
|
|
return f"Model changed to {choice}."
|
|
|
|
|
|
def _load_approved_tasks() -> dict:
|
|
"""Load approved-tasks.json under a shared flock; empty structure if missing."""
|
|
try:
|
|
data = read_locked(str(APPROVED_TASKS_FILE))
|
|
except FileNotFoundError:
|
|
return {"projects": [], "last_updated": None}
|
|
if not data:
|
|
return {"projects": [], "last_updated": None}
|
|
return data
|
|
|
|
|
|
def _save_approved_tasks(data: dict) -> None:
|
|
"""Persist approved-tasks.json under an exclusive flock + atomic replace."""
|
|
data["last_updated"] = datetime.now(timezone.utc).isoformat()
|
|
write_locked(str(APPROVED_TASKS_FILE), lambda _existing: data)
|
|
|
|
|
|
RALPH_CMDS = {
|
|
"propose": ("/p", "!propose"),
|
|
"approve": ("/a", "!approve"),
|
|
"list": ("/l", "!status"),
|
|
"stop": ("/k", "!stop"),
|
|
}
|
|
|
|
|
|
_WHATSAPP_REDIRECT = (
|
|
"\n\n💡 Pentru meniu interactiv folosește Discord sau Telegram."
|
|
)
|
|
|
|
|
|
def _maybe_whatsapp_redirect(text: str, adapter_name: str | None) -> str:
|
|
"""Append a redirect hint for WhatsApp users so they discover the rich UX."""
|
|
if adapter_name == "whatsapp":
|
|
return text + _WHATSAPP_REDIRECT
|
|
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 ""
|
|
|
|
if first in ("/p", "!propose"):
|
|
parts = text.split(None, 2)
|
|
if len(parts) < 3:
|
|
return _maybe_whatsapp_redirect(
|
|
"Folosire: /p <slug> <descriere>\nEx: /p roa2web Homepage redesign cu hero section",
|
|
adapter_name,
|
|
)
|
|
return _ralph_propose(parts[1].strip(), parts[2].strip())
|
|
|
|
if first in ("/a", "!approve"):
|
|
parts = text.split(None, 1)
|
|
slugs = []
|
|
if len(parts) > 1:
|
|
slugs = [s.strip() for s in parts[1].replace(",", " ").split() if s.strip()]
|
|
return _ralph_approve(slugs)
|
|
|
|
if first in ("/l", "!status"):
|
|
parts = text.split(None, 1)
|
|
filter_slug = parts[1].strip().lower() if len(parts) > 1 else None
|
|
return _maybe_whatsapp_redirect(_ralph_status(filter_slug), adapter_name)
|
|
|
|
if first in ("/k", "!stop"):
|
|
parts = text.split(None, 1)
|
|
if len(parts) < 2:
|
|
return "Folosire: /k <slug>"
|
|
return _ralph_stop(parts[1].strip())
|
|
|
|
return None
|
|
|
|
|
|
def _ralph_propose(slug: str, description: str) -> str:
|
|
"""Adaugă un proiect cu status pending în approved-tasks.json.
|
|
|
|
Schema includes the W2 planning fields (`planning_session_id`,
|
|
`final_plan_path`) so the orchestrator and PRD generator can find them.
|
|
"""
|
|
data = _load_approved_tasks()
|
|
|
|
for p in data["projects"]:
|
|
if p["name"].lower() == slug.lower():
|
|
return f"Proiectul '{slug}' există deja cu status: {p.get('status', 'unknown')}."
|
|
|
|
data["projects"].append({
|
|
"name": slug,
|
|
"description": description,
|
|
"status": "pending",
|
|
"planning_session_id": None,
|
|
"final_plan_path": None,
|
|
"proposed_at": datetime.now(timezone.utc).isoformat(),
|
|
"approved_at": None,
|
|
"started_at": None,
|
|
"pid": None,
|
|
})
|
|
_save_approved_tasks(data)
|
|
return f"📋 Adăugat: {slug}\n └ {description}\n\nAprobă cu: /a {slug}"
|
|
|
|
|
|
def _ralph_approve(slugs: list[str]) -> str:
|
|
"""Aprobă unul sau mai multe proiecte. Listă goală = listează pending."""
|
|
data = _load_approved_tasks()
|
|
|
|
if not slugs:
|
|
pending = [p for p in data["projects"] if p.get("status") == "pending"]
|
|
if not pending:
|
|
return "Niciun proiect pending. Adaugă cu /p <slug> <descriere>."
|
|
lines = ["📋 Proiecte pending (aprobă cu /a <slug>):"]
|
|
for p in pending:
|
|
lines.append(f" • {p['name']}")
|
|
lines.append(f" └ {p['description'][:80]}")
|
|
return "\n".join(lines)
|
|
|
|
approved_info: list[tuple[str, str]] = []
|
|
not_found: list[str] = []
|
|
|
|
for slug in slugs:
|
|
found = False
|
|
for p in data["projects"]:
|
|
if p["name"].lower() == slug.lower():
|
|
p["status"] = "approved"
|
|
p["approved_at"] = datetime.now(timezone.utc).isoformat()
|
|
approved_info.append((p["name"], p.get("description", "")))
|
|
found = True
|
|
break
|
|
if not found:
|
|
not_found.append(slug)
|
|
|
|
if not_found:
|
|
return f"Nu am găsit: {', '.join(not_found)}. Verifică /l pentru lista completă."
|
|
|
|
_save_approved_tasks(data)
|
|
lines = ["✅ Aprobat pentru tonight:"]
|
|
for name, desc in approved_info:
|
|
lines.append(f" • {name}")
|
|
lines.append(f" └ {desc[:80]}")
|
|
lines.append("\nNight-execute rulează la 23:00 și implementează stories autonom.")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _ralph_status(filter_slug: str | None = None) -> str:
|
|
"""Status Ralph pentru proiecte. Optional filter pe slug."""
|
|
data = _load_approved_tasks()
|
|
projects = data.get("projects", [])
|
|
|
|
if filter_slug:
|
|
projects = [p for p in projects if filter_slug in p["name"].lower()]
|
|
|
|
if not projects:
|
|
return "Niciun proiect. Adaugă cu /p <slug> <descriere>."
|
|
|
|
status_labels = {
|
|
"approved": "⏳ aștept 23:00",
|
|
"pending": "📋 pending",
|
|
"complete": "✅ complet",
|
|
"failed": "❌ eșuat",
|
|
"stopped": "⏹ oprit",
|
|
}
|
|
|
|
lines = ["📊 Proiecte Ralph:"]
|
|
for p in projects:
|
|
status = p.get("status", "unknown")
|
|
name = p["name"]
|
|
desc = p.get("description", "")
|
|
pid = p.get("pid")
|
|
started = p.get("started_at", "")[:16].replace("T", " ") if p.get("started_at") else "-"
|
|
|
|
if pid and status == "running":
|
|
try:
|
|
os.kill(pid, 0)
|
|
indicator = f"🟢 PID {pid}"
|
|
except (ProcessLookupError, PermissionError):
|
|
indicator = "🔴 PID mort"
|
|
p["status"] = "stopped"
|
|
_save_approved_tasks(data)
|
|
else:
|
|
indicator = status_labels.get(status, status)
|
|
|
|
prd_path = Path(f"/home/moltbot/workspace/{name}/scripts/ralph/prd.json")
|
|
stories_info = ""
|
|
if prd_path.exists():
|
|
try:
|
|
prd = json.loads(prd_path.read_text())
|
|
total = len(prd.get("userStories", []))
|
|
done = sum(1 for s in prd.get("userStories", []) if s.get("passes"))
|
|
stories_info = f" | {done}/{total} stories"
|
|
except Exception:
|
|
pass
|
|
|
|
lines.append(f"\n {name} {indicator}{stories_info} | Start: {started}")
|
|
if desc:
|
|
lines.append(f" └ {desc[:80]}")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _ralph_stop(slug: str) -> str:
|
|
"""Oprește Ralph loop (SIGTERM) pentru un proiect."""
|
|
data = _load_approved_tasks()
|
|
|
|
for p in data["projects"]:
|
|
if p["name"].lower() == slug.lower():
|
|
desc = p.get("description", "")
|
|
pid = p.get("pid")
|
|
if pid:
|
|
try:
|
|
os.kill(pid, signal.SIGTERM)
|
|
p["status"] = "stopped"
|
|
p["stopped_at"] = datetime.now(timezone.utc).isoformat()
|
|
_save_approved_tasks(data)
|
|
return f"⏹ Oprit: {p['name']} (PID {pid})\n └ {desc[:80]}"
|
|
except ProcessLookupError:
|
|
p["status"] = "stopped"
|
|
_save_approved_tasks(data)
|
|
return f"PID {pid} nu mai rula pentru {p['name']}. Status actualizat."
|
|
except PermissionError:
|
|
return f"❌ Nu am permisiune să opresc PID {pid}."
|
|
else:
|
|
return f"{p['name']} nu are PID activ (status: {p.get('status', 'unknown')})."
|
|
|
|
return f"Proiect '{slug}' nu găsit. Verifică /l pentru lista completă."
|
|
|
|
|
|
def _get_channel_config(channel_id: str) -> dict | None:
|
|
"""Find channel config by ID."""
|
|
channels = _get_config().get("channels", {})
|
|
for alias, ch in channels.items():
|
|
if ch.get("id") == channel_id:
|
|
return ch
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Planning session entry points (W2)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def start_planning_session(
|
|
slug: str,
|
|
description: str,
|
|
channel_id: str,
|
|
adapter_name: str,
|
|
on_text: Callable[[str], None] | None = None,
|
|
) -> str:
|
|
"""Begin a conversational planning session for `slug` on this channel.
|
|
|
|
Updates approved-tasks.json: status `planning`, `planning_session_id` set.
|
|
Returns the first response text from the planning agent — the adapter
|
|
will display it and the user replies in the same channel.
|
|
"""
|
|
data = _load_approved_tasks()
|
|
|
|
# Locate or create the project entry.
|
|
entry = None
|
|
for p in data["projects"]:
|
|
if p["name"].lower() == slug.lower():
|
|
entry = p
|
|
break
|
|
if entry is None:
|
|
entry = {
|
|
"name": slug,
|
|
"description": description,
|
|
"status": "pending",
|
|
"planning_session_id": None,
|
|
"final_plan_path": None,
|
|
"proposed_at": datetime.now(timezone.utc).isoformat(),
|
|
"approved_at": None,
|
|
"started_at": None,
|
|
"pid": None,
|
|
}
|
|
data["projects"].append(entry)
|
|
|
|
# Kick off orchestrator (this can take ~60s on first turn — caller should
|
|
# have already shown a "Echo se gândește..." indicator).
|
|
try:
|
|
session, first_response = PlanningOrchestrator.start(
|
|
slug=slug,
|
|
description=description,
|
|
channel_id=channel_id,
|
|
adapter=adapter_name or "echo",
|
|
on_text=on_text,
|
|
)
|
|
except Exception as e:
|
|
log.error("Planning session start failed for %s: %s", slug, e)
|
|
return f"Planning blocat: {e}\n\nÎncearcă din nou cu /plan {slug} <descriere>."
|
|
|
|
entry["status"] = "planning"
|
|
entry["planning_session_id"] = session.planning_session_id
|
|
if not entry.get("description"):
|
|
entry["description"] = description
|
|
_save_approved_tasks(data)
|
|
return first_response
|
|
|
|
|
|
def _revert_status_for_slug(slug: str, to: str = "pending") -> None:
|
|
"""Revert a project's status (planning → `to`) given its slug."""
|
|
if not slug:
|
|
return
|
|
data = _load_approved_tasks()
|
|
changed = False
|
|
for p in data["projects"]:
|
|
if p["name"].lower() == slug.lower() and p.get("status") == "planning":
|
|
p["status"] = to
|
|
p["planning_session_id"] = None
|
|
changed = True
|
|
break
|
|
if changed:
|
|
_save_approved_tasks(data)
|
|
|
|
|
|
def _approve_from_planning(channel_id: str, adapter_name: str) -> str:
|
|
"""User clicked 'Dau drumul' inside an active planning session.
|
|
|
|
Promotes status `planning` → `approved` and clears planning state.
|
|
Returns confirmation text.
|
|
"""
|
|
state = get_planning_state(adapter_name, channel_id)
|
|
if not state:
|
|
return "Nu există o sesiune de planning activă."
|
|
slug = state.get("slug")
|
|
if not slug:
|
|
return "Sesiunea de planning nu are slug — anulează cu /cancel și ia-o de la capăt."
|
|
|
|
data = _load_approved_tasks()
|
|
final_plan_path = state.get("final_plan_path") or str(
|
|
PlanningOrchestrator.final_plan_path(slug)
|
|
)
|
|
found = False
|
|
for p in data["projects"]:
|
|
if p["name"].lower() == slug.lower():
|
|
p["status"] = "approved"
|
|
p["approved_at"] = datetime.now(timezone.utc).isoformat()
|
|
p["planning_session_id"] = None
|
|
p["final_plan_path"] = final_plan_path
|
|
found = True
|
|
break
|
|
if not found:
|
|
return f"Proiectul `{slug}` lipsește din approved-tasks.json. Anulează cu /cancel."
|
|
_save_approved_tasks(data)
|
|
clear_planning_state(adapter_name, channel_id)
|
|
return (
|
|
f"✅ Aprobat: `{slug}`. Ralph începe la 23:00.\n"
|
|
f" Plan: `{final_plan_path}`"
|
|
)
|
|
|
|
|
|
# Public helpers — re-exported for adapter wiring.
|
|
def planning_state_for(channel_id: str, adapter_name: str) -> dict | None:
|
|
"""Return current planning state for (adapter, channel) — adapter helper."""
|
|
return get_planning_state(adapter_name, channel_id)
|
|
|
|
|
|
def planning_advance(
|
|
channel_id: str,
|
|
adapter_name: str,
|
|
on_text: Callable[[str], None] | None = None,
|
|
) -> tuple[str, bool]:
|
|
"""Advance the planning pipeline by one phase.
|
|
|
|
Returns (response_text, completed_bool).
|
|
"""
|
|
_session, text, completed = PlanningOrchestrator.advance(
|
|
adapter_name, channel_id, on_text=on_text,
|
|
)
|
|
return text, completed
|
|
|
|
|
|
def planning_cancel(channel_id: str, adapter_name: str) -> str:
|
|
"""Cancel an active planning session and revert project status."""
|
|
state = get_planning_state(adapter_name, channel_id)
|
|
if not state:
|
|
return "Nu era nicio sesiune de planning activă."
|
|
slug = state.get("slug")
|
|
PlanningOrchestrator.cancel(adapter_name, channel_id)
|
|
if slug:
|
|
_revert_status_for_slug(slug, to="pending")
|
|
return "Planning anulat. Status revenit la pending."
|
|
|
|
|
|
def planning_approve(channel_id: str, adapter_name: str) -> str:
|
|
"""Promote planning → approved (e.g. button click 'Dau drumul')."""
|
|
return _approve_from_planning(channel_id, adapter_name)
|