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>
This commit is contained in:
董宏凯andCursor committed 2026-08-01 01:07:05 +08:00
1 parent a0a75a9964
commit e690475e6e
27 files changed
+5369 -101

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -301,6 +301,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **Dashboard Sessions** (`/api/dashboard-sessions`) | 「大屏绘制智能体」独立页面会话,**按外部 `task_id` 存取,不按 user 分权**(open read + shared writes;`user_id` audit-only)。一个 task 一行(upsert):`transcript`(聊天记录 JSON)+ `report_json`(最新结构化数据,可解析 = 「有效结果」)。`GET /by-task/{task_id}` - 取该 task 会话(无则 404);`PUT /by-task/{task_id}` - upsert(`{transcript?, report_json?}`,只覆盖传入项)。Backed by `deerflow.persistence.dashboard_sessions`(table `dashboard_sessions`, auto-created by `create_all`,无迁移);store wired in `deps.py` as `app.state.dashboard_session_store`。前端独立页 `roundtable-planning/pages/DashboardAgentPage.tsx`(路由 `/page/strategy/dashboard-agent?taskId=`,侧边栏「问答管理 → 大屏绘制」)让智能体分析任务报告 JSON → report-json → 渲染 `DashboardApp`。Tests: `tests/test_dashboard_sessions.py`。 |
| **Task Reports** (`/api/task-reports`) | 任务级研判情报报告详情,**按 `task_id` 共享、不按 user 分权**(写入者只留 `updated_by` 审计)。`GET /by-task/{task_id}` 始终返回原 Consumer 兼容外壳 `{state:"200", msg:"操作成功!", data:[...]}`(未保存为 `data: []`);`POST /by-task/{task_id}` 首次保存,已有报告返回 409;`PUT /by-task/{task_id}` 原子替换完整 `data` 数组。`POST /import/by-task/{task_id}` 面向 TaskCOP 导入页,接收 `sq-report-mock.json` 兼容的 `data`/`records` 数组并完整覆盖目标任务;路径中的选定任务 id 优先于文件内的 `taskId`,避免误写。每一项严格是 `{id, taskId, createTime, updateTime, sessionId, contentJson, categoryType}`;`contentJson` 以 JSON **字符串**原样落在 `records_json`,避免破坏大屏按类目解析的输入格式。保存或导入非空 `data` 后,router 会通过 `app.state.taskcop_task_store.mark_analysis_completed` 将同 id 的 TaskCOP 任务升级至状态 `25`,使旧任务列表显示并筛选为“已完成”;普通保存时不存在匹配任务的报告仍可独立保存,导入则要求目标任务存在。Backed by `deerflow.persistence.task_reports`(table `task_reports`, auto-created by `create_all`);store wired in `deps.py` as `app.state.task_report_store`。前端客户端 `strategy-components/api/task-reports.ts`,`core/auth/dashboard-input.ts` 与 rwfx 3q 完成检查均直接读此接口,不再依赖 Consumer 或本地 sq-report mock。Tests: `tests/test_task_reports.py`。 |
| **Task Buttons** (`/api/task-buttons`) | Task-workspace configurable jump buttons (按钮管理), **global/shared** (no user scoping; `updated_by` audit-only). Each row = one jump button in a task deep-link workspace's bottom 快捷跳转 bar: `{id, business (rwfx/3qfx/xdfx), label, link_type (url/business), target, append_task_id, id_param (editable query-param name, e.g. id/task_id), id_kind (task/action — only xdfx differs, its deep-link id is an 行动id), open_mode (blank/self), login_params, append_auth, auth_token_param, auth_name_param, enabled, sort_order}`. **Two independent, coexisting login-append mechanisms for `url` buttons** — both resolved frontend-side: (1) `login_params` (JSON column `login_params_json`, `PortableJSON`, migration `20260625_01`) = a list of `{key, source, custom_value}` resolved at click time (`source` = a `userInfo` field or `other`→literal `custom_value`) and appended to the target URL — mirrors the light-app login params; empty = nothing appended. (2) `append_auth` + `auth_token_param` + `auth_name_param` (columns, migration `20260625_02`, default off) — appends `?{auth_token_param}={token1}&{auth_name_param}={username}` so the target system can identify the current user; gated on **token login** — the frontend only stashes `token1` (the `?authToken=`) + the raw `getTokenInfo` username (`login.token1`/`login.username`) when login went through `/login/token`, so a non-token-login session appends neither. The **frontend** holds code-seed defaults (rwfx's 4 historical buttons built from the live `task_deeplink` config); only when this table is empty do the workspace + admin page fall back to them, else this table is the sole source of truth. `GET /` - list all (open to any authenticated user — the workspace renders them); `PUT /` - **admin only** (`_require_admin`), atomically **replaces the whole set** (`{buttons: [...]}`, de-duped by `id`, last wins). Backed by `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`); store wired in `deps.py` as `app.state.task_button_store`. Frontend: helpers `core/auth/task-buttons.ts`, client `strategy-components/api/task-buttons.ts`, admin page `pages/TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, entry「按钮管理」in the bottom-left settings dropdown, admin-only, both sidebar modes; per-business tabs + 「导入默认」). Tests: `tests/test_task_buttons.py`. |
| **MAF Roundtable (实验)** (`/api/maf-roundtable`) | Microsoft Agent Framework 多智能体会商实验 — an **independent, feature-flag-gated** sibling of the legacy multi-agent roundtable (`/api/roundtable-*` / `/api/multi-agent/*`). Uses MAF's Agent / Workflow / Group Chat orchestration while reusing the existing DeerFlow Agent Runtime for each seat's actual execution (tools / skills / MCP / sandbox / external writes). **Phase 1 (shipped)** = persistence skeleton + API contracts: six isolated tables (`maf_roundtable_sessions` / `_participants` / `_runs` / `_events` / `_actions` / `_approvals`) with **no FK to the legacy `roundtable_*` tables** (full isolation — see `tests/test_maf_roundtable_isolation.py::test_maf_tables_isolated_from_legacy_roundtable`); session CRUD + participants + frozen `capability_snapshot`; run lifecycle (create / get / list / cancel); **monotonic per-run event seq** (`(run_id, seq)` unique, `SELECT MAX(seq)+1` with `IntegrityError` retry so concurrent appends from parallel seats produce a dense 1..N seq set — `tests/test_maf_roundtable_api.py::test_concurrent_appends_produce_dense_seq_set`); event 断线补拉 (`GET /runs/{id}/events?after_seq=N`); action idempotency-key uniqueness; approval decision transition + "already decided" guard. **P1/P2 hardening (shipped)**: (1) **atomic single-transaction run launch** — `store.claim_and_create_pending_run` opens one `AsyncSession` + `begin()`: conditional `UPDATE sessions SET status='running' WHERE id=:id AND user_id=:owner AND status NOT IN (active…)` then INSERT a `pending` run (`started_at=None` — pending = queued, not yet handed to the executor); `rowcount!=1` → no run, caller maps to 404/403/409; any flush/commit failure rolls the whole txn back (no residual `starting`, no orphan run). **Atomic cancel** — `store.cancel_run_atomically` SELECTs run JOIN session `with_for_update` in one txn: active run → run+session `cancelled` (+`finished_at`); already-`cancelled` run → repairs a still-active session (fixes historical half-commits); `completed`/`failed` run → run untouched, session repaired only if still active. Concurrent callers are serialised by the DB (not a Python lock). Until a real MAF executor exists the run stays `pending`. (2) **read/write permission split** — `admin_diagnostic_access` widens only the *read* scope (`_read_scope_user`); every *write* is owner-checked via `_write_scope_user` regardless of admin → `403 MafForbiddenError`. (3) **atomic approval decision** — conditional `UPDATE … WHERE status='pending'`; `rowcount==1` wins, already-decided → 409. (4) **config-driven create defaults + shared enums** — `MafMode` / `MafWritePolicy` / `MafParticipantRole` live in `maf_roundtable_config.py` (single source); API schemas reuse them; `default_max_rounds` has `ge=1, le=64`; omitted create fields fall back to config. (5) **no public mock event write** — `POST /runs/{id}/events` removed; events are written only via internal `service.append_event` (future Workflow executor). (6) **PUT rejects explicit null** on non-nullable fields (`title`/`task_text`/`mode`/`max_rounds`/`write_policy`/`participants` → 422); clearable fields (`task_payload`/`model_name`/…) keep null = clear. **User isolation (MAF-01-07)**: every read/write is owner-scoped by `sessions.user_id`; a non-owner gets `404` (not `403`, so existence is not leaked); admins bypass the owner filter when `maf_roundtable.admin_diagnostic_access` is on. **Feature flag (MAF-01-04)**: `config.yaml → maf_roundtable.enabled` (default **false**, deployment-level); when off, write paths return `503 Service Disabled` and the **MAF SDK is never imported** (lazy import inside `app.maf_roundtable` only when a run is actually started — Phase 2+), reads stay open for admin history review. **Run gating**: a session in `running` / `waiting_*` rejects config edits; `capability_snapshot` is frozen at creation and never mutated on update. **Lifespan (MAF-01-03)**: store always wired (`app.state.maf_roundtable_store`), service only constructed when enabled (`app.state.maf_roundtable_service`); init failures are logged but never abort Gateway startup (experimental / opt-in). Boundary: `deerflow.persistence.maf_roundtable` (harness) holds only pure data models + repos (no `app.*` / no MAF SDK); `app.maf_roundtable` (app) is the **only** place the MAF SDK is imported. Config: `deerflow.config.maf_roundtable_config.MafRoundtableConfig` (`enabled` / `default_mode: MafMode` / `default_write_policy: MafWritePolicy` / `default_max_rounds` ge=1 le=64 / `admin_diagnostic_access`). Router `app/gateway/routers/maf_roundtable.py`; service `app/maf_roundtable/service.py`; schemas `app/maf_roundtable/schemas.py`; persistence `packages/harness/deerflow/persistence/maf_roundtable/` (`model.py` = 6 ORM rows, `sessions.py` / `participants.py` / `runs.py` / `events.py` / `actions.py` / `approvals.py` = repos, `store.py` = facade + `claim_and_create_pending_runLine truncated
| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Defaults to `background=true`**: the slow per-thread extraction loop is deferred to a FastAPI background task and the endpoint returns `{queued:true, processed:N}` immediately (each thread's transcript is truncated to `max_input_chars` before the LLM call). Running the loop inline for a large batch outlives the nginx gateway timeout (504), which the frontend's `parseResponse` surfaces as a generic "Request failed"; `background=false` keeps the blocking path (full per-thread `created/skipped/failed` tally) for small batches/scripts. Shared loop: `_run_batch_ingest`. **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` ? `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "???????". The "??????" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector?keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[?]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). `GET/POST /folders` + `PUT/DELETE /folders/{id}` - user-managed directories (notes carry a `folder` path; `GET /notes?folder=` filters a dir + sub-dirs); `GET/POST /extract-templates` + `GET/PUT/DELETE /extract-templates/{id}` - editable extraction-prompt templates (thread/document ingest accept `template_id`; capture/PUT-note accept `folder`). Backed by the `deerflow.knowledge` module (see below). |
Proxied through nginx: `/api/langgraph/*` ? LangGraph, all other `/api/*` ? Gateway.
@@ -45,6 +45,7 @@ from app.gateway.routers import (
knowledge_ingest,
light_apps,
llm_metrics,
maf_roundtable,
mcp,
memory,
menu_overrides,
@@ -53,11 +54,11 @@ from app.gateway.routers import (
notifications,
open_3qfx,
open_chat,
parallel_agent_tasks,
parallel_agents,
position_roundtable,
positions,
position_roles,
parallel_agent_tasks,
parallel_agents,
position_roundtable,
positions,
position_roles,
public_config,
public_embed,
public_leaderboard,
@@ -858,9 +859,9 @@ async def _concurrency_sampler_loop(app: FastAPI) -> None:
logger.info("Concurrency sampler disabled by CONCURRENCY_SAMPLE_ENABLED")
return
store = getattr(app.state, "concurrency_sample_store", None)
run_mgr = getattr(app.state, "run_manager", None)
concurrency_gate = getattr(app.state, "concurrency_gate", None)
store = getattr(app.state, "concurrency_sample_store", None)
run_mgr = getattr(app.state, "run_manager", None)
concurrency_gate = getattr(app.state, "concurrency_gate", None)
if store is None or run_mgr is None:
logger.info("Concurrency sampler not started (no store / run manager — memory backend?)")
return
@@ -881,16 +882,16 @@ async def _concurrency_sampler_loop(app: FastAPI) -> None:
try:
# Runtime toggle (admin can turn sampling off without a restart).
# mtime-cached read, so this is cheap every tick.
from deerflow.config.system_settings import get_concurrency_monitor_settings
if get_concurrency_monitor_settings().enabled:
active_count = None
if concurrency_gate is not None:
active_count = await concurrency_gate.count_active()
if active_count is None:
active = await run_mgr.list_active()
active_count = len(active)
await store.record(active_count)
from deerflow.config.system_settings import get_concurrency_monitor_settings
if get_concurrency_monitor_settings().enabled:
active_count = None
if concurrency_gate is not None:
active_count = await concurrency_gate.count_active()
if active_count is None:
active = await run_mgr.list_active()
active_count = len(active)
await store.record(active_count)
ticks += 1
if ticks % purge_every_ticks == 0:
from datetime import UTC, datetime, timedelta
@@ -1338,18 +1339,18 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
v = os.environ.get("GATEWAY_CORS_ALLOW_ALL", "").strip().lower()
return v in ("1", "true", "yes", "on") or is_auth_disabled()
# The legacy TaskCOP Vue application uses Vite's default development port.
# Keep these origins available even when deployments configure additional
# origins through GATEWAY_CORS_ORIGINS.
taskcop_vite_origins = [
"http://localhost:8080",
"http://127.0.0.1:8080",
]
cors_origins_env = os.environ.get("GATEWAY_CORS_ORIGINS", "").strip()
cors_origins: list[str] = [o.strip() for o in cors_origins_env.split(",") if o.strip()] if cors_origins_env else []
if cors_origins:
cors_origins = list(dict.fromkeys([*cors_origins, *taskcop_vite_origins]))
# The legacy TaskCOP Vue application uses Vite's default development port.
# Keep these origins available even when deployments configure additional
# origins through GATEWAY_CORS_ORIGINS.
taskcop_vite_origins = [
"http://localhost:8080",
"http://127.0.0.1:8080",
]
cors_origins_env = os.environ.get("GATEWAY_CORS_ORIGINS", "").strip()
cors_origins: list[str] = [o.strip() for o in cors_origins_env.split(",") if o.strip()] if cors_origins_env else []
if cors_origins:
cors_origins = list(dict.fromkeys([*cors_origins, *taskcop_vite_origins]))
# Headers the LangGraph SDK / browser clients need to read from cross-origin
# responses. Without exposing them, the SDK's `response.headers.get(...)` returns
@@ -1408,10 +1409,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
"http://127.0.0.1:5173",
"http://localhost:5174",
"http://127.0.0.1:5174",
"http://localhost:3000",
"http://127.0.0.1:3000",
*taskcop_vite_origins,
]
"http://localhost:3000",
"http://127.0.0.1:3000",
*taskcop_vite_origins,
]
logger.warning(
"No CORS configuration found; enabling local dev origins only: %s. "
"Set GATEWAY_CORS_ORIGINS or GATEWAY_CORS_ALLOW_ALL for deployment.",
@@ -1471,25 +1472,25 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Step3 流程图结构化抽取 + 校验 (SSE) at /api/flow-extract
app.include_router(flow_extract.router)
# Roundtable-planning business chains at /api/roundtable-chains
app.include_router(roundtable_chains.router)
# 岗位协同独立会话 / 节点状态(不使用原圆桌总控或后台作业)
# Canonical position-workspace persistence API.
app.include_router(position_roundtable.router, prefix="/api")
# Compatibility route for intranet gateways whose allowlist only exposes
# the established `/api/multi-agent/*` namespace.
app.include_router(
position_roundtable.router,
prefix="/api/multi-agent",
include_in_schema=False,
)
# Roundtable-planning business chains at /api/roundtable-chains
app.include_router(roundtable_chains.router)
# 岗位协同独立会话 / 节点状态(不使用原圆桌总控或后台作业)
# Canonical position-workspace persistence API.
app.include_router(position_roundtable.router, prefix="/api")
# Compatibility route for intranet gateways whose allowlist only exposes
# the established `/api/multi-agent/*` namespace.
app.include_router(
position_roundtable.router,
prefix="/api/multi-agent",
include_in_schema=False,
)
# Global business ? agent / chain mapping at /api/business-mapping
app.include_router(business_mapping.router)
# Position-roundtable work-view role configuration at /api/position-roles.
app.include_router(position_roles.router)
app.include_router(business_mapping.router)
# Position-roundtable work-view role configuration at /api/position-roles.
app.include_router(position_roles.router)
# Light-app registry (?????) at /api/light-apps
app.include_router(light_apps.router)
@@ -1509,6 +1510,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Task-workspace configurable jump buttons (按钮管理) at /api/task-buttons
app.include_router(task_buttons.router)
# Microsoft Agent Framework 多智能体会商实验 (independent of the legacy
# roundtable; feature-flag-gated, six isolated tables) at /api/maf-roundtable
app.include_router(maf_roundtable.router)
# Temporary compatibility bridge for the TaskCOP situation-overview frontend.
app.include_router(taskcop_tasks.router)
@@ -255,23 +255,23 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.roundtable_draft_share_store = make_roundtable_draft_share_store(sf)
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
# 独立的岗位协同 Session / Node 存储。它只索引业务流程状态,真实消息和
# 文件仍复用 LangGraph thread/checkpoint 与现有 artifact API。
from deerflow.persistence.position_roundtable import make_position_roundtable_store
app.state.position_roundtable_store = make_position_roundtable_store(sf)
# 岗位会商工作视角(名称 / 顺序 / 特殊意图岗)是全局配置,和组织/RBAC
# 岗位分开保存。启动只补齐缺失内置项,不覆盖管理员的既有修改。
from app.gateway.position_role_defaults import POSITION_ROLE_SEED
from deerflow.persistence.position_roles import make_position_role_store
app.state.position_role_store = make_position_role_store(sf)
await app.state.position_role_store.ensure_seed(POSITION_ROLE_SEED)
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
# 独立的岗位协同 Session / Node 存储。它只索引业务流程状态,真实消息和
# 文件仍复用 LangGraph thread/checkpoint 与现有 artifact API。
from deerflow.persistence.position_roundtable import make_position_roundtable_store
app.state.position_roundtable_store = make_position_roundtable_store(sf)
# 岗位会商工作视角(名称 / 顺序 / 特殊意图岗)是全局配置,和组织/RBAC
# 岗位分开保存。启动只补齐缺失内置项,不覆盖管理员的既有修改。
from app.gateway.position_role_defaults import POSITION_ROLE_SEED
from deerflow.persistence.position_roles import make_position_role_store
app.state.position_role_store = make_position_role_store(sf)
await app.state.position_role_store.ensure_seed(POSITION_ROLE_SEED)
from deerflow.persistence.business_mapping import make_business_mapping_store
@@ -295,20 +295,52 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
from deerflow.persistence.task_buttons import make_task_button_store
app.state.task_button_store = make_task_button_store(sf)
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
app.state.taskcop_task_store = make_taskcop_task_store(sf)
from deerflow.persistence.task_reports import make_task_report_store
app.state.task_report_store = make_task_report_store(sf)
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
app.state.task_button_store = make_task_button_store(sf)
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
app.state.taskcop_task_store = make_taskcop_task_store(sf)
from deerflow.persistence.task_reports import make_task_report_store
app.state.task_report_store = make_task_report_store(sf)
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf)
# ── MAF roundtable experiment (Microsoft Agent Framework 多智能体会商) ──
# Independent, feature-flag-gated sibling of the legacy roundtable. Six
# isolated tables (no FK to roundtable_*). The store is always wired
# (so reads work for admin history review) but the **service** is only
# constructed when ``maf_roundtable.enabled`` is on — that is the gate
# the router checks; when off, write paths return 503 and the MAF SDK
# is never imported. Initialization failures are logged but never
# abort Gateway startup (the feature is experimental / opt-in).
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
app.state.maf_roundtable_store = make_maf_roundtable_store(sf)
try:
from app.maf_roundtable import MafRoundtableService
maf_cfg = getattr(config, "maf_roundtable", None)
maf_enabled = bool(getattr(maf_cfg, "enabled", False)) if maf_cfg is not None else False
maf_admin_diag = (
bool(getattr(maf_cfg, "admin_diagnostic_access", True)) if maf_cfg is not None else True
)
if app.state.maf_roundtable_store is not None:
app.state.maf_roundtable_service = MafRoundtableService(
app.state.maf_roundtable_store,
enabled=maf_enabled,
admin_diagnostic_access=maf_admin_diag,
config=maf_cfg,
)
else:
app.state.maf_roundtable_service = None
except Exception: # noqa: BLE001 — experimental feature must never abort startup
logger.exception("[maf-roundtable] service initialization failed; feature stays off")
app.state.maf_roundtable_service = None
from deerflow.persistence.roundtable_jobs import (
make_roundtable_job_store,
)
@@ -389,11 +421,11 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
run_events_config = getattr(config, "run_events", None)
app.state.run_event_store = make_run_event_store(run_events_config)
# RunManager with store backing for persistence
app.state.run_manager = RunManager(store=app.state.run_store)
from deerflow.runtime import make_redis_concurrency_gate
app.state.concurrency_gate = make_redis_concurrency_gate(config)
# RunManager with store backing for persistence
app.state.run_manager = RunManager(store=app.state.run_store)
from deerflow.runtime import make_redis_concurrency_gate
app.state.concurrency_gate = make_redis_concurrency_gate(config)
try:
yield
@@ -407,10 +439,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
await queue.stop()
set_auto_ingest_queue(None)
await close_engine()
gate = getattr(app.state, "concurrency_gate", None)
if gate is not None:
await gate.aclose()
await close_engine()
gate = getattr(app.state, "concurrency_gate", None)
if gate is not None:
await gate.aclose()
# ---------------------------------------------------------------------------
@@ -436,12 +468,12 @@ get_run_manager: Callable[[Request], RunManager] = _require("run_manager", "Run
get_checkpointer: Callable[[Request], Checkpointer] = _require("checkpointer", "Checkpointer")
get_run_event_store: Callable[[Request], RunEventStore] = _require("run_event_store", "Run event store")
get_feedback_repo: Callable[[Request], FeedbackRepository] = _require("feedback_repo", "Feedback")
get_run_store: Callable[[Request], RunStore] = _require("run_store", "Run store")
def get_concurrency_gate(request: Request):
"""Return the optional cross-worker concurrency gate."""
return getattr(request.app.state, "concurrency_gate", None)
get_run_store: Callable[[Request], RunStore] = _require("run_store", "Run store")
def get_concurrency_gate(request: Request):
"""Return the optional cross-worker concurrency gate."""
return getattr(request.app.state, "concurrency_gate", None)
def get_store(request: Request):
@@ -0,0 +1,313 @@
"""HTTP router for the MAF roundtable experiment at ``/api/maf-roundtable``.
Phase 1 ships:
- ``GET /health`` — feature-flag probe (open read; the frontend reads
``enabled`` to decide whether to show the entry).
- Session CRUD: ``POST /sessions``, ``GET /sessions``, ``GET /sessions/{id}``,
``PUT /sessions/{id}``, ``DELETE /sessions/{id}``.
- Run lifecycle: ``POST /sessions/{id}/runs``, ``GET /runs/{id}``,
``GET /sessions/{id}/runs``, ``POST /runs/{id}/cancel``.
- Event stream: ``GET /runs/{id}/events?after_seq=N`` (断线补拉) — **read-only**;
there is no public ``POST /runs/{id}/events``. Real workflow events are
written only by the backend executor via ``service.append_event``.
- Actions: ``GET /runs/{id}/actions``, ``GET /actions/{id}``.
- Approvals: ``GET /runs/{id}/approvals``, ``POST /approvals/{id}/decision``.
User isolation (MAF-01-07): every read / write is scoped to the caller's
``user_id``; admins may *read* cross-user when ``admin_diagnostic_access`` is
on, but **writes** remain owner-only (admin write of another user's resource
→ 403). A non-admin user cannot read or mutate another user's session / run /
action / approval — the store returns ``None`` for out-of-scope rows and the
router surfaces that as ``404`` (not ``403``, so the existence of another
user's session is not leaked).
Feature flag (MAF-01-04): when ``maf_roundtable.enabled`` is off, write paths
return ``503 Service Disabled``; reads stay open so admins can review history.
The MAF SDK is **not** imported by this router — Phase 2+ imports it lazily
inside ``app.maf_roundtable.service`` only when a run is actually started.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request
from app.maf_roundtable import (
ActionListResponse,
ActionResponse,
ApprovalDecisionRequest,
ApprovalListResponse,
ApprovalResponse,
CreateRunRequest,
CreateSessionRequest,
EventListResponse,
EventResponse,
HealthResponse,
MafConflictError,
MafDisabledError,
MafForbiddenError,
MafNotFoundError,
RunListResponse,
RunResponse,
SessionListResponse,
SessionResponse,
UpdateSessionRequest,
)
router = APIRouter(prefix="/api/maf-roundtable", tags=["maf-roundtable"])
# ── helpers ───────────────────────────────────────────────────────────────
def _get_service(request: Request):
"""Resolve the MAF service from app.state. Returns None when the feature
is fully off (no store wired) — the router then 503s on writes.
"""
return getattr(request.app.state, "maf_roundtable_service", None)
def _user_ctx(request: Request) -> tuple[str | None, bool]:
"""Return ``(user_id, is_admin)`` for the current request.
When auth is disabled (no user stamped on request.state), the caller is
treated as the ``"default"`` user (same convention as
``get_effective_user_id``) and non-admin — so a no-auth deployment stays
owner-scoped to ``"default"`` rather than accidentally bypassing the
owner filter.
"""
user = getattr(request.state, "user", None)
if user is None:
return "default", False
return str(user.id), getattr(user, "system_role", None) == "admin"
def _map_service_error(exc: Exception) -> HTTPException:
if isinstance(exc, MafDisabledError):
return HTTPException(status_code=503, detail="MAF roundtable feature is disabled")
if isinstance(exc, MafForbiddenError):
return HTTPException(status_code=403, detail=f"not the owner of {exc.what}")
if isinstance(exc, MafNotFoundError):
return HTTPException(status_code=404, detail=f"{exc.what} not found")
if isinstance(exc, MafConflictError):
return HTTPException(status_code=409, detail=str(exc))
return HTTPException(status_code=500, detail=str(exc))
def _require_service(request: Request):
svc = _get_service(request)
if svc is None:
raise HTTPException(status_code=503, detail="MAF roundtable store not available")
return svc
# ── health ─────────────────────────────────────────────────────────────────
@router.get("/health", response_model=HealthResponse)
async def get_health(request: Request) -> HealthResponse:
"""Feature-flag probe. Open read — the frontend reads ``enabled`` to
decide whether to show the MAF entry. Does not require the store.
"""
from deerflow.config import get_app_config
cfg = get_app_config().maf_roundtable
svc = _get_service(request)
store_available = svc is not None and svc.store is not None
return HealthResponse(
enabled=cfg.enabled,
store_available=store_available,
default_mode=cfg.default_mode,
default_write_policy=cfg.default_write_policy,
default_max_rounds=cfg.default_max_rounds,
admin_diagnostic_access=cfg.admin_diagnostic_access,
)
# ── sessions ───────────────────────────────────────────────────────────────
@router.post("/sessions", response_model=SessionResponse)
async def create_session(request: Request, body: CreateSessionRequest) -> SessionResponse:
svc = _require_service(request)
user_id, is_admin = _user_ctx(request)
try:
session = await svc.create_session(user_id=user_id, body=body.model_dump(), is_admin=is_admin)
except (MafDisabledError, MafForbiddenError, MafNotFoundError, MafConflictError) as exc:
raise _map_service_error(exc) from exc
return SessionResponse(**session)
@router.get("/sessions", response_model=SessionListResponse)
async def list_sessions(
request: Request,
status: str | None = Query(default=None),
mode: str | None = Query(default=None),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> SessionListResponse:
svc = _require_service(request)
user_id, is_admin = _user_ctx(request)
rows, total = await svc.list_sessions(
user_id=user_id, is_admin=is_admin, status=status, mode=mode, limit=limit, offset=offset
)
return SessionListResponse(sessions=[SessionResponse(**r) for r in rows], total=total)
@router.get("/sessions/{session_id}", response_model=SessionResponse)
async def get_session(request: Request, session_id: str) -> SessionResponse:
svc = _require_service(request)
user_id, is_admin = _user_ctx(request)
try:
session = await svc.get_session(session_id=session_id, user_id=user_id, is_admin=is_admin)
except MafNotFoundError as exc:
raise _map_service_error(exc) from exc
return SessionResponse(**session)
@router.put("/sessions/{session_id}", response_model=SessionResponse)
async def update_session(request: Request, session_id: str, body: UpdateSessionRequest) -> SessionResponse:
svc = _require_service(request)
user_id, is_admin = _user_ctx(request)
try:
session = await svc.update_session(
session_id=session_id, user_id=user_id, fields=body.model_dump(exclude_unset=True), is_admin=is_admin
)
except (MafDisabledError, MafForbiddenError, MafNotFoundError, MafConflictError) as exc:
raise _map_service_error(exc) from exc
return SessionResponse(**session)
@router.delete("/sessions/{session_id}")
async def delete_session(request: Request, session_id: str) -> dict[str, Any]:
svc = _require_service(request)
user_id, is_admin = _user_ctx(request)
try:
await svc.delete_session(session_id=session_id, user_id=user_id, is_admin=is_admin)
except (MafDisabledError, MafForbiddenError, MafNotFoundError) as exc:
raise _map_service_error(exc) from exc
return {"deleted": True, "id": session_id}
# ── runs ───────────────────────────────────────────────────────────────────
@router.post("/sessions/{session_id}/runs", response_model=RunResponse)
async def create_run(request: Request, session_id: str, body: CreateRunRequest) -> RunResponse:
svc = _require_service(request)
user_id, is_admin = _user_ctx(request)
try:
run = await svc.create_run(
session_id=session_id, user_id=user_id, workflow_version=body.workflow_version, is_admin=is_admin
)
except (MafDisabledError, MafForbiddenError, MafNotFoundError, MafConflictError) as exc:
raise _map_service_error(exc) from exc
return RunResponse(**run)
@router.get("/sessions/{session_id}/runs", response_model=RunListResponse)
async def list_runs(request: Request, session_id: str) -> RunListResponse:
svc = _require_service(request)
user_id, is_admin = _user_ctx(request)
try:
runs = await svc.list_runs(session_id=session_id, user_id=user_id, is_admin=is_admin)
except MafNotFoundError as exc:
raise _map_service_error(exc) from exc
return RunListResponse(runs=[RunResponse(**r) for r in runs])
@router.get("/runs/{run_id}", response_model=RunResponse)
async def get_run(request: Request, run_id: str) -> RunResponse:
svc = _require_service(request)
user_id, is_admin = _user_ctx(request)
try:
run = await svc.get_run(run_id=run_id, user_id=user_id, is_admin=is_admin)
except MafNotFoundError as exc:
raise _map_service_error(exc) from exc
return RunResponse(**run)
@router.post("/runs/{run_id}/cancel", response_model=RunResponse)
async def cancel_run(request: Request, run_id: str) -> RunResponse:
svc = _require_service(request)
user_id, is_admin = _user_ctx(request)
try:
run = await svc.cancel_run(run_id=run_id, user_id=user_id, is_admin=is_admin)
except (MafDisabledError, MafForbiddenError, MafNotFoundError) as exc:
raise _map_service_error(exc) from exc
return RunResponse(**run)
# ── events ─────────────────────────────────────────────────────────────────
@router.get("/runs/{run_id}/events", response_model=EventListResponse)
async def list_events(
request: Request,
run_id: str,
after_seq: int = Query(default=0, ge=0),
limit: int = Query(default=1000, ge=1, le=10000),
) -> EventListResponse:
svc = _require_service(request)
user_id, is_admin = _user_ctx(request)
try:
events, latest = await svc.list_events(
run_id=run_id, user_id=user_id, after_seq=after_seq, limit=limit, is_admin=is_admin
)
except MafNotFoundError as exc:
raise _map_service_error(exc) from exc
return EventListResponse(events=[EventResponse(**e) for e in events], latest_seq=latest)
# ── actions ────────────────────────────────────────────────────────────────
@router.get("/runs/{run_id}/actions", response_model=ActionListResponse)
async def list_actions(request: Request, run_id: str) -> ActionListResponse:
svc = _require_service(request)
user_id, is_admin = _user_ctx(request)
try:
actions = await svc.list_actions(run_id=run_id, user_id=user_id, is_admin=is_admin)
except MafNotFoundError as exc:
raise _map_service_error(exc) from exc
return ActionListResponse(actions=[ActionResponse(**a) for a in actions])
@router.get("/actions/{action_id}", response_model=ActionResponse)
async def get_action(request: Request, action_id: str) -> ActionResponse:
svc = _require_service(request)
user_id, is_admin = _user_ctx(request)
try:
action = await svc.get_action(action_id=action_id, user_id=user_id, is_admin=is_admin)
except MafNotFoundError as exc:
raise _map_service_error(exc) from exc
return ActionResponse(**action)
# ── approvals ──────────────────────────────────────────────────────────────
@router.get("/runs/{run_id}/approvals", response_model=ApprovalListResponse)
async def list_approvals(request: Request, run_id: str) -> ApprovalListResponse:
svc = _require_service(request)
user_id, is_admin = _user_ctx(request)
try:
approvals = await svc.list_approvals(run_id=run_id, user_id=user_id, is_admin=is_admin)
except MafNotFoundError as exc:
raise _map_service_error(exc) from exc
return ApprovalListResponse(approvals=[ApprovalResponse(**a) for a in approvals])
@router.post("/approvals/{approval_id}/decision", response_model=ApprovalResponse)
async def decide_approval(request: Request, approval_id: str, body: ApprovalDecisionRequest) -> ApprovalResponse:
svc = _require_service(request)
user_id, is_admin = _user_ctx(request)
try:
approval = await svc.decide_approval(
approval_id=approval_id, decision=body.decision, user_id=user_id, is_admin=is_admin
)
except (MafDisabledError, MafForbiddenError, MafNotFoundError, MafConflictError) as exc:
raise _map_service_error(exc) from exc
return ApprovalResponse(**approval)
@@ -0,0 +1,69 @@
"""Microsoft Agent Framework (MAF) multi-agent roundtable experiment.
An independent, feature-flag-gated sibling of the legacy multi-agent
roundtable (``/api/roundtable-*`` / ``/api/multi-agent/*``). It uses MAF's
Agent / Workflow / Group Chat orchestration while reusing the existing
DeerFlow Agent Runtime for each seat's actual execution (tools / skills /
MCP / sandbox / external writes). Phase 1 ships the persistence skeleton +
API contracts; the MAF SDK is loaded lazily inside this package only when
the feature flag is on and a run is actually started (Phase 2+).
Boundary: this package is the **only** place the MAF SDK is imported. It may
import ``deerflow.*`` (harness) freely but must not be imported by
``deerflow.*``. The harness-layer persistence (``deerflow.persistence.maf_roundtable``)
holds only pure data models and never imports the MAF SDK.
"""
from __future__ import annotations
from app.maf_roundtable.schemas import ( # noqa: F401 — re-exported for the router
ActionListResponse,
ActionResponse,
ApprovalDecisionRequest,
ApprovalListResponse,
ApprovalResponse,
CreateRunRequest,
CreateSessionRequest,
EventListResponse,
EventResponse,
HealthResponse,
ParticipantInput,
ParticipantResponse,
RunListResponse,
RunResponse,
SessionListResponse,
SessionResponse,
UpdateSessionRequest,
)
from app.maf_roundtable.service import ( # noqa: F401
MafConflictError,
MafDisabledError,
MafForbiddenError,
MafNotFoundError,
MafRoundtableService,
)
__all__ = [
"ActionListResponse",
"ActionResponse",
"ApprovalDecisionRequest",
"ApprovalListResponse",
"ApprovalResponse",
"CreateRunRequest",
"CreateSessionRequest",
"EventListResponse",
"EventResponse",
"HealthResponse",
"MafConflictError",
"MafDisabledError",
"MafForbiddenError",
"MafNotFoundError",
"MafRoundtableService",
"ParticipantInput",
"ParticipantResponse",
"RunListResponse",
"RunResponse",
"SessionListResponse",
"SessionResponse",
"UpdateSessionRequest",
]
@@ -0,0 +1,257 @@
"""Pydantic request/response schemas for the MAF roundtable API.
These are the wire contracts between the frontend (Phase 6) and the backend.
Phase 1 ships the session / participant / run / event contracts; the action /
approval contracts are defined too so Phase 5 can wire the Action Coordinator
without a schema break. Sensitive fields (credentials / tokens / cookies)
must already be redacted by the writer before they reach these schemas — the
backend does not re-redact.
``MafMode`` / ``MafWritePolicy`` / ``MafParticipantRole`` are imported from the
harness config module so API validation and deployment-config validation share
a single source of truth.
"""
from __future__ import annotations
from typing import Any, Self
from pydantic import BaseModel, Field, model_validator
from deerflow.config.maf_roundtable_config import MafMode, MafParticipantRole, MafWritePolicy
# ── sessions ──────────────────────────────────────────────────────────────
class ParticipantInput(BaseModel):
"""One seat / manager agent bound to a session at creation time."""
agent_id: str = Field(..., min_length=1, max_length=64)
display_name: str = Field(default="", max_length=255)
role: MafParticipantRole = "seat"
sort_order: int = 0
# Frozen at creation — the agent's tools / skills snapshot.
capability_snapshot: dict[str, Any] | None = None
class CreateSessionRequest(BaseModel):
title: str = Field(default="", max_length=255)
task_text: str = Field(..., min_length=1)
task_payload: dict[str, Any] | None = None
# Optional: omitted → service layer fills from MafRoundtableConfig.default_mode.
mode: MafMode | None = None
model_name: str | None = Field(default=None, max_length=128)
reasoning_effort: str | None = Field(default=None, max_length=32)
manager_agent_id: str | None = Field(default=None, max_length=64)
# Optional: omitted → MafRoundtableConfig.default_max_rounds.
max_rounds: int | None = Field(default=None, ge=1, le=64)
# Optional: omitted → MafRoundtableConfig.default_write_policy.
write_policy: MafWritePolicy | None = None
participants: list[ParticipantInput] = Field(default_factory=list)
# Frozen at creation — the full session capability snapshot (agents + tools + skills).
capability_snapshot: dict[str, Any] | None = None
# Fields that must not be explicitly set to null on PUT (→ 422).
# Clearable fields (task_payload / model_name / reasoning_effort /
# manager_agent_id / fact_ledger / final_result) keep null = "clear value".
_NON_NULLABLE_UPDATE_FIELDS = frozenset(
{"title", "task_text", "mode", "max_rounds", "write_policy", "participants"}
)
class UpdateSessionRequest(BaseModel):
"""Partial update — only allowed when the session is not yet ``running``.
Distinguishes "field omitted" (no change) from "field set to null":
- Non-nullable fields (title / task_text / mode / max_rounds / write_policy /
participants): explicit ``null`` → 422.
- Clearable fields (task_payload / model_name / reasoning_effort /
manager_agent_id / fact_ledger / final_result): explicit ``null`` clears.
Do **not** use ``exclude_none=True`` — that would collapse the two cases.
"""
title: str | None = Field(default=None, max_length=255)
task_text: str | None = None
task_payload: dict[str, Any] | None = None
mode: MafMode | None = None
model_name: str | None = Field(default=None, max_length=128)
reasoning_effort: str | None = Field(default=None, max_length=32)
manager_agent_id: str | None = Field(default=None, max_length=64)
max_rounds: int | None = Field(default=None, ge=1, le=64)
write_policy: MafWritePolicy | None = None
participants: list[ParticipantInput] | None = None
# fact_ledger / final_result are mutable runtime fields (updated by the run lifecycle).
fact_ledger: dict[str, Any] | None = None
final_result: dict[str, Any] | None = None
@model_validator(mode="after")
def _reject_explicit_null_for_non_nullable(self) -> Self:
for name in _NON_NULLABLE_UPDATE_FIELDS:
if name in self.model_fields_set and getattr(self, name) is None:
raise ValueError(f"{name} cannot be null")
return self
class ParticipantResponse(BaseModel):
id: str
session_id: str
agent_id: str
display_name: str
role: str
sort_order: int
thread_id: str | None = None
sandbox_id: str | None = None
status: str
last_turn_index: int | None = None
capability_snapshot: dict[str, Any] | None = None
created_at: str | None = None
updated_at: str | None = None
class SessionResponse(BaseModel):
id: str
user_id: str
title: str
task_text: str
task_payload: dict[str, Any] | None = None
mode: str
status: str
model_name: str | None = None
reasoning_effort: str | None = None
manager_agent_id: str | None = None
max_rounds: int
write_policy: str
capability_snapshot: dict[str, Any] | None = None
fact_ledger: dict[str, Any] | None = None
final_result: dict[str, Any] | None = None
participants: list[ParticipantResponse] = Field(default_factory=list)
created_at: str | None = None
updated_at: str | None = None
class SessionListResponse(BaseModel):
sessions: list[SessionResponse]
total: int
# ── runs ──────────────────────────────────────────────────────────────────
class RunResponse(BaseModel):
id: str
session_id: str
status: str
workflow_version: str | None = None
maf_checkpoint: dict[str, Any] | None = None
last_event_seq: int
started_at: str | None = None
finished_at: str | None = None
error_code: str | None = None
error_message_safe: str | None = None
token_usage: dict[str, Any] | None = None
latency_ms: int | None = None
created_at: str | None = None
updated_at: str | None = None
class RunListResponse(BaseModel):
runs: list[RunResponse]
class CreateRunRequest(BaseModel):
workflow_version: str | None = Field(default=None, max_length=64)
# ── events ───────────────────────────────────────────────────────────────
class EventResponse(BaseModel):
id: str
session_id: str
run_id: str
seq: int
event_type: str
agent_id: str | None = None
turn_index: int | None = None
payload: dict[str, Any] | None = None
created_at: str | None = None
class EventListResponse(BaseModel):
events: list[EventResponse]
latest_seq: int
# ── actions ───────────────────────────────────────────────────────────────
class ActionResponse(BaseModel):
id: str
session_id: str
run_id: str | None = None
agent_id: str | None = None
tool_name: str
logical_action_id: str | None = None
idempotency_key: str
target_key: str | None = None
risk_level: str
status: str
arguments: dict[str, Any] | None = None
result: dict[str, Any] | None = None
external_object_id: str | None = None
approval_id: str | None = None
attempt_count: int
started_at: str | None = None
finished_at: str | None = None
created_at: str | None = None
updated_at: str | None = None
class ActionListResponse(BaseModel):
actions: list[ActionResponse]
# ── approvals ─────────────────────────────────────────────────────────────
class ApprovalResponse(BaseModel):
id: str
session_id: str
run_id: str | None = None
action_id: str | None = None
requested_by_agent_id: str | None = None
status: str
summary: str
payload: dict[str, Any] | None = None
decided_by: str | None = None
decided_at: str | None = None
expires_at: str | None = None
created_at: str | None = None
updated_at: str | None = None
class ApprovalListResponse(BaseModel):
approvals: list[ApprovalResponse]
class ApprovalDecisionRequest(BaseModel):
# approved | rejected
decision: str = Field(..., pattern="^(approved|rejected)$")
# ── health ────────────────────────────────────────────────────────────────
class HealthResponse(BaseModel):
"""Health / feature-flag probe. The frontend reads ``enabled`` to decide
whether to show the MAF entry. ``store_available`` is false when no DB is
configured (memory mode) — the router then returns 503 on writes.
"""
enabled: bool
store_available: bool
default_mode: str
default_write_policy: str
default_max_rounds: int
admin_diagnostic_access: bool
@@ -0,0 +1,390 @@
"""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
from typing import Any
from deerflow.config.maf_roundtable_config import MafRoundtableConfig
from deerflow.persistence.maf_roundtable.store import MafRoundtableStore
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
# 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".
_RUNNING_STATUSES = {"starting", "running", "waiting_approval", "waiting_input"}
class MafRoundtableService:
def __init__(
self,
store: MafRoundtableStore,
*,
enabled: bool,
admin_diagnostic_access: bool,
config: MafRoundtableConfig | None = None,
) -> None:
self._store = store
self._enabled = enabled
self._admin_diagnostic_access = admin_diagnostic_access
# 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()
@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
# Config-driven defaults: an omitted field falls back to the deployed
# MafRoundtableConfig value; an explicit request value always wins.
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": body.get("capability_snapshot"),
}
session = await self._store.sessions.create_session(session_data)
participants = body.get("participants") or []
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)
# ── 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,
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=payload,
)
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]]:
run = await self.get_run(run_id=run_id, user_id=user_id, is_admin=is_admin)
return 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]:
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
# ── 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)
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)
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
+14 -1
View File
@@ -1,4 +1,4 @@
# Offline backend template for DeerFlow
# Offline backend template for DeerFlow
config_version: 8
log_level: info
@@ -550,3 +550,16 @@ task_deeplink:
taskDirection: "正向引导 + 风险预警"
taskName: "任务01:暑期文旅舆情综合研判"
taskContent: "围绕暑期文旅消费高峰,对重点城市、重点景区的公众情绪与舆情态势做综合研判,识别情绪拐点与潜在风险,提出正向引导与处置建议。"
# ── MAF 多智能体会商实验 (Microsoft Agent Framework) ──────────────────────
# 独立于现有 RoundtablePlanningPage 的实验性功能;默认关闭。关闭时:
# - Gateway 启动不加载 MAF SDK(延迟导入)
# - /api/maf-roundtable/* 写接口返回 503,只读接口可查历史
# - 前端入口隐藏
# 详见 frontend-web/docs/microsoft-agent-framework-多智能体会商-实施计划.md
maf_roundtable:
enabled: false # 部署级特性开关;首期仅管理员/白名单可见
default_mode: structured # structured(固定流程)| group_chat(动态群聊)
default_write_policy: confirm_high_risk # auto | confirm_high_risk | confirm_all_writes
default_max_rounds: 8 # 群聊模式轮次硬上限
admin_diagnostic_access: true # 管理员是否可跨用户读取所有 MAF 会商用于诊断(仅读,不含写)
@@ -11,9 +11,9 @@ from pydantic import BaseModel, ConfigDict, Field
from deerflow.config.acp_config import ACPAgentConfig, load_acp_config_from_dict
from deerflow.config.agents_api_config import AgentsApiConfig, load_agents_api_config_from_dict
from deerflow.config.browser_context_config import BrowserContextConfig
from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict
from deerflow.config.concurrency_config import ConcurrencyConfig
from deerflow.config.database_config import DatabaseConfig
from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict
from deerflow.config.concurrency_config import ConcurrencyConfig
from deerflow.config.database_config import DatabaseConfig
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.config.gateway_config import GatewayConfig
from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict
@@ -21,6 +21,7 @@ from deerflow.config.knowledge_config import KnowledgeConfig
from deerflow.config.knowledge_ingest_config import KnowledgeIngestConfig
from deerflow.config.log_system_config import LogSystemConfig
from deerflow.config.login_config import AuthLoginConfig
from deerflow.config.maf_roundtable_config import MafRoundtableConfig
from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict
from deerflow.config.model_config import ModelConfig
from deerflow.config.run_events_config import RunEventsConfig
@@ -138,13 +139,14 @@ class AppConfig(BaseModel):
gateway: GatewayConfig = Field(default_factory=GatewayConfig, description="Gateway FastAPI listening port (used by intra-process loopback)")
run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration")
checkpointer: CheckpointerConfig | None = Field(default=None, description="Checkpointer configuration")
stream_bridge: StreamBridgeConfig | None = Field(default=None, description="Stream bridge configuration")
concurrency: ConcurrencyConfig = Field(default_factory=ConcurrencyConfig, description="Optional cross-worker concurrency coordination")
browser_context: BrowserContextConfig = Field(default_factory=BrowserContextConfig, description="Optional browser page capture integration")
stream_bridge: StreamBridgeConfig | None = Field(default=None, description="Stream bridge configuration")
concurrency: ConcurrencyConfig = Field(default_factory=ConcurrencyConfig, description="Optional cross-worker concurrency coordination")
browser_context: BrowserContextConfig = Field(default_factory=BrowserContextConfig, description="Optional browser page capture integration")
knowledge: KnowledgeConfig = Field(default_factory=KnowledgeConfig, description="Product knowledge base (obsidian-wiki compatible vault) configuration")
knowledge_ingest: KnowledgeIngestConfig = Field(default_factory=KnowledgeIngestConfig, description="External knowledge-ingest endpoint configuration")
task_deeplink: TaskDeeplinkConfig = Field(default_factory=TaskDeeplinkConfig, description="任务深链(rwfx)目的树闸门与跳转地址配置(前端经 /api/public/config/task-deeplink 读取)")
y_log: YLogConfig = Field(default_factory=YLogConfig, description="操作日志上报配置(前端经 /api/public/config/y-log 读取)")
maf_roundtable: MafRoundtableConfig = Field(default_factory=MafRoundtableConfig, description="Microsoft Agent Framework 多智能体会商实验功能开关(部署级,默认关闭;关闭时 SDK 延迟不加载,写接口返回 503)")
@classmethod
def resolve_config_path(cls, config_path: str | None = None) -> Path:
@@ -0,0 +1,72 @@
"""Microsoft Agent Framework 多智能体会商实验功能开关配置。
本段为**部署级**特性开关:默认关闭。关闭时:
- Gateway 启动**不**加载 MAF SDK(SDK 延迟导入,仅在实际启用并触发会商 run
时才在 ``app.maf_roundtable`` 内 import)。
- ``/api/maf-roundtable/*`` 路由仍注册,但所有写接口与健康检查返回 ``503
Service Disabled``;只读的会话列表/详情可继续工作以便管理员查看历史。
- 前端入口由 ``GET /api/maf-roundtable/health`` 的 ``enabled`` 字段决定是否
显示。
段内只有开关与运行参数,无密钥,可公开只读(仿 ``y_log_config`` 的写法)。
``MafMode`` / ``MafWritePolicy`` 是部署配置与 API schema 共用的单一来源枚举,
避免两处各自维护一套合法值。
"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
# ── shared enums (config + API schemas) ───────────────────────────────────
MafMode = Literal["structured", "group_chat"]
MafWritePolicy = Literal["auto", "confirm_high_risk", "confirm_all_writes"]
MafParticipantRole = Literal["manager", "seat"]
class MafRoundtableConfig(BaseModel):
"""Microsoft Agent Framework 多智能体会商实验功能开关。
默认 ``enabled=False``:这是一个独立于现有 ``RoundtablePlanningPage`` 的
实验性功能,不替换旧会商;首期仅管理员/白名单可见,关闭时完全不影响
Gateway 启动与旧功能行为。
"""
enabled: bool = Field(
default=False,
description="是否启用 MAF 多智能体会商实验功能;关闭时 SDK 延迟不加载,写接口返回 503。",
)
# 默认编排模式(用户可在创建会商时覆盖)。
default_mode: MafMode = Field(
default="structured",
description="默认编排模式:structured(固定流程)| group_chat(动态群聊)",
)
# 默认写入策略(auto / confirm_high_risk / confirm_all_writes)。
default_write_policy: MafWritePolicy = Field(
default="confirm_high_risk",
description="默认外部写入策略:auto / confirm_high_risk / confirm_all_writes",
)
# 默认最大轮次(群聊硬上限)。
default_max_rounds: int = Field(
default=8,
ge=1,
le=64,
description="默认最大轮次上限(群聊模式硬上限,避免弱模型无限讨论)",
)
# 管理员诊断是否可见所有用户的会商(true=管理员跨用户读,false=管理员也只能看自己的)。
admin_diagnostic_access: bool = Field(
default=True,
description="管理员是否可跨用户读取所有 MAF 会商用于诊断(仅读,不含写)",
)
__all__ = [
"MafMode",
"MafParticipantRole",
"MafRoundtableConfig",
"MafWritePolicy",
]
@@ -0,0 +1,72 @@
"""Microsoft Agent Framework (MAF) roundtable experiment persistence.
Six independent tables — **no foreign keys to the legacy ``roundtable_*``
tables** — so the existing multi-agent roundtable keeps its current behavior,
schema, and recovery semantics untouched. The MAF experiment is fully
isolated: it has its own session / participant / run / event / action /
approval tables.
Boundary: this package lives in the harness layer (``deerflow.*``) and never
imports from ``app.*`` or the MAF SDK. The MAF SDK is loaded lazily inside
``app.maf_roundtable`` only when the feature flag is on and a run is actually
started (Phase 2+).
Tables (see the implementation plan §8):
- ``maf_roundtable_sessions`` — one MAF roundtable session (task + mode + capability snapshot)
- ``maf_roundtable_participants`` — manager + seat agents bound to a session
- ``maf_roundtable_runs`` — one execution run of a session
- ``maf_roundtable_events`` — monotonic per-run event stream (``(run_id, seq)`` unique)
- ``maf_roundtable_actions`` — external write / tool actions (idempotency-keyed)
- ``maf_roundtable_approvals`` — pending human-approval requests
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.maf_roundtable.actions import MafActionRepository
from deerflow.persistence.maf_roundtable.approvals import MafApprovalRepository
from deerflow.persistence.maf_roundtable.events import MafEventRepository
from deerflow.persistence.maf_roundtable.model import (
MafRoundtableActionRow,
MafRoundtableApprovalRow,
MafRoundtableEventRow,
MafRoundtableParticipantRow,
MafRoundtableRunRow,
MafRoundtableSessionRow,
)
from deerflow.persistence.maf_roundtable.participants import MafParticipantRepository
from deerflow.persistence.maf_roundtable.runs import MafRunRepository
from deerflow.persistence.maf_roundtable.sessions import MafSessionRepository
from deerflow.persistence.maf_roundtable.store import MafRoundtableStore
__all__ = [
"MafActionRepository",
"MafApprovalRepository",
"MafEventRepository",
"MafParticipantRepository",
"MafRoundtableActionRow",
"MafRoundtableApprovalRow",
"MafRoundtableEventRow",
"MafRoundtableParticipantRow",
"MafRoundtableRunRow",
"MafRoundtableSessionRow",
"MafRoundtableStore",
"MafRunRepository",
"MafSessionRepository",
"make_maf_roundtable_store",
]
def make_maf_roundtable_store(session_factory: async_sessionmaker[AsyncSession] | None) -> MafRoundtableStore | None:
"""Construct the MAF roundtable facade store.
Returns ``None`` when no DB session factory is configured (memory mode) —
the router then surfaces ``503 Service Disabled`` so the feature is
cleanly off rather than silently dropping data. The lifespan logs the
initialization and any failure (MAF-01-03).
"""
if session_factory is None:
return None
return MafRoundtableStore(session_factory)
@@ -0,0 +1,137 @@
"""Action repository for the MAF roundtable experiment (external writes).
Records every external write / tool action with an idempotency key so the
Action Coordinator (Phase 5) can guarantee at-most-once execution across
retries / restarts. Phase 1 ships the storage + CRUD; the Coordinator itself
is Phase 5. ``arguments_redacted_json`` / ``result_redacted_json`` must
already have credentials / tokens / cookies stripped by the writer — the
store does not re-redact.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.maf_roundtable.model import MafRoundtableActionRow
def _now() -> datetime:
return datetime.now(UTC)
def _to_dict(row: MafRoundtableActionRow) -> dict[str, Any]:
return {
"id": row.id,
"session_id": row.session_id,
"run_id": row.run_id,
"agent_id": row.agent_id,
"tool_name": row.tool_name,
"logical_action_id": row.logical_action_id,
"idempotency_key": row.idempotency_key,
"target_key": row.target_key,
"risk_level": row.risk_level,
"status": row.status,
"arguments": row.arguments_redacted_json,
"result": row.result_redacted_json,
"external_object_id": row.external_object_id,
"approval_id": row.approval_id,
"attempt_count": row.attempt_count,
"started_at": row.started_at.isoformat() if isinstance(row.started_at, datetime) else row.started_at,
"finished_at": row.finished_at.isoformat() if isinstance(row.finished_at, datetime) else row.finished_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 MafActionRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def create_action(self, data: dict[str, Any]) -> dict[str, Any]:
"""Create an action. Raises ``IntegrityError`` if the ``idempotency_key``
already exists (caller should treat that as "already submitted" and
fetch the existing row via ``get_by_idempotency_key``).
"""
now = _now()
row = MafRoundtableActionRow(
id=data.get("id") or uuid4().hex,
session_id=str(data["session_id"]),
run_id=data.get("run_id"),
agent_id=data.get("agent_id"),
tool_name=str(data["tool_name"]),
logical_action_id=data.get("logical_action_id"),
idempotency_key=str(data["idempotency_key"]),
target_key=data.get("target_key"),
risk_level=str(data.get("risk_level") or "R0"),
status=str(data.get("status") or "pending"),
arguments_redacted_json=data.get("arguments"),
result_redacted_json=data.get("result"),
external_object_id=data.get("external_object_id"),
approval_id=data.get("approval_id"),
attempt_count=int(data.get("attempt_count", 0)),
started_at=data.get("started_at"),
finished_at=data.get("finished_at"),
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
return await self.get_action(row.id) # type: ignore[arg-type]
async def get_action(self, action_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
stmt = select(MafRoundtableActionRow).where(MafRoundtableActionRow.id == action_id).limit(1)
row = (await session.execute(stmt)).scalars().first()
return _to_dict(row) if row is not None else None
async def get_by_idempotency_key(self, key: str) -> dict[str, Any] | None:
async with self._sf() as session:
stmt = select(MafRoundtableActionRow).where(MafRoundtableActionRow.idempotency_key == key).limit(1)
row = (await session.execute(stmt)).scalars().first()
return _to_dict(row) if row is not None else None
async def list_actions(
self, *, session_id: str | None = None, run_id: str | None = None, status: str | None = None
) -> list[dict[str, Any]]:
stmt = select(MafRoundtableActionRow)
if session_id is not None:
stmt = stmt.where(MafRoundtableActionRow.session_id == session_id)
if run_id is not None:
stmt = stmt.where(MafRoundtableActionRow.run_id == run_id)
if status is not None:
stmt = stmt.where(MafRoundtableActionRow.status == status)
stmt = stmt.order_by(MafRoundtableActionRow.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 update_action(self, action_id: str, fields: dict[str, Any]) -> dict[str, Any] | None:
async with self._sf() as session:
stmt = select(MafRoundtableActionRow).where(MafRoundtableActionRow.id == action_id).limit(1)
row = (await session.execute(stmt)).scalars().first()
if row is None:
return None
col_map = {
"status": "status",
"risk_level": "risk_level",
"arguments": "arguments_redacted_json",
"result": "result_redacted_json",
"external_object_id": "external_object_id",
"approval_id": "approval_id",
"attempt_count": "attempt_count",
"started_at": "started_at",
"finished_at": "finished_at",
}
for key, value in fields.items():
col = col_map.get(key)
if col is not None:
setattr(row, col, value)
row.updated_at = _now()
await session.commit()
return _to_dict(row)
@@ -0,0 +1,132 @@
"""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']}")
@@ -0,0 +1,168 @@
"""Event repository for the MAF roundtable experiment — monotonic per-run seq.
``(run_id, seq)`` is unique. The store allocates seq via ``SELECT MAX(seq) +
1`` and retries on ``IntegrityError`` so concurrent appends from parallel
seats cannot collide or skip seq values. This is the standard portable
approach (works on sqlite / postgres / mysql — no ``RETURNING`` / ``UPSERT``
dialect differences).
Pull API: ``list_events(run_id, after_seq=N)`` returns events with ``seq > N``
in ascending seq order — the断线补拉 contract. The router exposes this both
as a one-shot ``GET /runs/{run_id}/events?after_seq=N`` and as an SSE
``GET /runs/{run_id}/stream?after_seq=N`` (Phase 6 wires the SSE reducer;
Phase 1 ships the storage + one-shot pull).
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.maf_roundtable.model import MafRoundtableEventRow
from deerflow.persistence.maf_roundtable.runs import MafRunRepository
# Retry budget for the MAX+1 race: a parallel seat that wins the INSERT between
# our SELECT and our INSERT forces one retry. 8 is far more than the realistic
# concurrency (one run has at most ~8 seats), so a true deadlock here would
# indicate a bug, not contention.
_SEQ_INSERT_RETRIES = 8
def _now() -> datetime:
return datetime.now(UTC)
def _to_dict(row: MafRoundtableEventRow) -> dict[str, Any]:
return {
"id": row.id,
"session_id": row.session_id,
"run_id": row.run_id,
"seq": row.seq,
"event_type": row.event_type,
"agent_id": row.agent_id,
"turn_index": row.turn_index,
"payload": row.payload_json,
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
}
class MafEventRepository:
def __init__(
self,
session_factory: async_sessionmaker[AsyncSession],
run_repo: MafRunRepository | None = None,
) -> None:
self._sf = session_factory
self._run_repo = run_repo
async def append_event(
self,
*,
session_id: str,
run_id: str,
event_type: str,
agent_id: str | None = None,
turn_index: int | None = None,
payload: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Append one event with a monotonic per-run seq.
Concurrent appends are safe: each attempts ``MAX(seq)+1`` and retries
on the ``(run_id, seq)`` unique violation. The run's
``last_event_seq`` cache is bumped forward (max wins) so pollers can
cheaply check ``runs.last_event_seq`` without scanning the events
table.
"""
last_exc: Exception | None = None
for _ in range(_SEQ_INSERT_RETRIES):
async with self._sf() as session:
max_seq = (
await session.execute(
select(func.max(MafRoundtableEventRow.seq)).where(
MafRoundtableEventRow.run_id == run_id
)
)
).scalar_one()
next_seq = int(max_seq or 0) + 1
row = MafRoundtableEventRow(
id=uuid4().hex,
session_id=session_id,
run_id=run_id,
seq=next_seq,
event_type=event_type,
agent_id=agent_id,
turn_index=turn_index,
payload_json=payload,
created_at=_now(),
)
session.add(row)
try:
await session.commit()
except IntegrityError as exc: # noqa: PERF203 - retry path
last_exc = exc
await session.rollback()
continue
if self._run_repo is not None:
# best-effort cache bump; never let a cache failure abort
# an event the user already sees.
try:
await self._run_repo.bump_last_event_seq(run_id, next_seq)
except Exception: # noqa: BLE001
pass
return _to_dict(row)
raise IntegrityError(
"Could not append MAF event after retries (run_id seq race)",
params=None,
orig=last_exc,
)
async def append_events(
self, *, session_id: str, run_id: str, events: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""Append a batch in order. Each event is appended sequentially so the
per-run seq stays monotonic; a parallel writer can interleave but never
reorder within this batch.
"""
out: list[dict[str, Any]] = []
for ev in events:
out.append(
await self.append_event(
session_id=session_id,
run_id=run_id,
event_type=str(ev["event_type"]),
agent_id=ev.get("agent_id"),
turn_index=ev.get("turn_index"),
payload=ev.get("payload"),
)
)
return out
async def list_events(
self, run_id: str, *, after_seq: int = 0, limit: int = 1000
) -> list[dict[str, Any]]:
"""断线补拉: events with ``seq > after_seq`` in ascending seq order."""
stmt = (
select(MafRoundtableEventRow)
.where(MafRoundtableEventRow.run_id == run_id, MafRoundtableEventRow.seq > after_seq)
.order_by(MafRoundtableEventRow.seq.asc())
.limit(limit)
)
async with self._sf() as session:
rows = (await session.execute(stmt)).scalars().all()
return [_to_dict(r) for r in rows]
async def latest_seq(self, run_id: str) -> int:
async with self._sf() as session:
max_seq = (
await session.execute(
select(func.max(MafRoundtableEventRow.seq)).where(
MafRoundtableEventRow.run_id == run_id
)
)
).scalar_one()
return int(max_seq or 0)
@@ -0,0 +1,290 @@
"""ORM models for the MAF (Microsoft Agent Framework) roundtable experiment.
Six independent tables — **no foreign keys to the legacy ``roundtable_*``
tables** (``roundtable_jobs`` / ``roundtable_drafts`` /
``roundtable_task_drafts``). The MAF experiment is fully isolated: it has its
own session / participant / run / event / action / approval tables, keyed by
its own surrogate ids, so the legacy multi-agent roundtable keeps its current
behavior, schema, and recovery semantics untouched.
Tables (see the implementation plan §8):
- ``maf_roundtable_sessions`` — one MAF roundtable session (task + mode + capability snapshot)
- ``maf_roundtable_participants`` — manager + seat agents bound to a session
- ``maf_roundtable_runs`` — one execution run of a session
- ``maf_roundtable_events`` — monotonic per-run event stream (``(run_id, seq)`` unique)
- ``maf_roundtable_actions`` — external write / tool actions (idempotency-keyed)
- ``maf_roundtable_approvals`` — pending human-approval requests
User isolation: ``maf_roundtable_sessions.user_id`` is the owner; all child
rows are filtered through the owning session. ``user_id`` on child tables
(``actions``) is audit-only. Admins bypass the owner filter when
``maf_roundtable.admin_diagnostic_access`` is true.
All JSON / large-text columns use the portable types so the schema is identical
across sqlite / postgres / mysql (see ``deerflow.persistence.types``).
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import (
ForeignKey,
Integer,
String,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableJSON, PortableLongText
def _now() -> datetime:
return datetime.now(UTC)
# ── 1. sessions ───────────────────────────────────────────────────────────
class MafRoundtableSessionRow(Base):
"""One MAF roundtable session (the user's task + chosen mode + frozen capability snapshot).
Owner-scoped by ``user_id``. ``capability_snapshot_json`` freezes the
agents / tools / skills configuration at creation time so a run can be
reproduced even if the agent config changes later. ``fact_ledger_json``
holds the shared fact ledger (confirmed facts / tool results / pending /
failed actions) updated as the run progresses.
"""
__tablename__ = "maf_roundtable_sessions"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(255), default="")
task_text: Mapped[str] = mapped_column(PortableLongText, nullable=False)
task_payload_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
# structured | group_chat
mode: Mapped[str] = mapped_column(String(16), default="structured", index=True)
# draft | running | waiting_approval | waiting_input | completed | failed | cancelled
status: Mapped[str] = mapped_column(String(32), default="draft", index=True)
model_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
reasoning_effort: Mapped[str | None] = mapped_column(String(32), nullable=True)
manager_agent_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
max_rounds: Mapped[int] = mapped_column(Integer, default=8)
# auto | confirm_high_risk | confirm_all_writes
write_policy: Mapped[str] = mapped_column(String(32), default="confirm_high_risk")
capability_snapshot_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
fact_ledger_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
final_result_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=_now, onupdate=_now
)
# ── 2. participants ──────────────────────────────────────────────────────
class MafRoundtableParticipantRow(Base):
"""Manager + seat agents bound to a session.
One row per participant agent. ``role`` is ``manager`` / ``seat``.
``thread_id`` / ``sandbox_id`` bind the participant to its own DeerFlow
thread / sandbox (one seat = one thread = one sandbox). ``capability_snapshot_json``
freezes the agent's tools / skills at session creation time.
"""
__tablename__ = "maf_roundtable_participants"
__table_args__ = (
UniqueConstraint("session_id", "agent_id", name="uq_maf_participants_session_agent"),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
session_id: Mapped[str] = mapped_column(
String(64), ForeignKey("maf_roundtable_sessions.id", ondelete="CASCADE"), nullable=False, index=True
)
agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
display_name: Mapped[str] = mapped_column(String(255), default="")
# manager | seat
role: Mapped[str] = mapped_column(String(16), default="seat")
sort_order: Mapped[int] = mapped_column(Integer, default=0)
thread_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
sandbox_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# idle | running | done | failed | cancelled
status: Mapped[str] = mapped_column(String(16), default="idle")
last_turn_index: Mapped[int | None] = mapped_column(Integer, nullable=True)
capability_snapshot_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=_now, onupdate=_now
)
# ── 3. runs ───────────────────────────────────────────────────────────────
class MafRoundtableRunRow(Base):
"""One execution run of a session.
``maf_checkpoint_json`` stores the MAF workflow checkpoint (or a reference)
for resume-after-restart. ``last_event_seq`` is a denormalized cache of the
latest event seq for fast polling (the authoritative seq lives in
``maf_roundtable_events``). ``token_usage_json`` accumulates per-run token
usage. ``latency_ms`` is the wall-clock duration.
"""
__tablename__ = "maf_roundtable_runs"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
session_id: Mapped[str] = mapped_column(
String(64), ForeignKey("maf_roundtable_sessions.id", ondelete="CASCADE"), nullable=False, index=True
)
# pending | running | waiting_approval | waiting_input | completed | failed | cancelled
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
workflow_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
maf_checkpoint_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
last_event_seq: Mapped[int] = mapped_column(Integer, default=0)
started_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
error_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
error_message_safe: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
token_usage_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
latency_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=_now, onupdate=_now
)
# ── 4. events ─────────────────────────────────────────────────────────────
class MafRoundtableEventRow(Base):
"""Monotonic per-run event stream.
``(run_id, seq)`` is unique — seq is monotonically increasing per run. The
store allocates seq by ``SELECT MAX(seq) + 1`` and retries on
``IntegrityError`` so concurrent appends from parallel seats cannot collide
or skip seq values. ``payload_json`` carries the event-specific body (tool
result, agent message delta, fact-recorded, etc.); sensitive fields must
already be redacted by the writer.
"""
__tablename__ = "maf_roundtable_events"
__table_args__ = (
UniqueConstraint("run_id", "seq", name="uq_maf_events_run_seq"),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
session_id: Mapped[str] = mapped_column(
String(64), ForeignKey("maf_roundtable_sessions.id", ondelete="CASCADE"), nullable=False, index=True
)
run_id: Mapped[str] = mapped_column(
String(64), ForeignKey("maf_roundtable_runs.id", ondelete="CASCADE"), nullable=False, index=True
)
seq: Mapped[int] = mapped_column(Integer, nullable=False)
# run.started | run.status_changed | workflow.checkpointed | manager.decision |
# agent.turn_started | agent.message.delta | agent.message.completed |
# skill.selected | skill.started | skill.completed | tool.requested | tool.started |
# tool.progress | tool.completed | tool.failed | action.waiting_approval |
# action.approved | action.rejected | artifact.created | fact.recorded |
# run.completed | run.failed
event_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
agent_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
turn_index: Mapped[int | None] = mapped_column(Integer, nullable=True)
payload_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now, index=True)
# ── 5. actions ─────────────────────────────────────────────────────────────
class MafRoundtableActionRow(Base):
"""External write / tool action record (idempotency-keyed).
The Action Coordinator (Phase 5) uses ``idempotency_key`` to guarantee
at-most-once execution across retries / restarts. ``target_key`` carries a
per-business-object lease so parallel seats writing the same object are
serialized. ``risk_level`` is R0–R4. ``status`` is the action state machine:
pending | running | succeeded | failed | unknown | waiting_approval |
approved | rejected | compensated. ``arguments_redacted_json`` /
``result_redacted_json`` must already have credentials / tokens / cookies
stripped by the writer.
"""
__tablename__ = "maf_roundtable_actions"
__table_args__ = (
UniqueConstraint("idempotency_key", name="uq_maf_actions_idempotency"),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
session_id: Mapped[str] = mapped_column(
String(64), ForeignKey("maf_roundtable_sessions.id", ondelete="CASCADE"), nullable=False, index=True
)
run_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
agent_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
tool_name: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
logical_action_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
idempotency_key: Mapped[str] = mapped_column(String(191), nullable=False, unique=True)
target_key: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
# R0 | R1 | R2 | R3 | R4
risk_level: Mapped[str] = mapped_column(String(8), default="R0")
# pending | running | succeeded | failed | unknown | waiting_approval | approved | rejected | compensated
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
arguments_redacted_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
result_redacted_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
external_object_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
approval_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
started_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=_now, onupdate=_now
)
# ── 6. approvals ──────────────────────────────────────────────────────────
class MafRoundtableApprovalRow(Base):
"""Pending human-approval request for a high-risk external write.
``status`` is ``pending | approved | rejected | expired``. ``payload_redacted_json``
carries the action summary the user sees (redacted). ``expires_at`` is the
deadline after which the approval auto-expires and the action is marked
``rejected`` (or ``unknown`` if it may already have been sent).
"""
__tablename__ = "maf_roundtable_approvals"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
session_id: Mapped[str] = mapped_column(
String(64), ForeignKey("maf_roundtable_sessions.id", ondelete="CASCADE"), nullable=False, index=True
)
run_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
action_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
requested_by_agent_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# pending | approved | rejected | expired
status: Mapped[str] = mapped_column(String(16), default="pending", index=True)
summary: Mapped[str] = mapped_column(PortableLongText, default="")
payload_redacted_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
decided_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
decided_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
expires_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=_now, onupdate=_now
)
__all__ = [
"MafRoundtableActionRow",
"MafRoundtableApprovalRow",
"MafRoundtableEventRow",
"MafRoundtableParticipantRow",
"MafRoundtableRunRow",
"MafRoundtableSessionRow",
]
@@ -0,0 +1,122 @@
"""Participant repository for the MAF roundtable experiment.
Participants are the manager + seat agents bound to a session. They are
created together with the session (``replace_participants`` is the atomic
full-replacement API mirroring ``task_buttons``) and read via the session.
``capability_snapshot_json`` freezes each agent's tools / skills at session
creation time.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.maf_roundtable.model import MafRoundtableParticipantRow
def _now() -> datetime:
return datetime.now(UTC)
def _to_dict(row: MafRoundtableParticipantRow) -> dict[str, Any]:
return {
"id": row.id,
"session_id": row.session_id,
"agent_id": row.agent_id,
"display_name": row.display_name,
"role": row.role,
"sort_order": row.sort_order,
"thread_id": row.thread_id,
"sandbox_id": row.sandbox_id,
"status": row.status,
"last_turn_index": row.last_turn_index,
"capability_snapshot": row.capability_snapshot_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 MafParticipantRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def list_participants(self, session_id: str) -> list[dict[str, Any]]:
stmt = (
select(MafRoundtableParticipantRow)
.where(MafRoundtableParticipantRow.session_id == session_id)
.order_by(MafRoundtableParticipantRow.sort_order.asc())
)
async with self._sf() as session:
rows = (await session.execute(stmt)).scalars().all()
return [_to_dict(r) for r in rows]
async def replace_participants(
self, session_id: str, participants: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""Atomically replace all participants of a session.
Each item: ``{agent_id, display_name?, role?, sort_order?, thread_id?,
sandbox_id?, capability_snapshot?}``. De-dupes by ``agent_id`` (last
wins) to honor the ``(session_id, agent_id)`` unique constraint.
"""
by_agent: dict[str, dict[str, Any]] = {}
for p in participants:
agent_id = str(p.get("agent_id") or "")
if not agent_id:
continue
by_agent[agent_id] = p
now = _now()
new_rows = [
MafRoundtableParticipantRow(
id=uuid4().hex,
session_id=session_id,
agent_id=agent_id,
display_name=str(p.get("display_name") or ""),
role=str(p.get("role") or "seat"),
sort_order=int(p.get("sort_order", 0)),
thread_id=p.get("thread_id"),
sandbox_id=p.get("sandbox_id"),
status=str(p.get("status") or "idle"),
last_turn_index=p.get("last_turn_index"),
capability_snapshot_json=p.get("capability_snapshot"),
created_at=now,
updated_at=now,
)
for agent_id, p in by_agent.items()
]
async with self._sf() as session:
await session.execute(
delete(MafRoundtableParticipantRow).where(
MafRoundtableParticipantRow.session_id == session_id
)
)
session.add_all(new_rows)
await session.commit()
return [_to_dict(r) for r in new_rows]
async def update_participant_status(
self, session_id: str, agent_id: str, *, status: str, last_turn_index: int | None = None
) -> dict[str, Any] | None:
async with self._sf() as session:
stmt = (
select(MafRoundtableParticipantRow)
.where(
MafRoundtableParticipantRow.session_id == session_id,
MafRoundtableParticipantRow.agent_id == agent_id,
)
.limit(1)
)
row = (await session.execute(stmt)).scalars().first()
if row is None:
return None
row.status = status
if last_turn_index is not None:
row.last_turn_index = last_turn_index
row.updated_at = _now()
await session.commit()
return _to_dict(row)
@@ -0,0 +1,129 @@
"""Run repository for the MAF roundtable experiment.
A run is one execution of a session. Phase 1 only persists the run row and its
status lifecycle — actual workflow execution (MAF SDK) is Phase 2+. The run
row carries the MAF checkpoint (for resume-after-restart) and a denormalized
``last_event_seq`` cache for fast polling; the authoritative seq lives in
``maf_roundtable_events``.
"""
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 MafRoundtableRunRow
def _now() -> datetime:
return datetime.now(UTC)
def run_to_dict(row: MafRoundtableRunRow) -> dict[str, Any]:
"""Serialise a run ORM row to a plain dict (public — reused by the store facade)."""
return {
"id": row.id,
"session_id": row.session_id,
"status": row.status,
"workflow_version": row.workflow_version,
"maf_checkpoint": row.maf_checkpoint_json,
"last_event_seq": row.last_event_seq,
"started_at": row.started_at.isoformat() if isinstance(row.started_at, datetime) else row.started_at,
"finished_at": row.finished_at.isoformat() if isinstance(row.finished_at, datetime) else row.finished_at,
"error_code": row.error_code,
"error_message_safe": row.error_message_safe,
"token_usage": row.token_usage_json,
"latency_ms": row.latency_ms,
"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,
}
# Back-compat alias for internal callers that used the old private name.
_to_dict = run_to_dict
class MafRunRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def create_run(self, session_id: str, *, workflow_version: str | None = None) -> dict[str, Any]:
now = _now()
row = MafRoundtableRunRow(
id=uuid4().hex,
session_id=session_id,
status="pending",
workflow_version=workflow_version,
started_at=None, # pending = queued, not yet handed to the executor
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
return await self.get_run(row.id) # type: ignore[arg-type]
async def get_run(self, run_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
stmt = select(MafRoundtableRunRow).where(MafRoundtableRunRow.id == run_id).limit(1)
row = (await session.execute(stmt)).scalars().first()
return _to_dict(row) if row is not None else None
async def list_runs(self, session_id: str) -> list[dict[str, Any]]:
stmt = (
select(MafRoundtableRunRow)
.where(MafRoundtableRunRow.session_id == session_id)
.order_by(MafRoundtableRunRow.created_at.desc())
)
async with self._sf() as session:
rows = (await session.execute(stmt)).scalars().all()
return [_to_dict(r) for r in rows]
async def update_run(self, run_id: str, fields: dict[str, Any]) -> dict[str, Any] | None:
async with self._sf() as session:
stmt = select(MafRoundtableRunRow).where(MafRoundtableRunRow.id == run_id).limit(1)
row = (await session.execute(stmt)).scalars().first()
if row is None:
return None
col_map = {
"status": "status",
"workflow_version": "workflow_version",
"maf_checkpoint": "maf_checkpoint_json",
"last_event_seq": "last_event_seq",
"started_at": "started_at",
"finished_at": "finished_at",
"error_code": "error_code",
"error_message_safe": "error_message_safe",
"token_usage": "token_usage_json",
"latency_ms": "latency_ms",
}
for key, value in fields.items():
col = col_map.get(key)
if col is not None:
setattr(row, col, value)
row.updated_at = _now()
await session.commit()
return _to_dict(row)
async def set_status(self, run_id: str, status: str) -> None:
async with self._sf() as session:
await session.execute(
update(MafRoundtableRunRow)
.where(MafRoundtableRunRow.id == run_id)
.values(status=status, updated_at=_now())
)
await session.commit()
async def bump_last_event_seq(self, run_id: str, seq: int) -> None:
"""Denormalized cache update — only bumps forward (max wins)."""
async with self._sf() as session:
await session.execute(
update(MafRoundtableRunRow)
.where(MafRoundtableRunRow.id == run_id, MafRoundtableRunRow.last_event_seq < seq)
.values(last_event_seq=seq, updated_at=_now())
)
await session.commit()
@@ -0,0 +1,201 @@
"""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()
@@ -0,0 +1,192 @@
"""Facade combining all six MAF roundtable repositories into one store.
The router / service layer talks to this facade rather than the individual
repositories, so the wiring in ``deps.py`` is a single ``app.state.maf_roundtable_store``.
The facade also enforces the cross-repo invariant that an event / action /
approval's ``session_id`` must belong to a session the caller owns (or admin
bypass) — the individual repos are session-id-agnostic and trust the caller.
Cross-table atomic operations (e.g. claim session + create run, cancel run +
repair session) live here so they share a single ``AsyncSession`` / transaction
— individual repos each open their own session and cannot participate in one
another's transactions.
"""
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.actions import MafActionRepository
from deerflow.persistence.maf_roundtable.approvals import MafApprovalRepository
from deerflow.persistence.maf_roundtable.events import MafEventRepository
from deerflow.persistence.maf_roundtable.model import MafRoundtableRunRow, MafRoundtableSessionRow
from deerflow.persistence.maf_roundtable.participants import MafParticipantRepository
from deerflow.persistence.maf_roundtable.runs import MafRunRepository, run_to_dict
from deerflow.persistence.maf_roundtable.sessions import MafSessionRepository
# Session statuses that mean a run is in flight — the CAS claim refuses these.
_ACTIVE_SESSION_STATUSES = ("starting", "running", "waiting_approval", "waiting_input")
# Run statuses that are still in flight (cancellable).
_ACTIVE_RUN_STATUSES = ("pending", "running", "waiting_approval", "waiting_input")
# Run statuses that are terminal (cannot be re-cancelled).
_TERMINAL_RUN_STATUSES = ("completed", "failed", "cancelled")
def _now() -> datetime:
return datetime.now(UTC)
class MafRoundtableStore:
"""Combined MAF roundtable persistence facade.
Each sub-repo is independently constructed from the same session factory
so they share the engine / pool. The facade exposes them as attributes so
the service layer can do ``store.sessions.create_session(...)`` etc.
"""
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
self.sessions = MafSessionRepository(session_factory)
self.participants = MafParticipantRepository(session_factory)
self.runs = MafRunRepository(session_factory)
self.events = MafEventRepository(session_factory, run_repo=self.runs)
self.actions = MafActionRepository(session_factory)
self.approvals = MafApprovalRepository(session_factory)
@property
def session_factory(self) -> async_sessionmaker[AsyncSession]:
return self._sf
async def claim_and_create_pending_run(
self,
*,
session_id: str,
owner_user_id: str,
workflow_version: str | None = None,
) -> dict[str, Any] | None:
"""Atomically claim a session and create a ``pending`` run in one transaction.
Single ``AsyncSession`` + ``begin()``:
1. Conditional UPDATE: set ``status='running'`` only when the session
matches ``id`` + ``user_id`` and is not already active.
2. If ``rowcount != 1``, return ``None`` (caller maps to 404/403/409) —
no run is inserted.
3. Insert a ``pending`` run, ``flush`` to materialise the id, commit.
Any exception during insert / flush / commit rolls the whole transaction
back — the session never stays in a half-claimed ``starting`` state
(we never write ``starting``; the claim lands directly on ``running``).
Concurrent callers are serialised by the database UPDATE.
"""
now = _now()
async with self._sf() as db:
async with db.begin():
result = await db.execute(
update(MafRoundtableSessionRow)
.where(
MafRoundtableSessionRow.id == session_id,
MafRoundtableSessionRow.user_id == owner_user_id,
MafRoundtableSessionRow.status.notin_(_ACTIVE_SESSION_STATUSES),
)
.values(status="running", updated_at=now)
)
if result.rowcount != 1: # type: ignore[union-attr]
# Claim lost or session missing/wrong-owner — no run created.
return None
run_row = MafRoundtableRunRow(
id=uuid4().hex,
session_id=session_id,
status="pending",
workflow_version=workflow_version,
started_at=None, # pending = queued, not yet handed to executor
created_at=now,
updated_at=now,
)
db.add(run_row)
await db.flush()
# Build the dict before the transaction exits (expire-on-commit
# would otherwise wipe attributes after commit).
return run_to_dict(run_row)
async def cancel_run_atomically(
self,
*,
run_id: str,
owner_user_id: str,
) -> dict[str, Any] | None:
"""Atomically cancel a run and reconcile its session in one transaction.
Single ``AsyncSession`` + ``begin()``:
1. SELECT run JOIN session WHERE ``run.id`` + ``session.user_id``
(``with_for_update`` on PG/MySQL; SQLite accepts the no-op lock).
2. Run missing or not owned → return ``None`` (no writes).
3. Active run (``pending``/``running``/``waiting_*``): set run to
``cancelled`` + ``finished_at``, session to ``cancelled``.
4. Already-``cancelled`` run: repair session if still active
(fixes historical half-completed data).
5. Terminal run (``completed``/``failed``): do NOT change the run;
repair session to the run's terminal state only if session is
still active (never overwrite an already-terminal session).
6. Flush + commit; any exception rolls both run and session back.
"""
now = _now()
async with self._sf() as db:
async with db.begin():
stmt = (
select(MafRoundtableRunRow, MafRoundtableSessionRow)
.join(
MafRoundtableSessionRow,
MafRoundtableRunRow.session_id == MafRoundtableSessionRow.id,
)
.where(
MafRoundtableRunRow.id == run_id,
MafRoundtableSessionRow.user_id == owner_user_id,
)
.with_for_update()
)
row = (await db.execute(stmt)).first()
if row is None:
return None
run_row, session_row = row
if run_row.status in _ACTIVE_RUN_STATUSES:
# Normal cancel: run + session → cancelled.
run_row.status = "cancelled"
run_row.finished_at = now
run_row.updated_at = now
session_row.status = "cancelled"
session_row.updated_at = now
elif run_row.status == "cancelled":
# Already cancelled — repair session if still active.
if session_row.status in _ACTIVE_SESSION_STATUSES:
session_row.status = "cancelled"
session_row.updated_at = now
else:
# completed / failed — do NOT change the run's status.
# Repair session only if it is still active.
if session_row.status in _ACTIVE_SESSION_STATUSES:
session_row.status = run_row.status
session_row.updated_at = now
await db.flush()
return run_to_dict(run_row)
__all__ = [
"MafActionRepository",
"MafApprovalRepository",
"MafEventRepository",
"MafParticipantRepository",
"MafRoundtableStore",
"MafRunRepository",
"MafSessionRepository",
]
@@ -42,6 +42,14 @@ from deerflow.persistence.embed_sessions.model import EmbedSessionRow
from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.persistence.light_apps.model import LightAppRow
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
from deerflow.persistence.maf_roundtable.model import (
MafRoundtableActionRow,
MafRoundtableApprovalRow,
MafRoundtableEventRow,
MafRoundtableParticipantRow,
MafRoundtableRunRow,
MafRoundtableSessionRow,
)
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
from deerflow.persistence.menu_overrides.model import MenuOverrideRow
from deerflow.persistence.models.run_event import RunEventRow
@@ -108,6 +116,12 @@ __all__ = [
"KnowledgeTagRow",
"LightAppRow",
"LlmCallMetricRow",
"MafRoundtableActionRow",
"MafRoundtableApprovalRow",
"MafRoundtableEventRow",
"MafRoundtableParticipantRow",
"MafRoundtableRunRow",
"MafRoundtableSessionRow",
"MenuOverrideRow",
"NotificationRow",
"ParallelAgentTaskRow",
@@ -0,0 +1,206 @@
"""P1 admin write-boundary + P2 config-default tests for the MAF roundtable.
P1 — admin diagnostic access is **read-only**:
- An admin with ``admin_diagnostic_access=True`` can *read* another user's
session / run / action / approval (cross-user diagnostics).
- The same admin **cannot** *write* another user's resources: update / delete
session, start / cancel run, append event, decide approval all raise
``MafForbiddenError`` (→ HTTP 403), never silently succeeding.
- Normal users remain fully isolated from each other.
P2 — config-driven create defaults:
- Omitted ``mode`` / ``write_policy`` / ``max_rounds`` fall back to the deployed
``MafRoundtableConfig`` values.
- An explicit request value overrides the config default.
"""
from __future__ import annotations
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.maf_roundtable.service import (
MafForbiddenError,
MafNotFoundError,
MafRoundtableService,
)
from deerflow.config.maf_roundtable_config import MafRoundtableConfig
from deerflow.persistence.base import Base
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
@pytest_asyncio.fixture
async def store(tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'maf_adm.db'}")
async with engine.begin() as conn:
import deerflow.persistence.maf_roundtable.model # noqa: F401
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
yield make_maf_roundtable_store(sf)
await engine.dispose()
def _svc(store, *, enabled=True, admin_diagnostic_access=True, config=None):
return MafRoundtableService(
store,
enabled=enabled,
admin_diagnostic_access=admin_diagnostic_access,
config=config,
)
# ── P1: admin can READ cross-user ─────────────────────────────────────────
@pytest.mark.asyncio
async def test_admin_can_read_other_user_session(store):
svc = _svc(store, admin_diagnostic_access=True)
s = await svc.create_session(user_id="alice", body={"task_text": "t"})
fetched = await svc.get_session(session_id=s["id"], user_id="admin-1", is_admin=True)
assert fetched["id"] == s["id"]
assert fetched["user_id"] == "alice"
@pytest.mark.asyncio
async def test_admin_can_read_other_user_run_and_action(store):
svc = _svc(store, admin_diagnostic_access=True)
s = await svc.create_session(user_id="alice", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="alice")
action = await store.actions.create_action(
{"session_id": s["id"], "run_id": run["id"], "tool_name": "t", "idempotency_key": "k", "status": "pending"}
)
# Admin reads run + action cross-user without error.
assert (await svc.get_run(run_id=run["id"], user_id="admin-1", is_admin=True))["id"] == run["id"]
assert (await svc.get_action(action_id=action["id"], user_id="admin-1", is_admin=True))["id"] == action["id"]
assert len(await svc.list_runs(session_id=s["id"], user_id="admin-1", is_admin=True)) == 1
# ── P1: admin CANNOT WRITE cross-user ─────────────────────────────────────
@pytest.mark.asyncio
async def test_admin_cannot_update_other_user_session(store):
svc = _svc(store, admin_diagnostic_access=True)
s = await svc.create_session(user_id="alice", body={"task_text": "t"})
with pytest.raises(MafForbiddenError):
await svc.update_session(session_id=s["id"], user_id="admin-1", fields={"title": "hijack"}, is_admin=True)
# Unchanged.
assert (await svc.get_session(session_id=s["id"], user_id="alice"))["title"] == ""
@pytest.mark.asyncio
async def test_admin_cannot_delete_other_user_session(store):
svc = _svc(store, admin_diagnostic_access=True)
s = await svc.create_session(user_id="alice", body={"task_text": "t"})
with pytest.raises(MafForbiddenError):
await svc.delete_session(session_id=s["id"], user_id="admin-1", is_admin=True)
# Still present.
assert (await svc.get_session(session_id=s["id"], user_id="alice"))["id"] == s["id"]
@pytest.mark.asyncio
async def test_admin_cannot_start_run_on_other_user_session(store):
svc = _svc(store, admin_diagnostic_access=True)
s = await svc.create_session(user_id="alice", body={"task_text": "t"})
with pytest.raises(MafForbiddenError):
await svc.create_run(session_id=s["id"], user_id="admin-1", is_admin=True)
# No run was created.
assert await svc.list_runs(session_id=s["id"], user_id="alice") == []
@pytest.mark.asyncio
async def test_admin_cannot_cancel_other_user_run(store):
svc = _svc(store, admin_diagnostic_access=True)
s = await svc.create_session(user_id="alice", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="alice")
with pytest.raises(MafForbiddenError):
await svc.cancel_run(run_id=run["id"], user_id="admin-1", is_admin=True)
assert (await svc.get_run(run_id=run["id"], user_id="alice"))["status"] == "pending"
@pytest.mark.asyncio
async def test_non_owner_cannot_cancel_run_gets_not_found(store):
"""A non-admin user cancelling another user's run gets 404 (not 403)."""
svc = _svc(store, admin_diagnostic_access=True)
s = await svc.create_session(user_id="alice", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="alice")
with pytest.raises(MafNotFoundError):
await svc.cancel_run(run_id=run["id"], user_id="bob")
assert (await svc.get_run(run_id=run["id"], user_id="alice"))["status"] == "pending"
@pytest.mark.asyncio
async def test_admin_cannot_append_event_to_other_user_run(store):
svc = _svc(store, admin_diagnostic_access=True)
s = await svc.create_session(user_id="alice", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="alice")
with pytest.raises(MafForbiddenError):
await svc.append_event(run_id=run["id"], user_id="admin-1", event_type="run.started", is_admin=True)
@pytest.mark.asyncio
async def test_admin_cannot_decide_other_user_approval(store):
svc = _svc(store, admin_diagnostic_access=True)
s = await svc.create_session(user_id="alice", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="alice")
appr = await store.approvals.create_approval(
{"session_id": s["id"], "run_id": run["id"], "summary": "x", "status": "pending"}
)
with pytest.raises(MafForbiddenError):
await svc.decide_approval(approval_id=appr["id"], decision="approved", user_id="admin-1", is_admin=True)
assert (await store.approvals.get_approval(appr["id"]))["status"] == "pending"
@pytest.mark.asyncio
async def test_admin_diagnostic_off_cannot_even_read_cross_user(store):
svc = _svc(store, admin_diagnostic_access=False)
s = await svc.create_session(user_id="alice", body={"task_text": "t"})
with pytest.raises(MafNotFoundError):
await svc.get_session(session_id=s["id"], user_id="admin-1", is_admin=True)
@pytest.mark.asyncio
async def test_normal_users_remain_fully_isolated(store):
svc = _svc(store, admin_diagnostic_access=True)
s = await svc.create_session(user_id="alice", body={"task_text": "t"})
# bob is a non-admin → isolated on both read and write.
with pytest.raises(MafNotFoundError):
await svc.get_session(session_id=s["id"], user_id="bob")
with pytest.raises(MafNotFoundError):
await svc.update_session(session_id=s["id"], user_id="bob", fields={"title": "x"})
# ── P2: config-driven create defaults ─────────────────────────────────────
@pytest.mark.asyncio
async def test_create_session_uses_config_defaults_when_omitted(store):
cfg = MafRoundtableConfig(
default_mode="group_chat",
default_write_policy="confirm_all_writes",
default_max_rounds=12,
)
svc = _svc(store, config=cfg)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
assert s["mode"] == "group_chat"
assert s["write_policy"] == "confirm_all_writes"
assert s["max_rounds"] == 12
@pytest.mark.asyncio
async def test_create_session_explicit_values_override_config(store):
cfg = MafRoundtableConfig(
default_mode="group_chat",
default_write_policy="confirm_all_writes",
default_max_rounds=12,
)
svc = _svc(store, config=cfg)
s = await svc.create_session(
user_id="u1",
body={"task_text": "t", "mode": "structured", "write_policy": "auto", "max_rounds": 3},
)
assert s["mode"] == "structured"
assert s["write_policy"] == "auto"
assert s["max_rounds"] == 3
@@ -0,0 +1,282 @@
"""Tests for the MAF roundtable persistence + service layer (阶段一骨架).
Covers the Phase 1 exit criteria:
- Session CRUD with participants + capability snapshot.
- Run lifecycle (create / get / list / cancel).
- Monotonic per-run event seq under concurrent appends (MAF-01-06).
- Event 断线补拉 (list_events after_seq=N).
- Action idempotency-key uniqueness.
- Approval decision transition + "already decided" guard.
- User isolation (MAF-01-07) — non-owner gets None / 404, not 403.
- Admin diagnostic access bypass.
- Feature flag (MAF-01-04) — writes raise MafDisabledError when off; reads open.
- Run gating — running session rejects config edits.
- capability_snapshot frozen at creation.
- MAF tables have no FK to legacy roundtable_* tables.
"""
from __future__ import annotations
import asyncio
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.maf_roundtable.service import (
MafConflictError,
MafNotFoundError,
MafRoundtableService,
)
from deerflow.persistence.base import Base
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
@pytest_asyncio.fixture
async def store(tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'maf.db'}")
async with engine.begin() as conn:
import deerflow.persistence.maf_roundtable.model # noqa: F401
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
yield make_maf_roundtable_store(sf)
await engine.dispose()
def _svc(store, *, enabled=True, admin_diagnostic_access=True):
return MafRoundtableService(store, enabled=enabled, admin_diagnostic_access=admin_diagnostic_access)
@pytest.mark.asyncio
async def test_create_and_get_session_round_trips(store):
svc = _svc(store)
created = await svc.create_session(
user_id="u1",
body={
"title": "舆情会商",
"task_text": "分析暑期文旅舆情",
"mode": "structured",
"model_name": "glm-5",
"max_rounds": 5,
"write_policy": "confirm_high_risk",
"capability_snapshot": {"agents": ["a1"]},
"participants": [
{"agent_id": "mgr", "display_name": "主持人", "role": "manager", "sort_order": 0},
{"agent_id": "seat-a", "display_name": "席A", "role": "seat", "sort_order": 1},
],
},
)
assert created["user_id"] == "u1"
assert created["status"] == "draft"
assert created["mode"] == "structured"
assert created["capability_snapshot"] == {"agents": ["a1"]}
assert len(created["participants"]) == 2
assert created["participants"][0]["agent_id"] == "mgr"
fetched = await svc.get_session(session_id=created["id"], user_id="u1")
assert fetched["id"] == created["id"]
assert len(fetched["participants"]) == 2
@pytest.mark.asyncio
async def test_list_sessions_owner_scoped(store):
svc = _svc(store)
await svc.create_session(user_id="u1", body={"task_text": "t1"})
await svc.create_session(user_id="u2", body={"task_text": "t2"})
rows_u1, total_u1 = await svc.list_sessions(user_id="u1")
assert total_u1 == 1
assert rows_u1[0]["user_id"] == "u1"
rows_u2, total_u2 = await svc.list_sessions(user_id="u2")
assert total_u2 == 1
assert rows_u2[0]["user_id"] == "u2"
@pytest.mark.asyncio
async def test_update_session_partial_and_participants_replace(store):
svc = _svc(store)
s = await svc.create_session(
user_id="u1",
body={"task_text": "orig", "participants": [{"agent_id": "a1", "role": "seat"}]},
)
updated = await svc.update_session(
session_id=s["id"],
user_id="u1",
fields={"title": "新标题", "participants": [{"agent_id": "a2", "role": "seat"}]},
)
assert updated["title"] == "新标题"
assert updated["task_text"] == "orig"
assert {p["agent_id"] for p in updated["participants"]} == {"a2"}
@pytest.mark.asyncio
async def test_update_session_freezes_capability_snapshot(store):
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t", "capability_snapshot": {"v": 1}})
updated = await svc.update_session(
session_id=s["id"], user_id="u1", fields={"capability_snapshot": {"v": 999}}
)
assert updated["capability_snapshot"] == {"v": 1}
@pytest.mark.asyncio
async def test_delete_session_owner_scoped(store):
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
with pytest.raises(MafNotFoundError):
await svc.delete_session(session_id=s["id"], user_id="u2")
await svc.delete_session(session_id=s["id"], user_id="u1")
with pytest.raises(MafNotFoundError):
await svc.get_session(session_id=s["id"], user_id="u1")
# ── run lifecycle ──────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_create_run_sets_session_running(store):
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
# No real MAF executor exists yet (Phase 2+): the run is parked in
# ``pending`` (a clearly reserved, not-yet-executing state) rather than
# pretending to be ``running``; the session is occupied (``running``).
assert run["status"] == "pending"
refreshed = await svc.get_session(session_id=s["id"], user_id="u1")
assert refreshed["status"] == "running"
@pytest.mark.asyncio
async def test_create_run_rejects_second_active_run(store):
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
await svc.create_run(session_id=s["id"], user_id="u1")
with pytest.raises(MafConflictError):
await svc.create_run(session_id=s["id"], user_id="u1")
@pytest.mark.asyncio
async def test_update_session_rejected_while_running(store):
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
await svc.create_run(session_id=s["id"], user_id="u1")
with pytest.raises(MafConflictError):
await svc.update_session(session_id=s["id"], user_id="u1", fields={"title": "x"})
@pytest.mark.asyncio
async def test_cancel_run_marks_session_cancelled(store):
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
cancelled = await svc.cancel_run(run_id=run["id"], user_id="u1")
assert cancelled["status"] == "cancelled"
refreshed = await svc.get_session(session_id=s["id"], user_id="u1")
assert refreshed["status"] == "cancelled"
# ── events: monotonic seq + concurrent appends (MAF-01-06) ────────────────
@pytest.mark.asyncio
async def test_append_event_monotonic_seq(store):
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
e1 = await svc.append_event(run_id=run["id"], user_id="u1", event_type="run.started")
e2 = await svc.append_event(run_id=run["id"], user_id="u1", event_type="agent.message.completed")
e3 = await svc.append_event(run_id=run["id"], user_id="u1", event_type="run.completed")
assert [e1["seq"], e2["seq"], e3["seq"]] == [1, 2, 3]
refreshed_run = await svc.get_run(run_id=run["id"], user_id="u1")
assert refreshed_run["last_event_seq"] == 3
@pytest.mark.asyncio
async def test_concurrent_appends_produce_dense_seq_set(store):
"""Many parallel appends must produce a dense 1..N seq set — no gaps, no
collisions — exercising the MAX+1 retry on the (run_id, seq) unique
constraint (MAF-01-06)."""
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
n = 40
results = await asyncio.gather(
*[
svc.append_event(
run_id=run["id"],
user_id="u1",
event_type="agent.message.delta",
payload={"i": i},
)
for i in range(n)
]
)
seqs = sorted(r["seq"] for r in results)
assert seqs == list(range(1, n + 1))
@pytest.mark.asyncio
async def test_list_events_after_seq_pull(store):
"""断线补拉: events with seq > after_seq in ascending order."""
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
for i in range(5):
await svc.append_event(run_id=run["id"], user_id="u1", event_type="tool.progress", payload={"i": i})
events, latest = await svc.list_events(run_id=run["id"], user_id="u1", after_seq=2)
assert [e["seq"] for e in events] == [3, 4, 5]
assert latest == 5
# ── actions: idempotency key ──────────────────────────────────────────────
@pytest.mark.asyncio
async def test_action_idempotency_key_unique(store):
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
from sqlalchemy.exc import IntegrityError
await store.actions.create_action(
{
"session_id": s["id"],
"run_id": run["id"],
"tool_name": "update_task",
"idempotency_key": "sess1+run1+seat-a+update_task+act1",
"risk_level": "R2",
"status": "pending",
}
)
with pytest.raises(IntegrityError):
await store.actions.create_action(
{
"session_id": s["id"],
"run_id": run["id"],
"tool_name": "update_task",
"idempotency_key": "sess1+run1+seat-a+update_task+act1",
"status": "pending",
}
)
found = await store.actions.get_by_idempotency_key("sess1+run1+seat-a+update_task+act1")
assert found is not None
assert found["tool_name"] == "update_task"
# ── approvals: decision transition ────────────────────────────────────────
@pytest.mark.asyncio
async def test_approval_decision_and_already_decided_guard(store):
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
appr = await store.approvals.create_approval(
{"session_id": s["id"], "run_id": run["id"], "summary": "高风险写入", "status": "pending"}
)
decided = await svc.decide_approval(approval_id=appr["id"], decision="approved", user_id="u1")
assert decided["status"] == "approved"
assert decided["decided_by"] == "u1"
with pytest.raises(MafConflictError):
await svc.decide_approval(approval_id=appr["id"], decision="rejected", user_id="u1")
@@ -0,0 +1,319 @@
"""P1 concurrency + single-transaction launch tests for MAF roundtable.
Covers:
- **Concurrent run launch**: ``asyncio.gather`` of ≥8 ``create_run`` calls must
yield exactly one active run; the rest get ``MafConflictError``.
- **Single-transaction atomicity**: flush / pre-commit failures roll back fully —
session stays at its prior status, no orphan run, no residual ``starting``.
- **Success shape**: session ``running``, unique run ``pending``.
- **Cancel then re-launch** works.
- **Concurrent approval decision**: parallel approve + reject → exactly one winner.
"""
from __future__ import annotations
import asyncio
from unittest.mock import patch
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.maf_roundtable.service import (
MafConflictError,
MafRoundtableService,
)
from deerflow.persistence.base import Base
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
@pytest_asyncio.fixture
async def store(tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'maf_conc.db'}")
async with engine.begin() as conn:
import deerflow.persistence.maf_roundtable.model # noqa: F401
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
yield make_maf_roundtable_store(sf)
await engine.dispose()
def _svc(store, *, enabled=True, admin_diagnostic_access=True):
return MafRoundtableService(store, enabled=enabled, admin_diagnostic_access=admin_diagnostic_access)
# ── P1: concurrent run launch (single-tx CAS) ─────────────────────────────
@pytest.mark.asyncio
async def test_concurrent_create_run_only_one_wins(store):
"""8 parallel ``create_run`` calls → exactly one active run, 7 conflicts."""
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
async def _attempt() -> tuple[str, object]:
try:
run = await svc.create_run(session_id=s["id"], user_id="u1")
return ("ok", run)
except MafConflictError as exc:
return ("conflict", exc)
results = await asyncio.gather(*[_attempt() for _ in range(8)])
oks = [r for r in results if r[0] == "ok"]
conflicts = [r for r in results if r[0] == "conflict"]
assert len(oks) == 1, f"expected exactly one winner, got {len(oks)}"
assert len(conflicts) == 7
runs = await svc.list_runs(session_id=s["id"], user_id="u1")
active = [r for r in runs if r["status"] in ("pending", "running", "waiting_approval", "waiting_input")]
assert len(active) == 1
assert active[0]["id"] == oks[0][1]["id"]
assert active[0]["status"] == "pending"
refreshed = await svc.get_session(session_id=s["id"], user_id="u1")
assert refreshed["status"] == "running"
@pytest.mark.asyncio
async def test_create_run_success_session_running_run_pending(store):
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
assert run["status"] == "pending"
refreshed = await svc.get_session(session_id=s["id"], user_id="u1")
assert refreshed["status"] == "running"
assert len(await svc.list_runs(session_id=s["id"], user_id="u1")) == 1
@pytest.mark.asyncio
async def test_concurrent_create_run_second_launch_after_cancel(store):
"""After the winning run is cancelled, a fresh launch succeeds."""
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
results = await asyncio.gather(
*[svc.create_run(session_id=s["id"], user_id="u1") for _ in range(3)],
return_exceptions=True,
)
winners = [r for r in results if not isinstance(r, Exception)]
assert len(winners) == 1
await svc.cancel_run(run_id=winners[0]["id"], user_id="u1")
run2 = await svc.create_run(session_id=s["id"], user_id="u1")
assert run2["status"] == "pending"
refreshed = await svc.get_session(session_id=s["id"], user_id="u1")
assert refreshed["status"] == "running"
@pytest.mark.asyncio
async def test_flush_failure_rolls_back_session_and_no_run(store):
"""If flush() fails after the claim UPDATE, the whole transaction rolls back:
session stays at its prior status and no run row is persisted.
"""
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
prior_status = s["status"]
assert prior_status == "draft"
async def boom_flush(self, *args, **kwargs):
raise RuntimeError("simulated flush failure")
with patch.object(AsyncSession, "flush", boom_flush):
with pytest.raises(RuntimeError, match="simulated flush failure"):
await svc.create_run(session_id=s["id"], user_id="u1")
refreshed = await svc.get_session(session_id=s["id"], user_id="u1")
assert refreshed["status"] == prior_status
assert refreshed["status"] != "starting"
assert await svc.list_runs(session_id=s["id"], user_id="u1") == []
# A subsequent healthy launch still works.
run = await svc.create_run(session_id=s["id"], user_id="u1")
assert run["status"] == "pending"
@pytest.mark.asyncio
async def test_pre_commit_failure_leaves_no_starting_residue(store):
"""If an error occurs after flush but before commit, the transaction rolls
back: session must not residual ``starting`` / ``running``, and no run row.
"""
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
real_flush = AsyncSession.flush
async def flush_then_boom(self, *args, **kwargs):
await real_flush(self, *args, **kwargs)
raise RuntimeError("simulated post-flush pre-commit failure")
with patch.object(AsyncSession, "flush", flush_then_boom):
with pytest.raises(RuntimeError, match="simulated post-flush pre-commit failure"):
await svc.create_run(session_id=s["id"], user_id="u1")
refreshed = await svc.get_session(session_id=s["id"], user_id="u1")
assert refreshed["status"] == "draft"
assert refreshed["status"] != "starting"
assert await svc.list_runs(session_id=s["id"], user_id="u1") == []
# ── P1: concurrent approval decision ──────────────────────────────────────
@pytest.mark.asyncio
async def test_concurrent_approval_decision_only_one_wins(store):
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
appr = await store.approvals.create_approval(
{"session_id": s["id"], "run_id": run["id"], "summary": "高风险写入", "status": "pending"}
)
async def _decide(decision: str, who: str) -> tuple[str, object]:
try:
decided = await svc.decide_approval(approval_id=appr["id"], decision=decision, user_id=who)
return ("ok", decided)
except MafConflictError as exc:
return ("conflict", exc)
results = await asyncio.gather(
*[_decide("approved", "u1") for _ in range(4)],
*[_decide("rejected", "u1") for _ in range(4)],
)
oks = [r for r in results if r[0] == "ok"]
conflicts = [r for r in results if r[0] == "conflict"]
assert len(oks) == 1, f"expected exactly one winning decision, got {len(oks)}"
assert len(conflicts) == 7
winner = oks[0][1]
final = await store.approvals.get_approval(appr["id"])
assert final["status"] == winner["status"]
assert final["decided_by"] == winner["decided_by"] == "u1"
assert final["decided_at"] == winner["decided_at"]
assert final["decided_at"] is not None
@pytest.mark.asyncio
async def test_approval_decision_cannot_be_overwritten(store):
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
appr = await store.approvals.create_approval(
{"session_id": s["id"], "run_id": run["id"], "summary": "x", "status": "pending"}
)
first = await svc.decide_approval(approval_id=appr["id"], decision="approved", user_id="u1")
with pytest.raises(MafConflictError):
await svc.decide_approval(approval_id=appr["id"], decision="rejected", user_id="u1")
final = await store.approvals.get_approval(appr["id"])
assert final["status"] == first["status"] == "approved"
assert final["decided_by"] == "u1"
# ── P1: cancel_run single-transaction atomicity ───────────────────────────
@pytest.mark.asyncio
async def test_cancel_run_flush_failure_rolls_back_run_and_session(store):
"""If the cancel transaction's flush fails, run stays pending and session
stays running — nothing is half-committed."""
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
assert run["status"] == "pending"
async def boom_flush(self, *args, **kwargs):
raise RuntimeError("simulated cancel flush failure")
with patch.object(AsyncSession, "flush", boom_flush):
with pytest.raises(RuntimeError, match="simulated cancel flush failure"):
await svc.cancel_run(run_id=run["id"], user_id="u1")
refreshed_run = await svc.get_run(run_id=run["id"], user_id="u1")
assert refreshed_run["status"] == "pending"
assert refreshed_run["finished_at"] is None
refreshed_session = await svc.get_session(session_id=s["id"], user_id="u1")
assert refreshed_session["status"] == "running"
# A healthy cancel afterwards succeeds.
cancelled = await svc.cancel_run(run_id=run["id"], user_id="u1")
assert cancelled["status"] == "cancelled"
@pytest.mark.asyncio
async def test_cancel_repairs_historical_inconsistent_session(store):
"""Historical half-completed data: run=cancelled but session still running.
Re-cancelling returns the cancelled run and repairs the session."""
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
# Manufacture the inconsistency: run cancelled, session left running.
await store.runs.set_status(run["id"], "cancelled")
await store.sessions.set_status(s["id"], "running")
assert (await svc.get_session(session_id=s["id"], user_id="u1"))["status"] == "running"
result = await svc.cancel_run(run_id=run["id"], user_id="u1")
assert result["status"] == "cancelled"
assert (await svc.get_session(session_id=s["id"], user_id="u1"))["status"] == "cancelled"
@pytest.mark.asyncio
async def test_cancel_terminal_run_does_not_change_run(store):
"""A completed/failed run is not re-cancelled; its status is returned as-is."""
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
await store.runs.set_status(run["id"], "completed")
await store.sessions.set_status(s["id"], "completed")
result = await svc.cancel_run(run_id=run["id"], user_id="u1")
assert result["status"] == "completed"
assert (await svc.get_run(run_id=run["id"], user_id="u1"))["status"] == "completed"
@pytest.mark.asyncio
async def test_concurrent_cancel_same_run_consistent(store):
"""Two concurrent cancels of the same run → run and session both cancelled,
no exceptions, no inconsistent state."""
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
results = await asyncio.gather(
*[svc.cancel_run(run_id=run["id"], user_id="u1") for _ in range(2)],
return_exceptions=True,
)
assert not any(isinstance(r, Exception) for r in results), results
assert all(r["status"] == "cancelled" for r in results)
assert (await svc.get_run(run_id=run["id"], user_id="u1"))["status"] == "cancelled"
assert (await svc.get_session(session_id=s["id"], user_id="u1"))["status"] == "cancelled"
# ── P2: pending run time semantics ────────────────────────────────────────
@pytest.mark.asyncio
async def test_pending_run_has_no_started_at(store):
"""A freshly-created pending run is queued, not started: started_at is None."""
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
assert run["status"] == "pending"
assert run["started_at"] is None
@pytest.mark.asyncio
async def test_cancel_pending_run_sets_finished_at(store):
"""Cancelling a pending run sets finished_at (terminal) while started_at
stays None (it never actually ran)."""
svc = _svc(store)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
cancelled = await svc.cancel_run(run_id=run["id"], user_id="u1")
assert cancelled["status"] == "cancelled"
assert cancelled["finished_at"] is not None
assert cancelled["started_at"] is None
@@ -0,0 +1,59 @@
"""P2 deployment-config enum / range validation for MafRoundtableConfig.
Config and API schemas share ``MafMode`` / ``MafWritePolicy`` from
``deerflow.config.maf_roundtable_config`` — invalid deployment values must fail
at load time, not silently become runtime bugs.
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from deerflow.config.maf_roundtable_config import MafRoundtableConfig
def test_valid_config_loads():
cfg = MafRoundtableConfig(
enabled=True,
default_mode="group_chat",
default_write_policy="auto",
default_max_rounds=16,
admin_diagnostic_access=False,
)
assert cfg.default_mode == "group_chat"
assert cfg.default_write_policy == "auto"
assert cfg.default_max_rounds == 16
def test_default_config_loads():
cfg = MafRoundtableConfig()
assert cfg.enabled is False
assert cfg.default_mode == "structured"
assert cfg.default_write_policy == "confirm_high_risk"
assert cfg.default_max_rounds == 8
def test_invalid_default_mode_rejected():
with pytest.raises(ValidationError):
MafRoundtableConfig(default_mode="wrong")
def test_invalid_default_write_policy_rejected():
with pytest.raises(ValidationError):
MafRoundtableConfig(default_write_policy="wrong")
def test_default_max_rounds_zero_rejected():
with pytest.raises(ValidationError):
MafRoundtableConfig(default_max_rounds=0)
def test_default_max_rounds_above_cap_rejected():
with pytest.raises(ValidationError):
MafRoundtableConfig(default_max_rounds=65)
def test_allow_mock_events_field_removed():
"""allow_mock_events must not be a config field anymore."""
assert "allow_mock_events" not in MafRoundtableConfig.model_fields
@@ -0,0 +1,474 @@
"""P2 HTTP route tests for the MAF roundtable API.
Covers the full HTTP status code matrix:
- **401 Unauthorized**: missing / invalid auth
- **403 Forbidden**: admin trying to write another user's resource
- **404 Not Found**: non-existent session / run / action / approval
- **409 Conflict**: concurrent run launch, already-decided approval
- **422 Unprocessable Entity**: invalid enum values (mode, write_policy, role)
- **503 Service Unavailable**: feature disabled, mock events disabled
Also verifies that identity comes from ``request.state.user`` (stamped by auth
middleware), not from body parameters — a client cannot forge ``user_id`` in the
request body to impersonate another user.
"""
from __future__ import annotations
import pytest
import pytest_asyncio
from fastapi import FastAPI, Request
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway.routers.maf_roundtable import router as maf_router
from app.maf_roundtable import MafRoundtableService
from deerflow.config.maf_roundtable_config import MafRoundtableConfig
from deerflow.persistence.base import Base
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
class FakeUser:
"""Minimal user object for testing (mimics the real User model's shape)."""
def __init__(self, user_id: str, is_admin: bool = False):
self.id = user_id
self.system_role = "admin" if is_admin else "user"
@pytest_asyncio.fixture
async def app_and_client(tmp_path):
"""Build a minimal FastAPI app with the MAF router + a fake auth middleware."""
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'maf_http.db'}")
async with engine.begin() as conn:
import deerflow.persistence.maf_roundtable.model # noqa: F401
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
store = make_maf_roundtable_store(sf)
cfg = MafRoundtableConfig(enabled=True, admin_diagnostic_access=True)
svc = MafRoundtableService(store, enabled=True, admin_diagnostic_access=True, config=cfg)
app = FastAPI()
app.state.maf_roundtable_service = svc
app.include_router(maf_router)
# Fake auth middleware: reads X-Test-User / X-Test-Role headers and stamps
# request.state.user. No header → 401 (unauthenticated).
@app.middleware("http")
async def fake_auth(request: Request, call_next):
user_id = request.headers.get("X-Test-User")
if user_id is None:
from starlette.responses import JSONResponse
return JSONResponse(status_code=401, content={"detail": "Authentication required"})
is_admin = request.headers.get("X-Test-Role") == "admin"
request.state.user = FakeUser(user_id, is_admin)
return await call_next(request)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield app, client, store
await engine.dispose()
# ── 401 Unauthorized ──────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_401_missing_auth(app_and_client):
_, client, _ = app_and_client
resp = await client.get("/api/maf-roundtable/sessions")
assert resp.status_code == 401
# ── 403 Forbidden (admin write cross-user) ────────────────────────────────
@pytest.mark.asyncio
async def test_403_admin_cannot_update_other_user_session(app_and_client):
_, client, _ = app_and_client
# alice creates a session
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t"},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 200
session_id = resp.json()["id"]
# admin tries to update it → 403
resp = await client.put(
f"/api/maf-roundtable/sessions/{session_id}",
json={"title": "hijack"},
headers={"X-Test-User": "admin-1", "X-Test-Role": "admin"},
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_403_admin_cannot_delete_other_user_session(app_and_client):
_, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t"},
headers={"X-Test-User": "alice"},
)
session_id = resp.json()["id"]
resp = await client.delete(
f"/api/maf-roundtable/sessions/{session_id}",
headers={"X-Test-User": "admin-1", "X-Test-Role": "admin"},
)
assert resp.status_code == 403
# ── 404 Not Found ─────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_404_nonexistent_session(app_and_client):
_, client, _ = app_and_client
resp = await client.get(
"/api/maf-roundtable/sessions/nonexistent-id",
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_404_other_user_session_not_leaked(app_and_client):
_, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t"},
headers={"X-Test-User": "alice"},
)
session_id = resp.json()["id"]
# bob (non-admin) tries to read alice's session → 404 (not 403, so existence is not leaked)
resp = await client.get(
f"/api/maf-roundtable/sessions/{session_id}",
headers={"X-Test-User": "bob"},
)
assert resp.status_code == 404
# ── 409 Conflict ──────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_409_second_run_launch_conflicts(app_and_client):
_, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t"},
headers={"X-Test-User": "alice"},
)
session_id = resp.json()["id"]
# First run launch succeeds
resp = await client.post(
f"/api/maf-roundtable/sessions/{session_id}/runs",
json={},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 200
# Second run launch → 409
resp = await client.post(
f"/api/maf-roundtable/sessions/{session_id}/runs",
json={},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 409
@pytest.mark.asyncio
async def test_409_approval_already_decided(app_and_client):
_, client, store = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t"},
headers={"X-Test-User": "alice"},
)
session_id = resp.json()["id"]
resp = await client.post(
f"/api/maf-roundtable/sessions/{session_id}/runs",
json={},
headers={"X-Test-User": "alice"},
)
run_id = resp.json()["id"]
# Create an approval directly via the store
appr = await store.approvals.create_approval(
{"session_id": session_id, "run_id": run_id, "summary": "x", "status": "pending"}
)
# First decision succeeds
resp = await client.post(
f"/api/maf-roundtable/approvals/{appr['id']}/decision",
json={"decision": "approved"},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 200
# Second decision → 409
resp = await client.post(
f"/api/maf-roundtable/approvals/{appr['id']}/decision",
json={"decision": "rejected"},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 409
# ── 422 Unprocessable Entity (invalid enums) ──────────────────────────────
@pytest.mark.asyncio
async def test_422_invalid_mode(app_and_client):
_, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t", "mode": "invalid_mode"},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_422_invalid_write_policy(app_and_client):
_, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t", "write_policy": "invalid_policy"},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_422_invalid_participant_role(app_and_client):
_, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t", "participants": [{"agent_id": "a1", "role": "invalid_role"}]},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 422
# ── 503 Service Unavailable ───────────────────────────────────────────────
@pytest.mark.asyncio
async def test_503_feature_disabled(tmp_path):
"""When the feature flag is off, write paths return 503."""
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'maf_disabled.db'}")
async with engine.begin() as conn:
import deerflow.persistence.maf_roundtable.model # noqa: F401
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
store = make_maf_roundtable_store(sf)
cfg = MafRoundtableConfig(enabled=False)
svc = MafRoundtableService(store, enabled=False, admin_diagnostic_access=True, config=cfg)
app = FastAPI()
app.state.maf_roundtable_service = svc
app.include_router(maf_router)
@app.middleware("http")
async def fake_auth(request: Request, call_next):
request.state.user = FakeUser("alice", False)
return await call_next(request)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post("/api/maf-roundtable/sessions", json={"task_text": "t"})
assert resp.status_code == 503
await engine.dispose()
@pytest.mark.asyncio
async def test_404_post_events_endpoint_removed(app_and_client):
"""POST /runs/{id}/events is deliberately absent — no public mock write path."""
_, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t"},
headers={"X-Test-User": "alice"},
)
session_id = resp.json()["id"]
resp = await client.post(
f"/api/maf-roundtable/sessions/{session_id}/runs",
json={},
headers={"X-Test-User": "alice"},
)
run_id = resp.json()["id"]
resp = await client.post(
f"/api/maf-roundtable/runs/{run_id}/events",
json={"event_type": "manager.decision"},
headers={"X-Test-User": "alice"},
)
# FastAPI returns 405 when GET exists on the same path but POST is not
# registered; either 404 or 405 means no public write surface.
assert resp.status_code in (404, 405)
@pytest.mark.asyncio
async def test_internal_append_event_still_writes(app_and_client):
"""service.append_event remains available for the future MAF executor."""
app, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t"},
headers={"X-Test-User": "alice"},
)
session_id = resp.json()["id"]
resp = await client.post(
f"/api/maf-roundtable/sessions/{session_id}/runs",
json={},
headers={"X-Test-User": "alice"},
)
run_id = resp.json()["id"]
svc = app.state.maf_roundtable_service
event = await svc.append_event(run_id=run_id, user_id="alice", event_type="run.started")
assert event["seq"] == 1
assert event["event_type"] == "run.started"
# Public GET still works for the written event.
resp = await client.get(
f"/api/maf-roundtable/runs/{run_id}/events",
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 200
assert len(resp.json()["events"]) == 1
# ── PUT explicit-null rejection ───────────────────────────────────────────
@pytest.mark.asyncio
async def test_422_put_mode_null(app_and_client):
_, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t"},
headers={"X-Test-User": "alice"},
)
session_id = resp.json()["id"]
resp = await client.put(
f"/api/maf-roundtable/sessions/{session_id}",
json={"mode": None},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_422_put_title_null(app_and_client):
_, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t", "title": "keep"},
headers={"X-Test-User": "alice"},
)
session_id = resp.json()["id"]
resp = await client.put(
f"/api/maf-roundtable/sessions/{session_id}",
json={"title": None},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_422_put_max_rounds_null(app_and_client):
_, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t"},
headers={"X-Test-User": "alice"},
)
session_id = resp.json()["id"]
resp = await client.put(
f"/api/maf-roundtable/sessions/{session_id}",
json={"max_rounds": None},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_200_put_task_payload_null_clears(app_and_client):
_, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t", "task_payload": {"k": 1}},
headers={"X-Test-User": "alice"},
)
session_id = resp.json()["id"]
assert resp.json()["task_payload"] == {"k": 1}
resp = await client.put(
f"/api/maf-roundtable/sessions/{session_id}",
json={"task_payload": None},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 200
assert resp.json()["task_payload"] is None
@pytest.mark.asyncio
async def test_200_put_model_name_null_clears(app_and_client):
_, client, _ = app_and_client
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t", "model_name": "glm-5"},
headers={"X-Test-User": "alice"},
)
session_id = resp.json()["id"]
assert resp.json()["model_name"] == "glm-5"
resp = await client.put(
f"/api/maf-roundtable/sessions/{session_id}",
json={"model_name": None},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 200
assert resp.json()["model_name"] is None
# ── Identity forgery prevention ───────────────────────────────────────────
@pytest.mark.asyncio
async def test_identity_from_request_state_not_body(app_and_client):
"""A client cannot forge user_id in the request body to impersonate another user.
The service reads user_id from request.state.user (stamped by auth middleware),
not from the request body. Even if a malicious client includes a user_id field
in the JSON body, it is ignored.
"""
_, client, _ = app_and_client
# alice creates a session
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t"},
headers={"X-Test-User": "alice"},
)
assert resp.status_code == 200
session_id = resp.json()["id"]
assert resp.json()["user_id"] == "alice"
# bob tries to create a session with a forged user_id in the body
resp = await client.post(
"/api/maf-roundtable/sessions",
json={"task_text": "t", "user_id": "alice"}, # forged field
headers={"X-Test-User": "bob"},
)
assert resp.status_code == 200
# The session is owned by bob (from request.state.user), not alice (from body)
assert resp.json()["user_id"] == "bob"
# bob cannot read alice's session
resp = await client.get(
f"/api/maf-roundtable/sessions/{session_id}",
headers={"X-Test-User": "bob"},
)
assert resp.status_code == 404
@@ -0,0 +1,205 @@
"""User-isolation + table-isolation tests for the MAF roundtable experiment (MAF-01-07).
These tests focus on the security boundary: a non-owner cannot read or mutate
another user's session / run / action / approval, and the six MAF tables have
no foreign-key relationship to the legacy ``roundtable_*`` tables (the
experiment is fully isolated — see implementation plan §2.3 "不改原有的工程定义").
"""
from __future__ import annotations
import pytest
import pytest_asyncio
from sqlalchemy import inspect
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.maf_roundtable.service import MafNotFoundError, MafRoundtableService
from deerflow.persistence.base import Base
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
from deerflow.persistence.maf_roundtable.model import (
MafRoundtableActionRow,
MafRoundtableApprovalRow,
MafRoundtableEventRow,
MafRoundtableParticipantRow,
MafRoundtableRunRow,
MafRoundtableSessionRow,
)
@pytest_asyncio.fixture
async def store(tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'maf_iso.db'}")
async with engine.begin() as conn:
import deerflow.persistence.maf_roundtable.model # noqa: F401
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
yield make_maf_roundtable_store(sf), engine
await engine.dispose()
def _svc(store, *, enabled=True, admin_diagnostic_access=True):
return MafRoundtableService(store, enabled=enabled, admin_diagnostic_access=admin_diagnostic_access)
# ── user isolation: non-owner cannot read ────────────────────────────────
@pytest.mark.asyncio
async def test_non_owner_cannot_read_session(store):
svc, _ = store
s = await svc.sessions.create_session({"user_id": "u1", "task_text": "secret"})
fetched = await svc.sessions.get_session(s["id"], user_id="u2")
assert fetched is None # owner scope returns None, not the row
@pytest.mark.asyncio
async def test_non_owner_cannot_update_session(store):
svc, _ = store
s = await svc.sessions.create_session({"user_id": "u1", "task_text": "t"})
updated = await svc.sessions.update_session(s["id"], user_id="u2", fields={"title": "hijack"})
assert updated is None
refreshed = await svc.sessions.get_session(s["id"], user_id="u1")
assert refreshed["title"] == "" # unchanged
@pytest.mark.asyncio
async def test_non_owner_cannot_delete_session(store):
svc, _ = store
s = await svc.sessions.create_session({"user_id": "u1", "task_text": "t"})
ok = await svc.sessions.delete_session(s["id"], user_id="u2")
assert ok is False
refreshed = await svc.sessions.get_session(s["id"], user_id="u1")
assert refreshed is not None # still there
@pytest.mark.asyncio
async def test_service_non_owner_get_raises_not_found(store):
"""At the service layer, an out-of-scope read surfaces as 404 (not 403) so
the existence of another user's session is not leaked (MAF-01-07)."""
svc = _svc(store[0])
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
with pytest.raises(MafNotFoundError):
await svc.get_session(session_id=s["id"], user_id="u2")
@pytest.mark.asyncio
async def test_non_owner_cannot_read_run_via_service(store):
svc = _svc(store[0])
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
with pytest.raises(MafNotFoundError):
await svc.get_run(run_id=run["id"], user_id="u2")
@pytest.mark.asyncio
async def test_non_owner_cannot_append_event(store):
svc = _svc(store[0])
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
with pytest.raises(MafNotFoundError):
await svc.append_event(run_id=run["id"], user_id="u2", event_type="run.started")
@pytest.mark.asyncio
async def test_non_owner_cannot_read_action(store):
svc = _svc(store[0])
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
run = await svc.create_run(session_id=s["id"], user_id="u1")
action = await store[0].actions.create_action(
{
"session_id": s["id"],
"run_id": run["id"],
"tool_name": "update_task",
"idempotency_key": "k1",
"status": "pending",
}
)
with pytest.raises(MafNotFoundError):
await svc.get_action(action_id=action["id"], user_id="u2")
# ── admin diagnostic access ───────────────────────────────────────────────
@pytest.mark.asyncio
async def test_admin_diagnostic_access_bypasses_owner_filter(store):
svc = _svc(store[0], admin_diagnostic_access=True)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
fetched = await svc.get_session(session_id=s["id"], user_id="admin-1", is_admin=True)
assert fetched["id"] == s["id"]
rows, total = await svc.list_sessions(user_id="admin-1", is_admin=True)
assert total >= 1
@pytest.mark.asyncio
async def test_admin_diagnostic_access_off_keeps_owner_scope(store):
svc = _svc(store[0], admin_diagnostic_access=False)
s = await svc.create_session(user_id="u1", body={"task_text": "t"})
with pytest.raises(MafNotFoundError):
await svc.get_session(session_id=s["id"], user_id="admin-1", is_admin=True)
# ── table isolation: no FK to legacy roundtable_* ─────────────────────────
@pytest.mark.asyncio
async def test_maf_tables_isolated_from_legacy_roundtable(store):
"""The six MAF tables must exist and none of their FKs may target a legacy
roundtable_* table (MAF-01-02 / 退出标准: MAF 表和旧 roundtable 表完全无关联外键)."""
_, engine = store
async with engine.connect() as conn:
table_names = await conn.run_sync(lambda sync_conn: set(inspect(sync_conn).get_table_names()))
maf_tables = {n for n in table_names if n.startswith("maf_roundtable_")}
assert maf_tables == {
"maf_roundtable_sessions",
"maf_roundtable_participants",
"maf_roundtable_runs",
"maf_roundtable_events",
"maf_roundtable_actions",
"maf_roundtable_approvals",
}
# Every FK defined on an MAF model must reference another maf_roundtable_* table.
maf_models = [
MafRoundtableSessionRow,
MafRoundtableParticipantRow,
MafRoundtableRunRow,
MafRoundtableEventRow,
MafRoundtableActionRow,
MafRoundtableApprovalRow,
]
for model in maf_models:
for fk in model.__table__.foreign_keys:
target_table = fk.column.table.name
assert target_table.startswith("maf_roundtable_"), (
f"{model.__tablename__} FK targets non-MAF table {target_table}"
)
# ── feature flag (MAF-01-04) ──────────────────────────────────────────────
@pytest.mark.asyncio
async def test_disabled_feature_rejects_writes(store):
from app.maf_roundtable.service import MafDisabledError
svc = _svc(store[0], enabled=False)
with pytest.raises(MafDisabledError):
await svc.create_session(user_id="u1", body={"task_text": "t"})
@pytest.mark.asyncio
async def test_disabled_feature_keeps_reads_open(store):
from app.maf_roundtable.service import MafDisabledError
svc_on = _svc(store[0], enabled=True)
s = await svc_on.create_session(user_id="u1", body={"task_text": "t"})
svc_off = _svc(store[0], enabled=False)
# Reads do not raise — admin can review history while the feature is off.
fetched = await svc_off.get_session(session_id=s["id"], user_id="u1")
assert fetched["id"] == s["id"]
# But a write through the disabled service still raises.
with pytest.raises(MafDisabledError):
await svc_off.update_session(session_id=s["id"], user_id="u1", fields={"title": "x"})