feat(curator): pending_curator_actions schema + service (C2/5)
The backend foundation for curator-proposed mutations awaiting user
approval. No tools route to this yet — that's C3's job. This commit
just lands the schema and the service API everything else will use.
Migration 0051 — new table:
- id, user_id (CASCADE), conv_id (SET NULL — survives conv deletion).
- action_type (the tool name to replay), target_type/target_id/
target_label (display hints).
- payload (jsonb — the curator's proposed args, replayed verbatim
on approval).
- current_snapshot (jsonb — the target's state at proposal time, so
the review UI can render an honest diff even if other work modified
the entity between proposal and review).
- status ('pending' / 'approved' / 'rejected') + CHECK constraint.
- created_at / reviewed_at.
- Partial index ix_pending_curator_actions_user_pending narrowed to
status='pending' — the Needs Review panel hits this constantly,
history rows just accumulate.
Model: PendingCuratorAction with to_dict() for API serialization.
Service services/pending_actions.py:
- create_pending(...) — called from the curator interceptor (C3).
Accepts an already-fetched current_snapshot so each mutating tool
can capture target state in its own way (notes vs milestones vs
profile have different shapes).
- list_pending(user_id, limit=50) — what the Needs Review panel reads.
- approve(action_id, user_id) — replays via execute_tool and marks
approved on success. Stays pending on replay error so the user
can retry. NOTE: approve passes the request through execute_tool
unchanged for now; C3 will add authority='user' so the upcoming
curator interceptor doesn't re-intercept the replay and loop.
- reject(action_id, user_id) — marks rejected with no execution.
C3 next: wires the curator interceptor (authority='curator' on
execute_tool routes mutating tools to create_pending instead of
running them), adds the mutating tools back to the curator's
allowlist, and updates approve() to pass authority='user'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
"""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:
|
||||
# NOTE (C2 → C3 hand-off): execute_tool gains an `authority` kwarg
|
||||
# in commit C3 that the curator interceptor checks. Approval replay
|
||||
# MUST pass authority="user" once that lands, otherwise the
|
||||
# interceptor re-routes the replay back into pending and loops.
|
||||
# Until C3 lands, execute_tool is identity (no interceptor exists
|
||||
# yet), so this call runs cleanly.
|
||||
result = await execute_tool(
|
||||
user_id,
|
||||
row.action_type,
|
||||
row.payload,
|
||||
conv_id=row.conv_id,
|
||||
)
|
||||
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"}
|
||||
Reference in New Issue
Block a user