- Backend: action_coordinator, capability_snapshot, event_normalizer, tool_executor updates - Backend: approved_tool_executor with write guard, action leases, migration script - Frontend: maf-roundtable module (API client, event timeline, approval cards, fact flow panel) - Frontend: MafRoutes, MafSessionsPage, MafRunDetailPage - Tests: action_leases, migration_phase5, phase5_entry_e2e, real_write_e2e/http_e2e - Docs: update implementation plan Co-authored-by: Cursor <cursoragent@cursor.com>
634 lines
28 KiB
Python
634 lines
28 KiB
Python
"""Service layer for the MAF roundtable experiment.
|
|
|
|
Enforces the cross-cutting policies the individual repositories don't:
|
|
|
|
- **User isolation (MAF-01-07)**: every read / write is scoped to the caller's
|
|
``user_id``. Read and write scopes are deliberately **separate**:
|
|
``admin_diagnostic_access`` only widens the *read* scope (cross-user
|
|
diagnostics) — it never grants cross-user *writes*. Every mutating operation
|
|
(update / delete session, start / cancel run, append event, decide approval)
|
|
is owner-checked via ``_write_scope_user`` regardless of the diagnostic flag.
|
|
- **Run gating + atomic launch**: a session may have at most one active run
|
|
(``starting`` / ``running`` / ``waiting_*``). The launch is a **single
|
|
database transaction** (``store.claim_and_create_pending_run``) that
|
|
conditionally updates the session to ``running`` and inserts a ``pending``
|
|
run together — not a multi-step claim/create/set_status dance, and not a
|
|
Python lock. Concurrent callers cannot both win; a mid-transaction failure
|
|
rolls everything back so the session never sticks in a half-claimed state.
|
|
- **Feature flag**: when ``maf_roundtable.enabled`` is off, write paths raise
|
|
``MafDisabledError`` (the router maps it to 503); reads stay open so admins
|
|
can review history.
|
|
- **Config-driven defaults**: ``mode`` / ``write_policy`` / ``max_rounds``
|
|
default to the deployed ``MafRoundtableConfig`` values when the create
|
|
request omits them; an explicit request value always wins.
|
|
|
|
The service does **not** load the MAF SDK — that is Phase 2+ (the
|
|
``DeerFlowAgentAdapter``). Phase 1 ships the persistence + API skeleton; until
|
|
a real executor exists, a launched run is parked in ``pending`` (clearly a
|
|
reserved / not-yet-executing state) rather than pretending to be ``running``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
from app.maf_roundtable.sanitize import sanitize, sanitize_payload
|
|
from deerflow.config.maf_roundtable_config import MafRoundtableConfig
|
|
from deerflow.persistence.maf_roundtable.store import MafRoundtableStore
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MafDisabledError(RuntimeError):
|
|
"""Raised when a write is attempted while the feature flag is off."""
|
|
|
|
|
|
class MafNotFoundError(KeyError):
|
|
"""Raised when a session / run / action / approval is missing or out of scope."""
|
|
|
|
def __init__(self, what: str, ident: str) -> None:
|
|
super().__init__(f"{what} not found or out of scope: {ident}")
|
|
self.what = what
|
|
self.ident = ident
|
|
|
|
|
|
class MafConflictError(RuntimeError):
|
|
"""Raised when a session is in a state that rejects the requested edit."""
|
|
|
|
|
|
class MafForbiddenError(PermissionError):
|
|
"""Raised when a caller is authenticated but not the owner of the target
|
|
resource (e.g. an admin trying to *write* another user's session while
|
|
only holding cross-user *read* via the diagnostic flag)."""
|
|
|
|
def __init__(self, what: str, ident: str) -> None:
|
|
super().__init__(f"not the owner of {what}: {ident}")
|
|
self.what = what
|
|
self.ident = ident
|
|
|
|
|
|
class MafConfigurationError(RuntimeError):
|
|
"""MAF is enabled but a required safety prerequisite is missing.
|
|
|
|
Raised by :func:`validate_maf_prerequisites` on the startup path so the
|
|
feature can never come up in a half-safe state ("MAF on, approval guard not
|
|
mounted"). The gateway lifespan catches this and forces the feature OFF
|
|
(clearly logged) rather than aborting the whole (experimental, opt-in)
|
|
service.
|
|
"""
|
|
|
|
|
|
def get_maf_write_guard_readiness(*, app_config: Any | None = None) -> dict[str, Any]:
|
|
"""Return non-sensitive WriteGuard readiness information for MAF health.
|
|
|
|
A missing secret intentionally does not disable read-only MAF work:
|
|
WriteGuard fails closed before any external write can execute. ``ready``
|
|
is therefore the signal that the complete approval + encryption path is
|
|
usable, while ``enabled`` describes whether the middleware is mounted.
|
|
"""
|
|
import os
|
|
|
|
from deerflow.config.write_guard_config import get_write_guard_config
|
|
from deerflow.reflection import resolve_variable
|
|
|
|
wg = getattr(app_config, "write_guard", None) if app_config is not None else None
|
|
if wg is None:
|
|
wg = get_write_guard_config()
|
|
|
|
provider = getattr(wg, "provider", None)
|
|
provider_configured = bool(
|
|
provider
|
|
and getattr(provider, "policy_use", "")
|
|
and getattr(provider, "interceptor_use", "")
|
|
)
|
|
bridge_resolvable = False
|
|
if provider_configured:
|
|
try:
|
|
resolve_variable(provider.policy_use)
|
|
resolve_variable(provider.interceptor_use)
|
|
bridge_resolvable = True
|
|
except Exception: # noqa: BLE001 - health must remain safe and non-fatal
|
|
bridge_resolvable = False
|
|
|
|
secret_configured = bool((os.getenv("MAF_ACTION_SECRET_KEY") or "").strip())
|
|
if not getattr(wg, "enabled", False):
|
|
status = "disabled"
|
|
elif not provider_configured or not bridge_resolvable:
|
|
status = "provider_unavailable"
|
|
elif not secret_configured:
|
|
status = "missing_secret"
|
|
else:
|
|
status = "ready"
|
|
|
|
return {
|
|
"enabled": bool(getattr(wg, "enabled", False)),
|
|
"ready": status == "ready",
|
|
"secret_configured": secret_configured,
|
|
"status": status,
|
|
}
|
|
|
|
|
|
def validate_maf_prerequisites() -> None:
|
|
"""Fail loudly if MAF is enabled without its full safety stack in place.
|
|
|
|
Checks, in order:
|
|
1. ``write_guard.enabled`` is true (the approval guard is mounted);
|
|
2. ``write_guard.provider`` is complete (``policy_use`` + ``interceptor_use``);
|
|
3. the bridge factories actually resolve (the app-layer guard is importable);
|
|
4. secret readiness is reported separately; a missing secret must not
|
|
disable read-only MAF work, while writes fail closed before execution.
|
|
|
|
Raises ``MafConfigurationError`` on the first unmet condition. Callers on
|
|
the startup path treat that as "force the feature off"; direct unit-test
|
|
construction of :class:`MafRoundtableService` does NOT call this.
|
|
"""
|
|
from deerflow.config.write_guard_config import get_write_guard_config
|
|
from deerflow.reflection import resolve_variable
|
|
|
|
wg = get_write_guard_config()
|
|
if not wg.enabled:
|
|
raise MafConfigurationError(
|
|
"maf_roundtable.enabled=true requires write_guard.enabled=true "
|
|
"(the approval guard must be mounted)"
|
|
)
|
|
provider = wg.provider
|
|
if provider is None or not provider.policy_use or not provider.interceptor_use:
|
|
raise MafConfigurationError(
|
|
"maf_roundtable.enabled=true requires a complete write_guard.provider "
|
|
"(policy_use + interceptor_use)"
|
|
)
|
|
try:
|
|
resolve_variable(provider.policy_use)
|
|
resolve_variable(provider.interceptor_use)
|
|
except Exception as exc: # noqa: BLE001
|
|
raise MafConfigurationError(
|
|
f"write_guard bridge factories are not resolvable: {exc}"
|
|
) from exc
|
|
|
|
|
|
# Session statuses that may be edited (config changes).
|
|
_EDITABLE_STATUSES = {"draft", "completed", "failed", "cancelled"}
|
|
# Session statuses that mean a run is in flight (no config edits). ``starting``
|
|
# is the transient CAS-claim state between "claimed" and "run row created".
|
|
# ``cancel_failed`` blocks edits because the cancel cascade is incomplete and
|
|
# the may still have active sub-runs.
|
|
_RUNNING_STATUSES = {"starting", "running", "waiting_approval", "waiting_input", "cancelling", "cancel_failed"}
|
|
|
|
|
|
class MafRoundtableService:
|
|
def __init__(
|
|
self,
|
|
store: MafRoundtableStore,
|
|
*,
|
|
enabled: bool,
|
|
admin_diagnostic_access: bool,
|
|
config: MafRoundtableConfig | None = None,
|
|
agent_store: Any | None = None,
|
|
app_config: Any | None = None,
|
|
) -> None:
|
|
self._store = store
|
|
self._admin_diagnostic_access = admin_diagnostic_access
|
|
self._agent_store = agent_store
|
|
self._app_config = app_config
|
|
# Defaults source for create_session; falls back to the pydantic
|
|
# defaults when no config is injected (keeps unit tests lightweight).
|
|
self._config = config or MafRoundtableConfig()
|
|
|
|
# A missing MAF_ACTION_SECRET_KEY does not disable read-only MAF
|
|
# operation. The action coordinator raises before creating or
|
|
# executing an external-write action, so writes fail closed until the
|
|
# deployment injects the secret through the environment.
|
|
self._enabled = enabled
|
|
|
|
@property
|
|
def store(self) -> MafRoundtableStore:
|
|
return self._store
|
|
|
|
@property
|
|
def enabled(self) -> bool:
|
|
return self._enabled
|
|
|
|
@property
|
|
def config(self) -> MafRoundtableConfig:
|
|
return self._config
|
|
|
|
def _require_enabled(self) -> None:
|
|
if not self._enabled:
|
|
raise MafDisabledError("MAF roundtable feature is disabled")
|
|
|
|
# ── permission scopes ──────────────────────────────────────────────────
|
|
|
|
def _read_scope_user(self, user_id: str | None, is_admin: bool) -> str | None:
|
|
"""User id to scope **read** queries by.
|
|
|
|
- Admin with diagnostic access: ``None`` (cross-user read for diagnostics).
|
|
- Anyone else: their own ``user_id`` (enforced).
|
|
"""
|
|
if is_admin and self._admin_diagnostic_access:
|
|
return None
|
|
return user_id
|
|
|
|
def _write_scope_user(self, user_id: str | None, is_admin: bool) -> str | None:
|
|
"""User id to scope **write** operations by.
|
|
|
|
The diagnostic flag is read-only — it never widens the write scope.
|
|
Every mutating operation is owner-checked against the caller's own
|
|
``user_id`` (admin or not). A future explicit operator/admin force-stop
|
|
would be a separate, audited code path, not a reuse of this flag.
|
|
"""
|
|
return user_id
|
|
|
|
# ── sessions ──────────────────────────────────────────────────────────
|
|
|
|
async def create_session(
|
|
self, *, user_id: str, body: dict[str, Any], is_admin: bool = False
|
|
) -> dict[str, Any]:
|
|
self._require_enabled()
|
|
cfg = self._config
|
|
from app.maf_roundtable.capability_snapshot import (
|
|
AgentNotAccessibleError,
|
|
InvalidExcludedToolsError,
|
|
build_trusted_capability_snapshot,
|
|
seat_snapshot_for_agent,
|
|
)
|
|
from deerflow.config import get_app_config
|
|
|
|
app_config = self._app_config or get_app_config()
|
|
raw_participants = list(body.get("participants") or [])
|
|
# Strip any client-supplied snapshot / tool metadata — never trusted.
|
|
participants: list[dict[str, Any]] = []
|
|
for p in raw_participants:
|
|
participants.append(
|
|
{
|
|
"agent_id": p.get("agent_id"),
|
|
"display_name": p.get("display_name", ""),
|
|
"role": p.get("role", "seat"),
|
|
"sort_order": p.get("sort_order", 0),
|
|
"thread_id": p.get("thread_id"),
|
|
"excluded_tools": p.get("excluded_tools") or [],
|
|
}
|
|
)
|
|
|
|
try:
|
|
trusted_snapshot = await build_trusted_capability_snapshot(
|
|
user_id=user_id,
|
|
participants=participants,
|
|
app_config=app_config,
|
|
agent_store=self._agent_store,
|
|
session_model_name=body.get("model_name"),
|
|
session_reasoning_effort=body.get("reasoning_effort"),
|
|
session_excluded_tools=body.get("excluded_tools"),
|
|
)
|
|
except AgentNotAccessibleError as exc:
|
|
raise MafForbiddenError("agent", exc.agent_id) from exc
|
|
except InvalidExcludedToolsError as exc:
|
|
raise MafConflictError(
|
|
f"excluded_tools not allowed for agent {exc.agent_id}: {exc.tools}"
|
|
) from exc
|
|
|
|
for p in participants:
|
|
aid = p.get("agent_id")
|
|
if aid:
|
|
p["capability_snapshot"] = seat_snapshot_for_agent(trusted_snapshot, str(aid))
|
|
|
|
session_data: dict[str, Any] = {
|
|
"user_id": user_id,
|
|
"title": body.get("title", ""),
|
|
"task_text": body["task_text"],
|
|
"task_payload": body.get("task_payload"),
|
|
"mode": body.get("mode") or cfg.default_mode,
|
|
"status": "draft",
|
|
"model_name": body.get("model_name"),
|
|
"reasoning_effort": body.get("reasoning_effort"),
|
|
"manager_agent_id": body.get("manager_agent_id"),
|
|
"max_rounds": body.get("max_rounds") if body.get("max_rounds") is not None else cfg.default_max_rounds,
|
|
"write_policy": body.get("write_policy") or cfg.default_write_policy,
|
|
"capability_snapshot": trusted_snapshot,
|
|
}
|
|
session = await self._store.sessions.create_session(session_data)
|
|
if participants:
|
|
await self._store.participants.replace_participants(session["id"], participants)
|
|
return await self.get_session(session_id=session["id"], user_id=user_id, is_admin=is_admin)
|
|
|
|
async def get_session(self, *, session_id: str, user_id: str | None, is_admin: bool = False) -> dict[str, Any]:
|
|
scope = self._read_scope_user(user_id, is_admin)
|
|
session = await self._store.sessions.get_session(session_id, user_id=scope)
|
|
if session is None:
|
|
raise MafNotFoundError("session", session_id)
|
|
participants = await self._store.participants.list_participants(session_id)
|
|
session["participants"] = participants
|
|
return session
|
|
|
|
async def list_sessions(
|
|
self,
|
|
*,
|
|
user_id: str | None,
|
|
is_admin: bool = False,
|
|
status: str | None = None,
|
|
mode: str | None = None,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
) -> tuple[list[dict[str, Any]], int]:
|
|
scope = self._read_scope_user(user_id, is_admin)
|
|
rows = await self._store.sessions.list_sessions(
|
|
user_id=scope, status=status, mode=mode, limit=limit, offset=offset
|
|
)
|
|
total = await self._store.sessions.count_sessions(user_id=scope, status=status, mode=mode)
|
|
# Attach participants to each row (cheap — Phase 1 lists are bounded).
|
|
for row in rows:
|
|
row["participants"] = await self._store.participants.list_participants(row["id"])
|
|
return rows, total
|
|
|
|
async def update_session(
|
|
self, *, session_id: str, user_id: str | None, fields: dict[str, Any], is_admin: bool = False
|
|
) -> dict[str, Any]:
|
|
self._require_enabled()
|
|
# Writes are owner-scoped even for admins (diagnostic access is read-only).
|
|
scope = self._write_scope_user(user_id, is_admin)
|
|
existing = await self._store.sessions.get_session(session_id, user_id=scope)
|
|
if existing is None:
|
|
# Distinguish "missing" from "owned by someone else" so an admin
|
|
# (who can read cross-user) gets a clear 403 rather than a 404.
|
|
if is_admin and await self._store.sessions.get_session(session_id, user_id=None) is not None:
|
|
raise MafForbiddenError("session", session_id)
|
|
raise MafNotFoundError("session", session_id)
|
|
if existing["status"] in _RUNNING_STATUSES:
|
|
raise MafConflictError(
|
|
f"session {session_id} is running ({existing['status']}); config edits not allowed"
|
|
)
|
|
# capability_snapshot is frozen — never accept it on update.
|
|
fields.pop("capability_snapshot", None)
|
|
updated = await self._store.sessions.update_session(session_id, user_id=scope, fields=fields)
|
|
if updated is None:
|
|
raise MafNotFoundError("session", session_id)
|
|
if "participants" in fields and fields["participants"] is not None:
|
|
await self._store.participants.replace_participants(session_id, fields["participants"])
|
|
return await self.get_session(session_id=session_id, user_id=user_id, is_admin=is_admin)
|
|
|
|
async def delete_session(self, *, session_id: str, user_id: str | None, is_admin: bool = False) -> None:
|
|
self._require_enabled()
|
|
scope = self._write_scope_user(user_id, is_admin)
|
|
# Pre-check existence cross-user so an admin gets 403, not 404.
|
|
if await self._store.sessions.get_session(session_id, user_id=scope) is None:
|
|
if is_admin and await self._store.sessions.get_session(session_id, user_id=None) is not None:
|
|
raise MafForbiddenError("session", session_id)
|
|
raise MafNotFoundError("session", session_id)
|
|
ok = await self._store.sessions.delete_session(session_id, user_id=scope)
|
|
if not ok:
|
|
raise MafNotFoundError("session", session_id)
|
|
|
|
# ── runs ────────────────────────────────────────────────────────────────
|
|
|
|
async def create_run(
|
|
self, *, session_id: str, user_id: str | None, workflow_version: str | None = None, is_admin: bool = False
|
|
) -> dict[str, Any]:
|
|
self._require_enabled()
|
|
# Owner-only write: an admin cannot start another user's run. The
|
|
# owner_user_id also rides in the atomic SQL (not just this pre-check).
|
|
if not user_id:
|
|
raise MafNotFoundError("session", session_id)
|
|
|
|
run = await self._store.claim_and_create_pending_run(
|
|
session_id=session_id,
|
|
owner_user_id=user_id,
|
|
workflow_version=workflow_version,
|
|
)
|
|
if run is not None:
|
|
return run
|
|
|
|
# Classify the failed claim: missing / wrong-owner / already active.
|
|
any_session = await self._store.sessions.get_session(session_id, user_id=None)
|
|
if any_session is None:
|
|
raise MafNotFoundError("session", session_id)
|
|
if any_session["user_id"] != user_id:
|
|
if is_admin:
|
|
raise MafForbiddenError("session", session_id)
|
|
raise MafNotFoundError("session", session_id)
|
|
raise MafConflictError(
|
|
f"session {session_id} already has an active run; concurrent launch rejected"
|
|
)
|
|
|
|
async def get_run(self, *, run_id: str, user_id: str | None, is_admin: bool = False) -> dict[str, Any]:
|
|
run = await self._store.runs.get_run(run_id)
|
|
if run is None:
|
|
raise MafNotFoundError("run", run_id)
|
|
# Enforce session owner scope (read scope — admins may diagnose).
|
|
scope = self._read_scope_user(user_id, is_admin)
|
|
session = await self._store.sessions.get_session(run["session_id"], user_id=scope)
|
|
if session is None:
|
|
raise MafNotFoundError("run", run_id)
|
|
return run
|
|
|
|
async def list_runs(self, *, session_id: str, user_id: str | None, is_admin: bool = False) -> list[dict[str, Any]]:
|
|
scope = self._read_scope_user(user_id, is_admin)
|
|
session = await self._store.sessions.get_session(session_id, user_id=scope)
|
|
if session is None:
|
|
raise MafNotFoundError("session", session_id)
|
|
return await self._store.runs.list_runs(session_id)
|
|
|
|
async def cancel_run(self, *, run_id: str, user_id: str | None, is_admin: bool = False) -> dict[str, Any]:
|
|
self._require_enabled()
|
|
if not user_id:
|
|
raise MafNotFoundError("run", run_id)
|
|
|
|
result = await self._store.cancel_run_atomically(run_id=run_id, owner_user_id=user_id)
|
|
if result is not None:
|
|
return result
|
|
|
|
# Classify the miss: missing vs wrong-owner.
|
|
run = await self._store.runs.get_run(run_id)
|
|
if run is None:
|
|
raise MafNotFoundError("run", run_id)
|
|
if is_admin:
|
|
raise MafForbiddenError("run", run_id)
|
|
raise MafNotFoundError("run", run_id)
|
|
|
|
async def start_cancel(self, *, run_id: str, user_id: str | None, is_admin: bool = False) -> dict[str, Any]:
|
|
"""Phase 1 of two-phase cancel: atomically set run+session to ``cancelling``.
|
|
|
|
The caller (router) then calls ``dispatcher.cancel(run_id)`` to
|
|
cascade to DeerFlow, then calls ``cancel_run`` to finalize to
|
|
``cancelled``.
|
|
"""
|
|
self._require_enabled()
|
|
if not user_id:
|
|
raise MafNotFoundError("run", run_id)
|
|
|
|
result = await self._store.start_cancel_atomically(run_id=run_id, owner_user_id=user_id)
|
|
if result is not None:
|
|
return result
|
|
|
|
# Classify the miss.
|
|
run = await self._store.runs.get_run(run_id)
|
|
if run is None:
|
|
raise MafNotFoundError("run", run_id)
|
|
if is_admin:
|
|
raise MafForbiddenError("run", run_id)
|
|
raise MafNotFoundError("run", run_id)
|
|
|
|
async def finalize_cancel(self, *, run_id: str, user_id: str | None, is_admin: bool = False) -> dict[str, Any]:
|
|
"""Phase 2 of two-phase cancel: finalize ``cancelling`` → ``cancelled``."""
|
|
return await self.cancel_run(run_id=run_id, user_id=user_id, is_admin=is_admin)
|
|
|
|
async def mark_cancel_failed(
|
|
self,
|
|
*,
|
|
run_id: str,
|
|
user_id: str | None,
|
|
is_admin: bool = False,
|
|
reason: str = "",
|
|
) -> dict[str, Any]:
|
|
"""Mark a cancelling run as ``cancel_failed`` (non-terminal).
|
|
|
|
Used when the dispatcher's cancel cascade could not confirm all
|
|
DeerFlow sub-runs were cancelled. The run AND session both enter
|
|
``cancel_failed`` atomically so their statuses never diverge.
|
|
The user can retry cancel later — ``cancel_run_atomically``
|
|
accepts ``cancel_failed`` as a valid input state.
|
|
A failed cancel must NEVER masquerade as ``cancelled``.
|
|
"""
|
|
self._require_enabled()
|
|
if not user_id:
|
|
raise MafNotFoundError("run", run_id)
|
|
|
|
scope = self._write_scope_user(user_id, is_admin)
|
|
run = await self._store.runs.get_run(run_id)
|
|
if run is None:
|
|
raise MafNotFoundError("run", run_id)
|
|
# Owner (or admin) check.
|
|
session = await self._store.sessions.get_session(run["session_id"], user_id=scope)
|
|
if session is None:
|
|
if is_admin and await self._store.sessions.get_session(run["session_id"], user_id=None) is not None:
|
|
raise MafForbiddenError("run", run_id)
|
|
raise MafNotFoundError("run", run_id)
|
|
|
|
safe_reason = str(sanitize(reason))[:500] if reason else "one or more sub-runs could not be cancelled"
|
|
updated = await self._store.mark_cancel_failed_atomically(
|
|
run_id=run_id,
|
|
owner_user_id=user_id,
|
|
error_code="CancelCascadeIncomplete",
|
|
error_message=safe_reason,
|
|
)
|
|
if updated is None:
|
|
raise MafNotFoundError("run", run_id)
|
|
return updated
|
|
|
|
# ── events ─────────────────────────────────────────────────────────────
|
|
|
|
async def append_event(
|
|
self,
|
|
*,
|
|
run_id: str,
|
|
user_id: str | None,
|
|
event_type: str,
|
|
agent_id: str | None = None,
|
|
turn_index: int | None = None,
|
|
payload: dict[str, Any] | None = None,
|
|
source_event_id: str | None = None,
|
|
is_admin: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Append an event to a run's stream (internal / executor only).
|
|
|
|
There is **no** public HTTP write endpoint for events — Phase-1 audit
|
|
integrity requires that only the backend Workflow executor (Phase 2+)
|
|
call this method. Frontend联调 mocks belong in tests / scripts, not in
|
|
a production-reachable route.
|
|
"""
|
|
self._require_enabled()
|
|
# Owner-only write: an admin cannot forge events into another user's run.
|
|
run = await self._store.runs.get_run(run_id)
|
|
if run is None:
|
|
raise MafNotFoundError("run", run_id)
|
|
scope = self._write_scope_user(user_id, is_admin)
|
|
session = await self._store.sessions.get_session(run["session_id"], user_id=scope)
|
|
if session is None:
|
|
if is_admin and await self._store.sessions.get_session(run["session_id"], user_id=None) is not None:
|
|
raise MafForbiddenError("run", run_id)
|
|
raise MafNotFoundError("run", run_id)
|
|
return await self._store.events.append_event(
|
|
session_id=run["session_id"],
|
|
run_id=run_id,
|
|
event_type=event_type,
|
|
agent_id=agent_id,
|
|
turn_index=turn_index,
|
|
payload=sanitize_payload(payload),
|
|
source_event_id=source_event_id,
|
|
)
|
|
|
|
async def list_events(
|
|
self, *, run_id: str, user_id: str | None, after_seq: int = 0, limit: int = 1000, is_admin: bool = False
|
|
) -> tuple[list[dict[str, Any]], int]:
|
|
# get_run enforces owner read-scope (raises MafNotFoundError if out of scope).
|
|
await self.get_run(run_id=run_id, user_id=user_id, is_admin=is_admin)
|
|
events = await self._store.events.list_events(run_id, after_seq=after_seq, limit=limit)
|
|
latest = await self._store.events.latest_seq(run_id)
|
|
return events, latest
|
|
|
|
# ── actions ────────────────────────────────────────────────────────────
|
|
|
|
async def list_actions(
|
|
self, *, run_id: str, user_id: str | None, is_admin: bool = False
|
|
) -> list[dict[str, Any]]:
|
|
from deerflow.persistence.maf_roundtable.actions import action_to_public_dict
|
|
|
|
run = await self.get_run(run_id=run_id, user_id=user_id, is_admin=is_admin)
|
|
return [
|
|
action_to_public_dict(action)
|
|
for action in await self._store.actions.list_actions(run_id=run["id"])
|
|
]
|
|
|
|
async def get_action(self, *, action_id: str, user_id: str | None, is_admin: bool = False) -> dict[str, Any]:
|
|
from deerflow.persistence.maf_roundtable.actions import action_to_public_dict
|
|
|
|
action = await self._store.actions.get_action(action_id)
|
|
if action is None:
|
|
raise MafNotFoundError("action", action_id)
|
|
scope = self._read_scope_user(user_id, is_admin)
|
|
session = await self._store.sessions.get_session(action["session_id"], user_id=scope)
|
|
if session is None:
|
|
raise MafNotFoundError("action", action_id)
|
|
return action_to_public_dict(action)
|
|
|
|
# ── approvals ──────────────────────────────────────────────────────────
|
|
|
|
async def list_approvals(
|
|
self, *, run_id: str, user_id: str | None, is_admin: bool = False
|
|
) -> list[dict[str, Any]]:
|
|
run = await self.get_run(run_id=run_id, user_id=user_id, is_admin=is_admin)
|
|
# Reads are observational. Expiry is performed by the dedicated
|
|
# scanner; decision/resume paths retain the fail-closed pre-CAS check.
|
|
return await self._store.approvals.list_approvals(run_id=run["id"])
|
|
|
|
async def decide_approval(
|
|
self,
|
|
*,
|
|
approval_id: str,
|
|
decision: str,
|
|
user_id: str | None,
|
|
is_admin: bool = False,
|
|
) -> dict[str, Any]:
|
|
self._require_enabled()
|
|
approval = await self._store.approvals.get_approval(approval_id)
|
|
if approval is None:
|
|
raise MafNotFoundError("approval", approval_id)
|
|
# Owner-only write: an admin cannot decide another user's approval.
|
|
scope = self._write_scope_user(user_id, is_admin)
|
|
session = await self._store.sessions.get_session(approval["session_id"], user_id=scope)
|
|
if session is None:
|
|
if is_admin and await self._store.sessions.get_session(approval["session_id"], user_id=None) is not None:
|
|
raise MafForbiddenError("approval", approval_id)
|
|
raise MafNotFoundError("approval", approval_id)
|
|
# The timeout transition is server-owned and happens before the
|
|
# decision CAS, so an expired approval can never be revived by a late
|
|
# browser request.
|
|
if approval.get("run_id"):
|
|
await self._store.expire_due_approvals(run_id=approval["run_id"])
|
|
try:
|
|
updated = await self._store.approvals.decide(approval_id, decision=decision, decided_by=user_id)
|
|
except ValueError as exc:
|
|
raise MafConflictError(str(exc)) from exc
|
|
if updated is None:
|
|
raise MafNotFoundError("approval", approval_id)
|
|
return updated
|