"""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()