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

202 lines
8.7 KiB
Python

"""Session repository for the MAF roundtable experiment (owner-scoped CRUD).
Owner-scoped by ``user_id``: every read / update / delete checks the session's
owner against the caller. Admins bypass the owner filter when
``admin_diagnostic_access`` is true (handled in the service / router layer, not
here — the repo exposes both scoped and unscoped lookups).
Capability snapshot: ``capability_snapshot_json`` is frozen at creation time
(``create_session``) and never mutated by later updates — it is the audit /
reproducibility record of which agents / tools / skills were configured when
the session started. ``fact_ledger_json`` / ``final_result_json`` are mutable
and updated as the run progresses.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.maf_roundtable.model import MafRoundtableSessionRow
def _now() -> datetime:
return datetime.now(UTC)
def _to_dict(row: MafRoundtableSessionRow) -> dict[str, Any]:
return {
"id": row.id,
"user_id": row.user_id,
"title": row.title,
"task_text": row.task_text,
"task_payload": row.task_payload_json,
"mode": row.mode,
"status": row.status,
"model_name": row.model_name,
"reasoning_effort": row.reasoning_effort,
"manager_agent_id": row.manager_agent_id,
"max_rounds": row.max_rounds,
"write_policy": row.write_policy,
"capability_snapshot": row.capability_snapshot_json,
"fact_ledger": row.fact_ledger_json,
"final_result": row.final_result_json,
"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 MafSessionRepository:
"""Owner-scoped CRUD for MAF roundtable sessions."""
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def create_session(self, data: dict[str, Any]) -> dict[str, Any]:
"""Create a new session. ``data`` must include ``user_id`` and ``task_text``."""
now = _now()
row = MafRoundtableSessionRow(
id=data.get("id") or uuid4().hex,
user_id=str(data["user_id"]),
title=str(data.get("title") or ""),
task_text=str(data["task_text"]),
task_payload_json=data.get("task_payload"),
mode=str(data.get("mode") or "structured"),
status=str(data.get("status") or "draft"),
model_name=data.get("model_name"),
reasoning_effort=data.get("reasoning_effort"),
manager_agent_id=data.get("manager_agent_id"),
max_rounds=int(data.get("max_rounds", 8)),
write_policy=str(data.get("write_policy") or "confirm_high_risk"),
capability_snapshot_json=data.get("capability_snapshot"),
fact_ledger_json=data.get("fact_ledger"),
final_result_json=data.get("final_result"),
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
return await self.get_session(row.id, user_id=None) # type: ignore[arg-type]
async def get_session(
self, session_id: str, *, user_id: str | None = "REQUIRED"
) -> dict[str, Any] | None:
"""Read one session.
``user_id`` scoping:
- ``"REQUIRED"`` (default): enforce owner scope — returns ``None`` if the
session is missing **or** owned by another user.
- ``None``: bypass owner scope (admin / internal path) — returns the
row regardless of owner.
"""
async with self._sf() as session:
stmt = select(MafRoundtableSessionRow).where(MafRoundtableSessionRow.id == session_id).limit(1)
row = (await session.execute(stmt)).scalars().first()
if row is None:
return None
if user_id is not None and row.user_id != user_id:
return None
return _to_dict(row)
async def list_sessions(
self,
*,
user_id: str | None = None,
status: str | None = None,
mode: str | None = None,
limit: int = 100,
offset: int = 0,
) -> list[dict[str, Any]]:
"""List sessions, newest-first.
``user_id=None`` lists across all users (admin / diagnostic). Otherwise
scoped to the caller. ``status`` / ``mode`` are optional filters.
"""
stmt = select(MafRoundtableSessionRow)
if user_id is not None:
stmt = stmt.where(MafRoundtableSessionRow.user_id == user_id)
if status is not None:
stmt = stmt.where(MafRoundtableSessionRow.status == status)
if mode is not None:
stmt = stmt.where(MafRoundtableSessionRow.mode == mode)
stmt = stmt.order_by(MafRoundtableSessionRow.created_at.desc()).limit(limit).offset(offset)
async with self._sf() as session:
rows = (await session.execute(stmt)).scalars().all()
return [_to_dict(r) for r in rows]
async def count_sessions(
self, *, user_id: str | None = None, status: str | None = None, mode: str | None = None
) -> int:
stmt = select(func.count()).select_from(MafRoundtableSessionRow)
if user_id is not None:
stmt = stmt.where(MafRoundtableSessionRow.user_id == user_id)
if status is not None:
stmt = stmt.where(MafRoundtableSessionRow.status == status)
if mode is not None:
stmt = stmt.where(MafRoundtableSessionRow.mode == mode)
async with self._sf() as session:
return int((await session.execute(stmt)).scalar_one())
async def update_session(
self, session_id: str, *, user_id: str | None, fields: dict[str, Any]
) -> dict[str, Any] | None:
"""Partial update. Only allowed when the session is not yet ``running``
(enforced by the service layer). ``capability_snapshot`` is **never**
mutated here — it is frozen at creation. Returns ``None`` if the session
is missing or owned by another user.
"""
async with self._sf() as session:
stmt = select(MafRoundtableSessionRow).where(MafRoundtableSessionRow.id == session_id).limit(1)
row = (await session.execute(stmt)).scalars().first()
if row is None:
return None
if user_id is not None and row.user_id != user_id:
return None
for key in ("title", "task_text", "task_payload", "mode", "status", "model_name",
"reasoning_effort", "manager_agent_id", "max_rounds", "write_policy",
"fact_ledger", "final_result"):
if key in fields:
value = fields[key]
col = {
"task_payload": "task_payload_json",
"capability_snapshot": "capability_snapshot_json",
"fact_ledger": "fact_ledger_json",
"final_result": "final_result_json",
}.get(key, key)
setattr(row, col, value)
row.updated_at = _now()
await session.commit()
return _to_dict(row)
async def delete_session(self, session_id: str, *, user_id: str | None) -> bool:
"""Delete a session (and its children via FK CASCADE). Returns ``True``
if a row was deleted, ``False`` if missing or owned by another user.
"""
async with self._sf() as session:
stmt = select(MafRoundtableSessionRow).where(MafRoundtableSessionRow.id == session_id).limit(1)
row = (await session.execute(stmt)).scalars().first()
if row is None:
return False
if user_id is not None and row.user_id != user_id:
return False
await session.execute(
delete(MafRoundtableSessionRow).where(MafRoundtableSessionRow.id == session_id)
)
await session.commit()
return True
async def set_status(self, session_id: str, status: str) -> None:
"""Internal: update only the status column (used by the run lifecycle)."""
async with self._sf() as session:
await session.execute(
update(MafRoundtableSessionRow)
.where(MafRoundtableSessionRow.id == session_id)
.values(status=status, updated_at=_now())
)
await session.commit()