Echo Core devine planning agent: poartă o conversație multi-fază cu Marius folosind skill-urile gstack (/office-hours → /plan-ceo-review → /plan-eng-review → /plan-design-review opt) și produce final-plan.md în ~/workspace/<slug>/scripts/ralph/, gata să fie consumat de Ralph PRD generator (W3) noaptea. Decizii arhitecturale (din eng review + spike findings): - PlanningSession ca clasă SEPARATĂ de chat-ul main (NU mode=string param) — separation explicit. claude_session.py rămâne strict pentru chat; planning trăiește în src/planning_session.py + src/planning_orchestrator.py. Inheritance literală nu se aplică (claude_session.py expune funcții module-level, nu o clasă) — separation e satisfacută prin module distinct. - Fresh subprocess PER skill phase, NU single resumed session — phase-urile coordinează via disk artifacts (gstack convention în ~/.gstack/projects/<slug>/). Avoids context window growth. - --max-turns 20 default + retry pe error_max_turns la --max-turns 30. Spike a arătat că prompt-uri complexe pot exploda turn budget-ul. - approved-tasks.json schema extins cu planning_session_id + final_plan_path (Status flow: pending → planning → approved → running → complete). - State separat în sessions/planning.json (NU active.json), keyed pe (adapter, channel_id) pentru re-resume la restart echo-core. Trigger-e: - Discord: slash command /plan <slug> [descriere] cu autocomplete pe pending, buton "🧠 Planifică" în RalphProjectView, și /cancel slash command. - Telegram: /plan + /cancel commands, plus buton "🧠 Planifică" în ralph project keyboard. - Router: state-aware routing — dacă chat-ul e în planning, mesajele plain trec la PlanningOrchestrator.respond() prin --resume; /cancel revine la status pending; /advance / "Continuă faza" advance fază nouă (fresh subprocess); /finalize sau "Dau drumul" promote la status approved. Discord defer pattern: toate butoanele noi (PlanningActiveView, PlanningFinalView, "🧠 Planifică") apelează await interaction.response.defer(ephemeral=True) ÎNAINTE de orice IO — evită "Interaction failed" pe IO >3s. UX strings warm + colaborativ (per design review): "🧠 Pornesc planning pentru ...", "Răspunde aici", "Continuă faza", "Dau drumul tonight", "Anulează" — niciun "Submit/Approve/Cancel" generic. Tests: 23 noi (test_planning_session, test_planning_orchestrator, test_router_planning) — toate pass. Mock pe _run_claude pentru a evita subprocess Claude real în CI. Files new: prompts/planning_agent.md src/planning_session.py src/planning_orchestrator.py tests/test_planning_session.py tests/test_planning_orchestrator.py tests/test_router_planning.py Files modified: src/claude_session.py — _run_claude(cwd=...) optional + surface subtype/is_error src/router.py — state-aware routing, start_planning_session, planning_advance/approve/cancel, _ralph_propose schema cu planning_session_id + final_plan_path src/adapters/discord_bot.py — /plan + /cancel slash commands; planning views imported src/adapters/discord_views.py — PlanningActiveView, PlanningFinalView, "Planifică" button în RalphProjectView, _split_chunks helper src/adapters/telegram_bot.py — /plan + /cancel handlers, callback_ralph extins cu plan/planadvance/plancancel/planapprove, planning keyboards Status testelor pe modulele atinse: 75 passed, 0 failed (test_claude_session security_section preexistent — neatins). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
394 lines
15 KiB
Python
394 lines
15 KiB
Python
"""Discord interactive Views for Ralph — root list + per-project actions + propose modal.
|
||
|
||
Critical pattern: every button callback that does I/O MUST call
|
||
`await interaction.response.defer(ephemeral=True)` FIRST, then use
|
||
`interaction.followup.send(...)`. Otherwise Discord's 3s interaction
|
||
timeout kicks in and the user sees "Interaction failed".
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
from pathlib import Path
|
||
|
||
import discord
|
||
|
||
from src.router import (
|
||
_load_approved_tasks,
|
||
_ralph_approve,
|
||
_ralph_propose,
|
||
_ralph_status,
|
||
_ralph_stop,
|
||
planning_advance,
|
||
planning_approve,
|
||
planning_cancel,
|
||
start_planning_session,
|
||
)
|
||
from src.planning_session import is_in_planning
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
WORKSPACE_DIR = Path("/home/moltbot/workspace")
|
||
|
||
# Status → emoji prefix for project labels
|
||
_STATUS_EMOJI = {
|
||
"pending": "📋",
|
||
"approved": "⏳",
|
||
"running": "🟢",
|
||
"complete": "✅",
|
||
"failed": "❌",
|
||
"stopped": "⏹",
|
||
}
|
||
|
||
VIEW_TIMEOUT = 600 # 10 min — matches ralph_flow TTL
|
||
|
||
|
||
def _project_label(name: str, status: str | None) -> str:
|
||
"""Return a short button label like '🟢 roa2web' (max 80 chars per Discord)."""
|
||
emoji = _STATUS_EMOJI.get(status or "", "·")
|
||
label = f"{emoji} {name}"
|
||
return label[:80]
|
||
|
||
|
||
def _list_workspace_projects() -> list[str]:
|
||
"""Return workspace folder names sorted, skipping hidden dirs."""
|
||
if not WORKSPACE_DIR.exists():
|
||
return []
|
||
return sorted(
|
||
p.name for p in WORKSPACE_DIR.iterdir()
|
||
if p.is_dir() and not p.name.startswith(".")
|
||
)
|
||
|
||
|
||
def _project_status(name: str) -> str | None:
|
||
"""Look up status of a project from approved-tasks.json (or None)."""
|
||
try:
|
||
data = _load_approved_tasks()
|
||
except Exception:
|
||
return None
|
||
for p in data.get("projects", []):
|
||
if p.get("name", "").lower() == name.lower():
|
||
return p.get("status")
|
||
return None
|
||
|
||
|
||
def _read_prd(name: str) -> dict | None:
|
||
prd_path = WORKSPACE_DIR / name / "scripts" / "ralph" / "prd.json"
|
||
if not prd_path.exists():
|
||
return None
|
||
try:
|
||
return json.loads(prd_path.read_text(encoding="utf-8"))
|
||
except (ValueError, OSError):
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# RalphProposeModal — text input for new feature description
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class RalphProposeModal(discord.ui.Modal, title="Propune feature Ralph"):
|
||
"""Modal asking the user for a feature description for a given project."""
|
||
|
||
description: discord.ui.TextInput = discord.ui.TextInput(
|
||
label="Descriere",
|
||
placeholder="Ce trebuie făcut? (1-3 propoziții)",
|
||
style=discord.TextStyle.paragraph,
|
||
required=True,
|
||
max_length=1000,
|
||
)
|
||
|
||
def __init__(self, slug: str) -> None:
|
||
super().__init__(timeout=VIEW_TIMEOUT)
|
||
self.slug = slug
|
||
self.title = f"Propune feature: {slug}"[:45]
|
||
|
||
async def on_submit(self, interaction: discord.Interaction) -> None:
|
||
try:
|
||
result = _ralph_propose(self.slug, str(self.description.value).strip())
|
||
except Exception as e:
|
||
log.exception("Ralph propose modal failed for %s", self.slug)
|
||
await interaction.response.send_message(
|
||
f"Eroare la propunere: {e}", ephemeral=True
|
||
)
|
||
return
|
||
await interaction.response.send_message(result, ephemeral=True)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# RalphProjectView — per-project action buttons
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class RalphProjectView(discord.ui.View):
|
||
"""Buttons: Propune feature / Vezi PRD / Start acum / Status / Stop / Înapoi."""
|
||
|
||
def __init__(self, slug: str) -> None:
|
||
super().__init__(timeout=VIEW_TIMEOUT)
|
||
self.slug = slug
|
||
|
||
@discord.ui.button(label="➕ Propune feature", style=discord.ButtonStyle.primary, row=0)
|
||
async def propose(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
|
||
await interaction.response.send_modal(RalphProposeModal(self.slug))
|
||
|
||
@discord.ui.button(label="👁 Vezi PRD", style=discord.ButtonStyle.secondary, row=0)
|
||
async def view_prd(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
|
||
await interaction.response.defer(ephemeral=True)
|
||
prd = _read_prd(self.slug)
|
||
if prd is None:
|
||
await interaction.followup.send(
|
||
f"Nu există PRD pentru {self.slug}. Aprobă cu /a {self.slug} și night-execute îl generează.",
|
||
ephemeral=True,
|
||
)
|
||
return
|
||
stories = prd.get("userStories", [])
|
||
done = sum(1 for s in stories if s.get("passes"))
|
||
total = len(stories)
|
||
lines = [f"**PRD pentru {self.slug}** — {done}/{total} stories"]
|
||
for s in stories[:12]:
|
||
mark = "✅" if s.get("passes") else "⏳"
|
||
sid = s.get("id", "?")
|
||
title = (s.get("title") or "")[:80]
|
||
lines.append(f"{mark} `{sid}` {title}")
|
||
if total > 12:
|
||
lines.append(f"\n…și încă {total - 12} stories.")
|
||
await interaction.followup.send("\n".join(lines), ephemeral=True)
|
||
|
||
@discord.ui.button(label="📊 Status", style=discord.ButtonStyle.secondary, row=0)
|
||
async def status(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
|
||
await interaction.response.defer(ephemeral=True)
|
||
text = _ralph_status(self.slug)
|
||
await interaction.followup.send(text, ephemeral=True)
|
||
|
||
@discord.ui.button(label="✅ Aprobă pentru tonight", style=discord.ButtonStyle.success, row=1)
|
||
async def approve(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
|
||
await interaction.response.defer(ephemeral=True)
|
||
text = _ralph_approve([self.slug])
|
||
await interaction.followup.send(text, ephemeral=True)
|
||
|
||
@discord.ui.button(label="🛑 Stop", style=discord.ButtonStyle.danger, row=1)
|
||
async def stop(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
|
||
await interaction.response.defer(ephemeral=True)
|
||
text = _ralph_stop(self.slug)
|
||
await interaction.followup.send(text, ephemeral=True)
|
||
|
||
@discord.ui.button(label="🧠 Planifică", style=discord.ButtonStyle.primary, row=2)
|
||
async def plan(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
|
||
await interaction.response.defer(ephemeral=True)
|
||
# Look up description from approved-tasks.json
|
||
description = ""
|
||
try:
|
||
data = _load_approved_tasks()
|
||
for p in data.get("projects", []):
|
||
if p.get("name", "").lower() == self.slug.lower():
|
||
description = p.get("description") or ""
|
||
break
|
||
except Exception:
|
||
log.exception("approved-tasks lookup failed")
|
||
if not description:
|
||
await interaction.followup.send(
|
||
f"Nu am descriere pentru `{self.slug}`. "
|
||
f"Adaugă mai întâi cu `/p {self.slug} <descriere>`.",
|
||
ephemeral=True,
|
||
)
|
||
return
|
||
channel_id = str(interaction.channel_id)
|
||
await interaction.followup.send(
|
||
f"🧠 Pornesc planning pentru `{self.slug}`… (durează ~60s)",
|
||
ephemeral=True,
|
||
)
|
||
try:
|
||
first = start_planning_session(
|
||
self.slug, description, channel_id, "discord",
|
||
)
|
||
except Exception as e:
|
||
log.exception("start_planning_session failed for %s", self.slug)
|
||
await interaction.followup.send(f"Planning blocat: {e}", ephemeral=True)
|
||
return
|
||
# Send first message of the planning agent + active keyboard
|
||
for chunk in _split_chunks(first, 1900):
|
||
await interaction.followup.send(chunk, ephemeral=True)
|
||
await interaction.followup.send(
|
||
"Răspunde aici. Apasă **Continuă faza** când ești gata să trec la următoarea.",
|
||
view=PlanningActiveView(),
|
||
ephemeral=True,
|
||
)
|
||
|
||
@discord.ui.button(label="🔙 Înapoi", style=discord.ButtonStyle.secondary, row=2)
|
||
async def back(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
|
||
await interaction.response.defer(ephemeral=True)
|
||
view = RalphRootView()
|
||
content = view.render_summary()
|
||
await interaction.edit_original_response(content=content, view=view)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# RalphRootView — workspace + active projects landing
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class _ProjectButton(discord.ui.Button):
|
||
"""Dynamic button representing a single project; opens RalphProjectView."""
|
||
|
||
def __init__(self, slug: str, status: str | None, row: int) -> None:
|
||
super().__init__(
|
||
label=_project_label(slug, status),
|
||
style=discord.ButtonStyle.secondary,
|
||
row=row,
|
||
custom_id=f"ralph_project:{slug}",
|
||
)
|
||
self.slug = slug
|
||
|
||
async def callback(self, interaction: discord.Interaction) -> None:
|
||
await interaction.response.defer(ephemeral=True)
|
||
view = RalphProjectView(self.slug)
|
||
content = f"**{self.slug}**\nAlege o acțiune:"
|
||
await interaction.edit_original_response(content=content, view=view)
|
||
|
||
|
||
class _RefreshButton(discord.ui.Button):
|
||
def __init__(self) -> None:
|
||
super().__init__(
|
||
label="🔄 Reîncarcă",
|
||
style=discord.ButtonStyle.primary,
|
||
row=4,
|
||
custom_id="ralph_refresh",
|
||
)
|
||
|
||
async def callback(self, interaction: discord.Interaction) -> None:
|
||
await interaction.response.defer(ephemeral=True)
|
||
view = RalphRootView()
|
||
await interaction.edit_original_response(
|
||
content=view.render_summary(), view=view
|
||
)
|
||
|
||
|
||
class _CloseButton(discord.ui.Button):
|
||
def __init__(self) -> None:
|
||
super().__init__(
|
||
label="❌ Închide",
|
||
style=discord.ButtonStyle.danger,
|
||
row=4,
|
||
custom_id="ralph_close",
|
||
)
|
||
|
||
async def callback(self, interaction: discord.Interaction) -> None:
|
||
await interaction.response.defer(ephemeral=True)
|
||
await interaction.edit_original_response(content="Închis.", view=None)
|
||
|
||
|
||
def _split_chunks(text: str, limit: int = 1900) -> list[str]:
|
||
"""Split a long message into Discord-safe chunks."""
|
||
if len(text) <= limit:
|
||
return [text]
|
||
chunks: list[str] = []
|
||
while text:
|
||
if len(text) <= limit:
|
||
chunks.append(text)
|
||
break
|
||
cut = text.rfind("\n", 0, limit)
|
||
if cut == -1:
|
||
cut = limit
|
||
chunks.append(text[:cut])
|
||
text = text[cut:].lstrip("\n")
|
||
return chunks
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Planning views (W2) — buttons that drive the planning conversation
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class PlanningActiveView(discord.ui.View):
|
||
"""Buttons shown DURING an active planning session: advance phase / cancel."""
|
||
|
||
def __init__(self) -> None:
|
||
super().__init__(timeout=VIEW_TIMEOUT)
|
||
|
||
@discord.ui.button(label="▶️ Continuă faza", style=discord.ButtonStyle.primary, row=0)
|
||
async def advance(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
|
||
await interaction.response.defer(ephemeral=True)
|
||
channel_id = str(interaction.channel_id)
|
||
try:
|
||
text, completed = planning_advance(channel_id, "discord")
|
||
except Exception as e:
|
||
log.exception("planning advance failed")
|
||
await interaction.followup.send(f"Eroare: {e}", ephemeral=True)
|
||
return
|
||
for chunk in _split_chunks(text):
|
||
await interaction.followup.send(chunk, ephemeral=True)
|
||
view: discord.ui.View = (
|
||
PlanningFinalView() if completed else PlanningActiveView()
|
||
)
|
||
await interaction.followup.send(
|
||
("Plan gata. Confirmi?" if completed else "Continuăm?"),
|
||
view=view, ephemeral=True,
|
||
)
|
||
|
||
@discord.ui.button(label="🛑 Anulează", style=discord.ButtonStyle.danger, row=0)
|
||
async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
|
||
await interaction.response.defer(ephemeral=True)
|
||
text = planning_cancel(str(interaction.channel_id), "discord")
|
||
await interaction.followup.send(text, ephemeral=True)
|
||
|
||
|
||
class PlanningFinalView(discord.ui.View):
|
||
"""Buttons shown when ALL planning phases finished — Dau drumul / Anulează."""
|
||
|
||
def __init__(self) -> None:
|
||
super().__init__(timeout=VIEW_TIMEOUT)
|
||
|
||
@discord.ui.button(label="✅ Dau drumul tonight", style=discord.ButtonStyle.success, row=0)
|
||
async def approve(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
|
||
await interaction.response.defer(ephemeral=True)
|
||
text = planning_approve(str(interaction.channel_id), "discord")
|
||
await interaction.followup.send(text, ephemeral=True)
|
||
|
||
@discord.ui.button(label="🛑 Anulează", style=discord.ButtonStyle.danger, row=0)
|
||
async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
|
||
await interaction.response.defer(ephemeral=True)
|
||
text = planning_cancel(str(interaction.channel_id), "discord")
|
||
await interaction.followup.send(text, ephemeral=True)
|
||
|
||
|
||
class RalphRootView(discord.ui.View):
|
||
"""Landing view: workspace projects with status emoji + refresh + close."""
|
||
|
||
def __init__(self) -> None:
|
||
super().__init__(timeout=VIEW_TIMEOUT)
|
||
self._build_buttons()
|
||
|
||
def _build_buttons(self) -> None:
|
||
projects = _list_workspace_projects()
|
||
# Discord limit: 5 buttons per row × 5 rows. Reserve last row for refresh/close.
|
||
# Project buttons go on rows 0..3 (max 20 projects).
|
||
for idx, slug in enumerate(projects[:20]):
|
||
row = idx // 5
|
||
status = _project_status(slug)
|
||
self.add_item(_ProjectButton(slug, status, row=row))
|
||
self.add_item(_RefreshButton())
|
||
self.add_item(_CloseButton())
|
||
|
||
def render_summary(self) -> str:
|
||
"""Build the message text shown above the buttons."""
|
||
try:
|
||
data = _load_approved_tasks()
|
||
except Exception:
|
||
data = {"projects": []}
|
||
active = [
|
||
p for p in data.get("projects", [])
|
||
if p.get("status") in ("pending", "approved", "running")
|
||
]
|
||
lines = ["📋 **Proiecte Ralph**"]
|
||
if active:
|
||
lines.append("")
|
||
lines.append("**Active:**")
|
||
for p in active[:10]:
|
||
emoji = _STATUS_EMOJI.get(p.get("status", ""), "·")
|
||
desc = (p.get("description") or "")[:60]
|
||
lines.append(f"{emoji} `{p.get('name')}` — {desc}")
|
||
lines.append("")
|
||
lines.append("Apasă pe un proiect pentru acțiuni, sau Reîncarcă pentru status fresh.")
|
||
return "\n".join(lines)
|