Files
董宏凯andCursor e690475e6e feat(maf-roundtable): add Phase-1 multi-agent roundtable infrastructure
Ship an independent, feature-flag-gated MAF roundtable skeleton with six
isolated tables, atomic single-transaction run launch/cancel, owner-scoped
read/write split, shared config enums, and HTTP/concurrency regression tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 01:07:05 +08:00

133 lines
5.5 KiB
Python

"""Approval repository for the MAF roundtable experiment.
Pending human-approval requests for high-risk external writes. Phase 1 ships
the storage + CRUD + decision transition; the actual pause/resume wiring
(checkpoint restore after a decision) is Phase 5.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.maf_roundtable.model import MafRoundtableApprovalRow
def _now() -> datetime:
return datetime.now(UTC)
def _to_dict(row: MafRoundtableApprovalRow) -> dict[str, Any]:
return {
"id": row.id,
"session_id": row.session_id,
"run_id": row.run_id,
"action_id": row.action_id,
"requested_by_agent_id": row.requested_by_agent_id,
"status": row.status,
"summary": row.summary,
"payload": row.payload_redacted_json,
"decided_by": row.decided_by,
"decided_at": row.decided_at.isoformat() if isinstance(row.decided_at, datetime) else row.decided_at,
"expires_at": row.expires_at.isoformat() if isinstance(row.expires_at, datetime) else row.expires_at,
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
}
class MafApprovalRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def create_approval(self, data: dict[str, Any]) -> dict[str, Any]:
now = _now()
row = MafRoundtableApprovalRow(
id=data.get("id") or uuid4().hex,
session_id=str(data["session_id"]),
run_id=data.get("run_id"),
action_id=data.get("action_id"),
requested_by_agent_id=data.get("requested_by_agent_id"),
status=str(data.get("status") or "pending"),
summary=str(data.get("summary") or ""),
payload_redacted_json=data.get("payload"),
decided_by=data.get("decided_by"),
decided_at=data.get("decided_at"),
expires_at=data.get("expires_at"),
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
return await self.get_approval(row.id) # type: ignore[arg-type]
async def get_approval(self, approval_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
stmt = select(MafRoundtableApprovalRow).where(MafRoundtableApprovalRow.id == approval_id).limit(1)
row = (await session.execute(stmt)).scalars().first()
return _to_dict(row) if row is not None else None
async def list_approvals(
self, *, run_id: str | None = None, status: str | None = None
) -> list[dict[str, Any]]:
stmt = select(MafRoundtableApprovalRow)
if run_id is not None:
stmt = stmt.where(MafRoundtableApprovalRow.run_id == run_id)
if status is not None:
stmt = stmt.where(MafRoundtableApprovalRow.status == status)
stmt = stmt.order_by(MafRoundtableApprovalRow.created_at.asc())
async with self._sf() as session:
rows = (await session.execute(stmt)).scalars().all()
return [_to_dict(r) for r in rows]
async def decide(
self,
approval_id: str,
*,
decision: str,
decided_by: str | None = None,
) -> dict[str, Any] | None:
"""Record a decision atomically. ``decision`` is ``approved`` /
``rejected`` / ``expired``.
The transition is a single conditional UPDATE:
``UPDATE … SET status=:decision … WHERE id=:id AND status='pending'``.
``rowcount == 1`` means this caller won the race; ``rowcount == 0``
means the approval is missing **or** was already decided by someone
else, which the caller surfaces as a conflict. This guarantees a
decision can never be overwritten by a concurrent (or later) request,
and the winning approver + timestamp are the ones persisted.
"""
if decision not in ("approved", "rejected", "expired"):
raise ValueError(f"invalid approval decision: {decision}")
now = _now()
async with self._sf() as session:
result = await session.execute(
update(MafRoundtableApprovalRow)
.where(
MafRoundtableApprovalRow.id == approval_id,
MafRoundtableApprovalRow.status == "pending",
)
.values(
status=decision,
decided_by=decided_by,
decided_at=now,
updated_at=now,
)
)
await session.commit()
if result.rowcount == 1: # type: ignore[union-attr]
# This caller won the race — the decision is persisted.
return await self.get_approval(approval_id)
# rowcount == 0: either the approval is missing, or it was already
# decided by a concurrent request. Distinguish so the service layer can
# map missing→404 and already-decided→409.
existing = await self.get_approval(approval_id)
if existing is None:
return None
raise ValueError(f"approval already decided: {existing['status']}")