"""Pending-action service — curator-proposed mutations awaiting user approval. See models/pending_curator_action.py for the schema rationale. This module is the API everything else uses: - `create_pending(...)` is called from execute_tool's curator-authority interceptor when the curator tries to call a mutating tool. The caller has already captured a snapshot of the target's current state. - `list_pending(user_id)` is what the Needs Review panel reads. - `approve(action_id, user_id)` replays the original tool call via `execute_tool` with `authority="user"`, which bypasses the curator interceptor and just runs. On success, the row moves to status='approved'. - `reject(action_id, user_id)` marks the row 'rejected' without executing. History row stays around for audit; nothing runs. """ from __future__ import annotations import datetime import logging from datetime import timezone from typing import Any from sqlalchemy import select, update from fabledassistant.models import async_session from fabledassistant.models.pending_curator_action import PendingCuratorAction logger = logging.getLogger(__name__) async def create_pending( *, user_id: int, conv_id: int | None, action_type: str, target_type: str | None, target_id: int | None, target_label: str | None, payload: dict, current_snapshot: dict, ) -> PendingCuratorAction: """Persist a curator-proposed mutation for later review. Returns the created row so the interceptor can include its id in the tool-result envelope (handy for tests, logs, and future UI deep-links). """ async with async_session() as session: row = PendingCuratorAction( user_id=user_id, conv_id=conv_id, action_type=action_type, target_type=target_type, target_id=target_id, target_label=target_label, payload=payload or {}, current_snapshot=current_snapshot or {}, status="pending", ) session.add(row) await session.commit() await session.refresh(row) logger.info( "Curator proposed %s on %s/%s (label=%r) — action_id=%d, conv=%s", action_type, target_type, target_id, target_label, row.id, conv_id, ) return row async def list_pending(user_id: int, limit: int = 50) -> list[dict]: """Return the user's pending actions, newest first. Only `status='pending'` — once a row is approved or rejected, it drops off this list. Use the index `ix_pending_curator_actions_user_pending`. """ async with async_session() as session: result = await session.execute( select(PendingCuratorAction) .where( PendingCuratorAction.user_id == user_id, PendingCuratorAction.status == "pending", ) .order_by(PendingCuratorAction.created_at.desc()) .limit(limit) ) return [row.to_dict() for row in result.scalars().all()] async def _load_for_user(action_id: int, user_id: int) -> PendingCuratorAction | None: async with async_session() as session: result = await session.execute( select(PendingCuratorAction).where( PendingCuratorAction.id == action_id, PendingCuratorAction.user_id == user_id, ) ) return result.scalar_one_or_none() async def _mark_reviewed(action_id: int, status: str) -> None: async with async_session() as session: await session.execute( update(PendingCuratorAction) .where(PendingCuratorAction.id == action_id) .values(status=status, reviewed_at=datetime.datetime.now(timezone.utc)) ) await session.commit() async def approve(action_id: int, user_id: int) -> dict[str, Any]: """Replay the curator's proposed action with user authority and mark approved. The replay path goes through `execute_tool` with `authority="user"` so the curator interceptor doesn't intercept its own replay (which would create another pending row and loop forever). Returns the tool-result dict from execute_tool. Action stays in `pending` if replay returns an error so the user can retry; only transitions to `approved` on success. """ row = await _load_for_user(action_id, user_id) if row is None: return {"success": False, "error": "Pending action not found"} if row.status != "pending": return { "success": False, "error": f"Action already {row.status}", } from fabledassistant.services.tools import execute_tool try: # authority="user" bypasses the curator interceptor — required so # the replay actually executes the mutating tool instead of # creating another pending row (which would infinite-loop). result = await execute_tool( user_id, row.action_type, row.payload, conv_id=row.conv_id, authority="user", ) except Exception as e: logger.exception( "Replay of curator action %d (%s) failed", action_id, row.action_type, ) return {"success": False, "error": f"{type(e).__name__}: {e}"} if result.get("success", True) and not result.get("error"): await _mark_reviewed(action_id, "approved") return result async def reject(action_id: int, user_id: int) -> dict[str, Any]: """Mark the pending action as rejected without executing anything.""" row = await _load_for_user(action_id, user_id) if row is None: return {"success": False, "error": "Pending action not found"} if row.status != "pending": return { "success": False, "error": f"Action already {row.status}", } await _mark_reviewed(action_id, "rejected") return {"success": True, "id": action_id, "status": "rejected"}