From e690475e6e2044023084907b78802cfa8221dc6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sat, 1 Aug 2026 01:07:05 +0800 Subject: [PATCH] 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 --- ...t-agent-framework-多智能体会商-实施计划.md | 1112 +++++++++++++++++ offline-backend-20260512/backend/CLAUDE.md | 1 + .../backend/app/gateway/app.py | 107 +- .../backend/app/gateway/deps.py | 118 +- .../app/gateway/routers/maf_roundtable.py | 313 +++++ .../backend/app/maf_roundtable/__init__.py | 69 + .../backend/app/maf_roundtable/schemas.py | 257 ++++ .../backend/app/maf_roundtable/service.py | 390 ++++++ offline-backend-20260512/backend/config.yaml | 15 +- .../harness/deerflow/config/app_config.py | 14 +- .../deerflow/config/maf_roundtable_config.py | 72 ++ .../persistence/maf_roundtable/__init__.py | 72 ++ .../persistence/maf_roundtable/actions.py | 137 ++ .../persistence/maf_roundtable/approvals.py | 132 ++ .../persistence/maf_roundtable/events.py | 168 +++ .../persistence/maf_roundtable/model.py | 290 +++++ .../maf_roundtable/participants.py | 122 ++ .../persistence/maf_roundtable/runs.py | 129 ++ .../persistence/maf_roundtable/sessions.py | 201 +++ .../persistence/maf_roundtable/store.py | 192 +++ .../deerflow/persistence/models/__init__.py | 14 + .../test_maf_roundtable_admin_boundary.py | 206 +++ .../backend/tests/test_maf_roundtable_api.py | 282 +++++ .../tests/test_maf_roundtable_concurrency.py | 319 +++++ .../tests/test_maf_roundtable_config.py | 59 + .../backend/tests/test_maf_roundtable_http.py | 474 +++++++ .../tests/test_maf_roundtable_isolation.py | 205 +++ 27 files changed, 5369 insertions(+), 101 deletions(-) create mode 100644 frontend-web/docs/microsoft-agent-framework-多智能体会商-实施计划.md create mode 100644 offline-backend-20260512/backend/app/gateway/routers/maf_roundtable.py create mode 100644 offline-backend-20260512/backend/app/maf_roundtable/__init__.py create mode 100644 offline-backend-20260512/backend/app/maf_roundtable/schemas.py create mode 100644 offline-backend-20260512/backend/app/maf_roundtable/service.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/config/maf_roundtable_config.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/actions.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/approvals.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/events.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/participants.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/runs.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/sessions.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/store.py create mode 100644 offline-backend-20260512/backend/tests/test_maf_roundtable_admin_boundary.py create mode 100644 offline-backend-20260512/backend/tests/test_maf_roundtable_api.py create mode 100644 offline-backend-20260512/backend/tests/test_maf_roundtable_concurrency.py create mode 100644 offline-backend-20260512/backend/tests/test_maf_roundtable_config.py create mode 100644 offline-backend-20260512/backend/tests/test_maf_roundtable_http.py create mode 100644 offline-backend-20260512/backend/tests/test_maf_roundtable_isolation.py diff --git a/frontend-web/docs/microsoft-agent-framework-多智能体会商-实施计划.md b/frontend-web/docs/microsoft-agent-framework-多智能体会商-实施计划.md new file mode 100644 index 00000000..3202a5cc --- /dev/null +++ b/frontend-web/docs/microsoft-agent-framework-多智能体会商-实施计划.md @@ -0,0 +1,1112 @@ +# Microsoft Agent Framework 多智能体会商对照页实施计划 + +> 文档状态:实施中;阶段 1(独立后端骨架与持久化)已验收通过 +> 更新时间:2026-08-01 +> 目标版本:实验性独立功能,不替换现有多智能体会商 +> 建议页面名称:`MAF 会商(实验)` + +## 0. 当前实施状态与前后端隔离决议 + +### 0.1 已完成并验收的阶段 1 + +新的后端骨架已经独立落在 `/api/maf-roundtable` 及 `maf_roundtable_*` 专用表中;会话、运行、事件、审批、用户隔离、启动/取消原子事务与配置校验均不复用旧会商的持久化状态。当前验收证据为:78 项 MAF 后端回归测试通过,且 Harness → App 导入边界检查通过。 + +阶段 1 完成并不表示功能已对用户开放:`maf_roundtable.enabled` 继续默认关闭,直到后续 MAF 编排和新前端页面均完成验收。 + +### 0.2 新前端页面的强制隔离原则 + +后续前端必须新建一套 MAF 会商页面,仅调用新后台 `/api/maf-roundtable/*`;不得把新页面接到旧会商 API,也不得为了复用 UI 而修改旧会商的状态机、草稿、运行记录或路由行为。 + +允许复用通用 UI 基础组件(按钮、Dialog、Tabs、SSE 基础设施、鉴权请求客户端),但以下模块只能作为只读参考,禁止修改其业务代码或直接共用其业务状态: + +- 旧 `RoundtablePlanningPage` 及 `roundtable-planning/**`; +- `/api/roundtable-*`、`/api/multi-agent/*` 及其草稿/作业接口; +- 任务深链会商、岗位会商和现有多智能体会商的页面、store、hooks; +- `roundtable_jobs`、`roundtable_drafts`、`roundtable_task_drafts` 及旧会商事件流。 + +新页面的唯一数据源是 MAF 专用 API 与 MAF 专用表;新旧会商之间如需做效果对照,只能通过显式的只读对照记录或导出的指标关联,不能共享运行状态。 + +## 1. 结论 + +本项目可以接入 Microsoft Agent Framework(以下简称 MAF),新增一套独立的多智能体会商页面,并且保留真实的工具调用、技能调用、MCP、沙箱文件写入和外部系统写入能力。 + +本方案不把 Microsoft Samples 示例应用整体嵌入 DeerFlow,而是使用 MAF 的 Agent/Workflow/Group Chat 能力负责多智能体编排;每个业务席位的实际执行优先复用现有 DeerFlow Agent Runtime。这样能够同时满足以下目标: + +- 原有会商页面、API、任务、草稿、后台作业和数据库表保持原行为。 +- MAF 页面拥有独立路由、独立 API、独立会话和独立事件记录。 +- 现有 Agent 配置中的工具组、技能、MCP、模型、沙箱和用户权限继续生效。 +- 外部写入不是演示占位,而是正式执行能力。 +- 可以在同模型、同提示词、同任务和同能力条件下比较两种编排方式。 + +## 2. 范围与边界 + +### 2.1 本期必须实现 + +- 新页面:`/page/strategy/qa/maf-roundtable`。 +- 两种 MAF 编排模式: + - `structured`:专家并行分析 → 交叉审查 → 主持人综合。 + - `group_chat`:主持人动态选择下一位发言人,多轮讨论后收敛。 +- 支持从现有 Agent 列表选择主持人和参会席位。 +- 支持现有模型配置,包括 OpenAI 兼容端点。 +- 支持每个席位使用其已有工具组和技能白名单。 +- 支持内置工具、MCP 工具、技能脚本、沙箱产物和外部系统写入。 +- 支持工具过程、技能过程、审批过程、会商发言和最终结果的实时事件流。 +- 支持后台执行、刷新恢复、断线续传、取消和失败重试。 +- 支持查看外部写入结果、调用参数摘要、执行人、执行时间和审计状态。 +- 支持与现有会商的结果指标进行人工或半自动对比。 + +### 2.2 本期不做 + +- 不替换现有 `RoundtablePlanningPage`。 +- 不迁移现有 `roundtable_jobs`、草稿或历史记录。 +- 不把 MAF 依赖放进 `deerflow.*` 核心 Harness 的 Agent 编排层。 +- 不在第一期制作自由拖线式工作流编辑器。 +- 不在生产业务对象上同时执行两套会商的重复写操作;真实写入对照使用 UAT、测试租户或隔离业务命名空间。 + +### 2.3 “不改原有”的工程定义 + +允许在公共入口增加受特性开关保护的路由注册和菜单入口,但不得改变旧功能的状态机、API 请求体、数据库表、任务恢复逻辑或页面交互。 + +以下旧链路必须保持兼容: + +- `/api/roundtable-*` +- `/api/multi-agent/*` +- `roundtable_jobs` +- `roundtable_drafts` +- `roundtable_task_drafts` +- `/page/strategy/qa/multi-agent-roundtable` +- 任务深链下的三个原会商入口 + +### 2.4 新前端与新后台的对接边界 + +新前端页面固定使用独立路由 `/page/strategy/qa/maf-roundtable`,并且只允许请求以下新接口前缀: + +```text +GET /api/maf-roundtable/health +POST /api/maf-roundtable/sessions +GET /api/maf-roundtable/sessions +GET /api/maf-roundtable/sessions/{session_id} +PUT /api/maf-roundtable/sessions/{session_id} +POST /api/maf-roundtable/sessions/{session_id}/runs +GET /api/maf-roundtable/sessions/{session_id}/runs +GET /api/maf-roundtable/runs/{run_id} +POST /api/maf-roundtable/runs/{run_id}/cancel +GET /api/maf-roundtable/runs/{run_id}/events +GET /api/maf-roundtable/runs/{run_id}/actions +GET /api/maf-roundtable/runs/{run_id}/approvals +POST /api/maf-roundtable/approvals/{approval_id}/decision +``` + +其中事件写入没有公开 HTTP 接口;只有后续后端 Workflow 执行器可以通过内部服务写入事件。前端不得使用 mock 事件、不得伪造工具、技能、审批或外部写入事件。 + +## 3. 核心架构决策 + +### 3.1 采用“MAF 编排 + DeerFlow 席位执行” + +MAF 负责: + +- 工作流拓扑。 +- 发言人选择。 +- 轮次与终止条件。 +- 群聊上下文同步。 +- 并行、审查和综合流程。 +- Workflow checkpoint 与恢复。 + +DeerFlow Agent Runtime 负责: + +- 读取 Agent 的 SOUL、模型、工具组和技能白名单。 +- 注入技能说明和运行上下文。 +- 调用内置工具、MCP、ACP、沙箱和子智能体工具。 +- 文件产物生成。 +- 用户身份、记忆、视觉、循环检测、工具错误处理等现有中间件。 +- 外部 API、外部业务系统和其他有副作用工具的实际执行。 + +不建议第一版把 LangChain `BaseTool` 逐个改造成 MAF 原生工具。现有工具中存在 `ToolRuntime`、线程、用户、沙箱和中间件依赖,直接包装容易产生“工具能调用,但身份或沙箱上下文丢失”的隐性问题。 + +第一版应实现一个 `DeerFlowAgentAdapter`:MAF 把一次席位发言交给适配器,适配器通过进程内 ASGI 调用现有 thread/run stream,完整消费 DeerFlow SSE,再转换成统一的 MAF 会商事件。 + +### 3.2 公平对照原则 + +要比较的是“多智能体编排方式”,而不是模型、提示词或工具实现差异。因此同一组对照实验必须冻结: + +- 模型与推理参数。 +- 任务正文、附件和补充材料。 +- 主持人和席位 Agent。 +- 每个 Agent 的 SOUL、技能和工具权限。 +- 最大轮次、单次超时和总预算。 +- 外部写入模式与目标环境。 +- 最终输出结构和验收规则。 + +### 3.3 新功能隔离结构 + +```mermaid +flowchart LR + UI["MAF 会商实验页"] --> API["/api/maf-roundtable"] + API --> SVC["MAF Roundtable Service"] + SVC --> WF["MAF Workflow / GroupChatBuilder"] + WF --> MGR["主持人 Agent"] + WF --> A1["席位 Agent Adapter A"] + WF --> A2["席位 Agent Adapter B"] + WF --> A3["席位 Agent Adapter C"] + A1 --> DF["DeerFlow Thread / Run Stream"] + A2 --> DF + A3 --> DF + DF --> CAP["工具 + 技能 + MCP + 沙箱 + 外部写入"] + SVC --> DB["独立 MAF 会商表"] + SVC --> EVT["事件流与审计"] +``` + +## 4. 运行流程 + +### 4.1 创建会商 + +1. 用户进入 `MAF 会商(实验)`。 +2. 输入任务、选择模型、主持人、参会 Agent 和编排模式。 +3. 前端读取每个 Agent 当前可用的工具组和技能,并展示能力摘要。 +4. 用户选择外部写入策略: + - `auto`:按现有用户权限直接执行所有已授权写操作。 + - `confirm_high_risk`:普通写入自动执行,高风险写入等待确认。 + - `confirm_all_writes`:所有外部写入均等待确认。 +5. 后端创建 MAF session,并冻结本次 `capability_snapshot`。 +6. 后端为主持人和每个席位创建或绑定独立 DeerFlow thread。 + +### 4.2 执行会商 + +1. MAF 生成下一步调度决策。 +2. 被选中的席位收到:用户任务、完整或裁剪后的会商上下文、自己的角色要求和当前阶段目标。 +3. `DeerFlowAgentAdapter` 发起席位 run。 +4. 席位可真实调用工具、技能、MCP、沙箱和外部写入。 +5. 工具与技能事件实时写入 `maf_roundtable_events`,并推送给前端。 +6. 席位完成后,标准化为 `AgentResponse` 返回 MAF。 +7. MAF 决定下一席位、进入审查、请求用户确认或结束会商。 +8. 主持人生成结构化最终结论和执行记录。 + +### 4.3 暂停与恢复 + +出现以下情况时进入 `waiting_approval` 或 `waiting_input`: + +- 工具声明需要审批。 +- 外部写入策略要求审批。 +- Agent 主动请求澄清。 +- MAF 工作流触发人工参与节点。 + +恢复时必须从持久化 checkpoint 和最后一个事件序号继续,不允许重新执行已经成功的外部写入。 + +## 5. 编排模式设计 + +### 5.1 固定流程 `structured` + +```mermaid +flowchart LR + T["任务"] --> P["计划与分工"] + P --> E1["专家 A"] + P --> E2["专家 B"] + P --> E3["专家 C"] + E1 --> R["交叉审查"] + E2 --> R + E3 --> R + R --> F["主持人综合"] + F --> O["结论 + 行动 + 产物 + 外部写入记录"] +``` + +实现要求: + +- 专家阶段允许并行。 +- 并行席位各用独立 DeerFlow thread 和独立沙箱。 +- 每个席位允许使用工具、技能和外部写入。 +- 并行写同一个外部对象时由 Action Coordinator 串行化,不通过禁用写工具规避冲突。 +- 专家输出必须通过 Pydantic/JSON Schema 校验。 +- 校验失败允许一次定向修复;再次失败则保留原文并标记 `schema_invalid`,不得无限重试。 +- 审查阶段显式检查证据、冲突、遗漏、工具失败和外部写入结果。 + +### 5.2 动态群聊 `group_chat` + +使用 MAF `GroupChatBuilder`,由主持人或确定性 selector 选择下一位发言人。 + +实现要求: + +- 默认最大 8 个席位回合,可配置但设置硬上限。 +- 默认最多连续选择同一席位 2 次。 +- 终止条件同时包含:主持人判定完成、结构化结果通过校验、最大回合到达、总超时到达。 +- 每个席位在发言前同步会商上下文,但长工具输出只传摘要与引用,不广播完整二进制或超长日志。 +- 工具执行结果必须进入共享事实账本,避免后续席位把未经执行的提议当作已执行事实。 +- 主持人也可以使用其 Agent 配置中已授权的工具和技能,不做框架级禁用。 + +### 5.3 共享事实账本 + +群聊文本不能作为外部动作成功与否的唯一依据。新增 `fact_ledger`,记录: + +- 已确认事实。 +- 信息来源与证据引用。 +- 工具调用结果。 +- 已执行外部写入。 +- 等待确认的动作。 +- 失败或状态不确定的动作。 +- Agent 之间尚未解决的冲突。 + +主持人生成结论时必须以账本状态为准。 + +## 6. 工具、技能与外部写入 + +### 6.1 工具接入 + +工具能力不在 MAF 页面统一裁剪。每个席位继承其现有 Agent 配置和当前登录用户权限,包括: + +- `config.yaml` 配置工具。 +- DeerFlow 内置工具。 +- MCP 工具。 +- ACP Agent 工具。 +- 文件、搜索、浏览器、图像、记忆和计划任务工具。 +- Agent 已获授权的子智能体工具。 + +能力快照至少包含: + +```json +{ + "agent_id": "example-agent", + "model_name": "configured-model", + "tool_groups": ["search", "business-api"], + "tools": [ + {"name": "search", "schema_hash": "...", "source": "builtin"}, + {"name": "update_task", "schema_hash": "...", "source": "mcp"} + ], + "skills": [ + {"name": "example-skill", "version": "...", "content_hash": "..."} + ] +} +``` + +快照用于审计和复现,不代表运行期间复制工具实例。 + +### 6.2 技能接入 + +- 继续使用 Agent 当前技能白名单。 +- 默认 Agent 沿用现有“可见全部已启用技能”的规则。 +- `skill_list`、`skill_view`、`search_skills` 和技能脚本按当前配置生效。 +- 如果启用了技能演进,`skill_manage` 也允许执行,但其写入必须进入 MAF 审计事件。 +- 会商启动时记录技能内容哈希;会商中途技能被修改时,当前 run 继续使用启动快照,新 run 才读取新版本。 +- 技能脚本产生的文件必须归属当前席位 sandbox,并通过产物清单暴露给其他席位。 + +### 6.3 外部写入接入 + +外部写入是正式能力,不使用全局 `excluded_tools` 将其移除。为了在多智能体并发下正确执行,需要增加 Action Coordinator。 + +Action Coordinator 只负责执行一致性,不替代工具: + +- 生成幂等键:`session_id + run_id + agent_id + tool_name + logical_action_id`。 +- 同一业务对象的写操作按 `target_key` 加租约锁并串行执行。 +- 工具声明幂等时才允许自动网络重试。 +- 工具未声明幂等且响应状态不明时,标记 `unknown` 并进入人工核验,不盲目重放。 +- 支持工具返回 `external_object_id`、`before_digest`、`after_digest` 和可选补偿动作。 +- 认证凭据只在服务端解析,不进入模型消息、会商历史或前端事件正文。 +- 日志中的令牌、密码、Cookie、Authorization 和敏感字段必须脱敏。 + +建议写入风险级别: + +| 等级 | 示例 | `auto` | `confirm_high_risk` | +|---|---|---:|---:| +| R0 读取 | 查询、搜索、读取附件 | 自动 | 自动 | +| R1 本地写入 | 席位沙箱文件、草稿 | 自动 | 自动 | +| R2 可逆外部写入 | 更新测试任务、写入可回退记录 | 自动 | 自动 | +| R3 高影响写入 | 发送消息、生产数据变更、发布 | 自动,但要求用户显式选择 `auto` | 等待确认 | +| R4 破坏性操作 | 删除、覆盖且无补偿、批量变更 | 等待确认 | 等待确认 | + +这套分级不禁止外部写入,而是保证它在不同运行策略下可控、可恢复、可审计。 + +### 6.4 沙箱与产物冲突 + +- 一个席位一个 thread,一个 thread 一个沙箱上下文。 +- 席位可正常使用写文件、脚本和产物工具。 +- 不允许多个席位直接共享同一可写目录。 +- 汇总阶段通过只读产物清单读取各席位交付。 +- 最终发布由主持人或指定发布席位写入会商级产物目录。 +- 同名产物使用 `agent_id/revision/path` 做唯一定位。 + +## 7. 后端目录与文件计划 + +建议新增: + +```text +offline-backend-20260512/backend/ +├─ app/ +│ ├─ gateway/routers/ +│ │ └─ maf_roundtable.py +│ └─ maf_roundtable/ +│ ├─ __init__.py +│ ├─ schemas.py +│ ├─ service.py +│ ├─ workflow_factory.py +│ ├─ model_client.py +│ ├─ deerflow_agent_adapter.py +│ ├─ event_normalizer.py +│ ├─ context_builder.py +│ ├─ fact_ledger.py +│ ├─ action_coordinator.py +│ ├─ approval_service.py +│ └─ metrics.py +├─ packages/harness/deerflow/persistence/maf_roundtable/ +│ ├─ __init__.py +│ ├─ model.py +│ └─ sql.py +└─ tests/ + ├─ test_maf_roundtable_api.py + ├─ test_maf_roundtable_workflow.py + ├─ test_maf_agent_adapter.py + ├─ test_maf_action_coordinator.py + ├─ test_maf_approval_resume.py + └─ test_maf_roundtable_isolation.py +``` + +边界规则: + +- `app.maf_roundtable` 可以导入 `deerflow.*`。 +- `deerflow.*` 不得导入 `app.*` 或 MAF SDK。 +- `deerflow.persistence.maf_roundtable` 只保存纯数据模型,不依赖 MAF SDK。 +- MAF SDK 只在 `app.maf_roundtable` 内加载。 +- `app.py` 只增加 router 注册和生命周期初始化,不改变旧 router。 + +## 8. 数据模型 + +### 8.1 `maf_roundtable_sessions` + +建议字段: + +- `id` +- `user_id` +- `title` +- `task_text` +- `task_payload_json` +- `mode`:`structured | group_chat` +- `status`:`draft | running | waiting_approval | waiting_input | completed | failed | cancelled` +- `model_name` +- `reasoning_effort` +- `manager_agent_id` +- `max_rounds` +- `write_policy` +- `capability_snapshot_json` +- `fact_ledger_json` +- `final_result_json` +- `created_at` +- `updated_at` + +### 8.2 `maf_roundtable_participants` + +- `id` +- `session_id` +- `agent_id` +- `display_name` +- `role` +- `sort_order` +- `thread_id` +- `sandbox_id` +- `status` +- `last_turn_index` +- `capability_snapshot_json` + +### 8.3 `maf_roundtable_runs` + +- `id` +- `session_id` +- `status` +- `workflow_version` +- `maf_checkpoint_json` 或 checkpoint 引用 +- `last_event_seq` +- `started_at` +- `finished_at` +- `error_code` +- `error_message_safe` +- `token_usage_json` +- `latency_ms` + +### 8.4 `maf_roundtable_events` + +- `id` +- `session_id` +- `run_id` +- `seq` +- `event_type` +- `agent_id` +- `turn_index` +- `payload_json` +- `created_at` + +唯一约束:`(run_id, seq)`。 + +### 8.5 `maf_roundtable_actions` + +- `id` +- `session_id` +- `run_id` +- `agent_id` +- `tool_name` +- `logical_action_id` +- `idempotency_key` +- `target_key` +- `risk_level` +- `status` +- `arguments_redacted_json` +- `result_redacted_json` +- `external_object_id` +- `approval_id` +- `attempt_count` +- `started_at` +- `finished_at` + +唯一约束:`idempotency_key`。 + +### 8.6 `maf_roundtable_approvals` + +- `id` +- `session_id` +- `run_id` +- `action_id` +- `requested_by_agent_id` +- `status`:`pending | approved | rejected | expired` +- `summary` +- `payload_redacted_json` +- `decided_by` +- `decided_at` +- `expires_at` + +## 9. API 设计 + +统一前缀:`/api/maf-roundtable`。 + +### 9.0 契约状态与前端接入顺序 + +阶段 1 已实现的前端可用基线是会商 CRUD、启动/查询/取消 run、轮询补拉 `GET /runs/{run_id}/events`、动作查询、审批查询与审批决定,准确路径见 2.4。新前端第一版必须只调用这些已落地接口,并以事件轮询补拉作为恢复机制。 + +`/stream`、`/resume`、`/input`、对照接口、动作人工核验/补偿接口属于后续编排阶段契约;在对应后端任务验收前,前端不得预先调用、伪造或为其创建兼容旧会商的降级路径。接口新增必须保持在 `/api/maf-roundtable/*` 前缀内。 + +### 9.1 会话 + +- `POST /sessions`:创建会商。 +- `GET /sessions`:会商列表。 +- `GET /sessions/{session_id}`:会商详情。 +- `PUT /sessions/{session_id}`:修改未运行配置。 +- `DELETE /sessions/{session_id}`:删除草稿或归档记录。 + +### 9.2 执行 + +- `POST /sessions/{session_id}/runs`:启动新 run。 +- `GET /runs/{run_id}`:读取运行状态。 +- `GET /runs/{run_id}/events?after_seq=N`:断线补拉事件。 +- `GET /runs/{run_id}/stream?after_seq=N`:SSE 实时订阅。 +- `POST /runs/{run_id}/cancel`:取消。 +- `POST /runs/{run_id}/resume`:从 checkpoint 恢复。 +- `POST /runs/{run_id}/input`:提交澄清内容。 + +### 9.3 审批和动作 + +- `GET /runs/{run_id}/approvals`:待处理审批。 +- `POST /approvals/{approval_id}/decision`:批准或拒绝。 +- `GET /runs/{run_id}/actions`:外部写入和工具动作清单。 +- `GET /actions/{action_id}`:单次动作详情。 +- `POST /actions/{action_id}/reconcile`:状态不明动作的人工核验。 +- `POST /actions/{action_id}/compensate`:执行工具声明的补偿操作。 + +### 9.4 对比 + +- `POST /comparisons`:绑定一个 MAF run 与一个原会商 run。 +- `GET /comparisons/{comparison_id}`:获取对比指标。 + +### 9.5 统一事件格式 + +```json +{ + "seq": 42, + "event": "tool.completed", + "session_id": "...", + "run_id": "...", + "agent_id": "risk-reviewer", + "turn_index": 3, + "timestamp": "2026-07-31T10:00:00Z", + "payload": { + "tool_name": "update_task", + "action_id": "...", + "status": "succeeded", + "summary": "已更新测试任务状态" + } +} +``` + +首期事件类型至少包括: + +- `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` + +## 10. 前端设计 + +### 10.0 独立前端模块、路由和状态 + +新增目录固定为 `frontend-web/src/maf-roundtable/`,其 API client 只能封装 `/api/maf-roundtable/*`。新页面根组件为 `MafRoundtablePage.tsx`,路由仅新增 `/page/strategy/qa/maf-roundtable`;不得替换、重命名、迁移或修改旧 `multi-agent-roundtable` 路由及其页面组件。 + +入口展示前先读取 `GET /api/maf-roundtable/health`:`enabled=false` 时隐藏入口并禁止创建会商;开启后页面必须明确展示“实验版 / 独立后端”标识。TanStack Query 的 key 使用独立命名空间,例如 `['maf-roundtable', 'sessions', ...]`,不得复用任何旧 `roundtable`、`draft` 或 `job` key。所有页面本地状态、事件 reducer、轮询/补拉游标、取消控制器和历史恢复逻辑也必须归属于 MAF 模块。 + +前端只能消费新后台写入的事件;不得为联调重新开放公开 mock 写接口,也不得在浏览器端伪造工具、技能、审批或外部写入事件。 + +建议新增: + +```text +frontend-web/src/maf-roundtable/ +├─ api/ +│ └─ maf-roundtable.ts +├─ components/ +│ ├─ MafRoundtableSetup.tsx +│ ├─ ParticipantRail.tsx +│ ├─ DiscussionTimeline.tsx +│ ├─ AgentTurnCard.tsx +│ ├─ CapabilitySnapshot.tsx +│ ├─ ToolExecutionCard.tsx +│ ├─ SkillExecutionCard.tsx +│ ├─ ExternalActionCard.tsx +│ ├─ ApprovalCard.tsx +│ ├─ FactLedgerPanel.tsx +│ ├─ FinalDecisionPanel.tsx +│ └─ ComparisonPanel.tsx +├─ hooks/ +│ ├─ useMafRoundtableSession.ts +│ ├─ useMafRoundtableStream.ts +│ └─ useMafRoundtableActions.ts +├─ lib/ +│ ├─ event-reducer.ts +│ ├─ schemas.ts +│ └─ metrics.ts +└─ pages/ + └─ MafRoundtablePage.tsx +``` + +### 10.1 页面布局 + +- 顶部:实验标识、模型、编排模式、轮次上限、写入策略、启动/暂停/取消。 +- 左侧:主持人与参会席位,展示运行状态、当前发言者、工具数和技能数。 +- 中部:统一时间线,依次展示发言、调度、工具、技能、审批和产物事件。 +- 右侧:事实账本、外部动作、产物和运行指标。 +- 底部或结果页:最终结论、共识、分歧、执行动作、失败动作和证据引用。 + +### 10.2 页面必须明确展示的内容 + +- 当前使用的是 MAF,而不是原会商。 +- 当前编排模式及终止条件。 +- 每个席位真实启用的工具和技能。 +- 哪个 Agent 请求了外部写入。 +- 写入是否已执行、等待审批、失败或状态不明。 +- 最终报告中的“建议动作”和“已实际执行动作”必须分栏,避免混淆。 +- 刷新后从数据库事件恢复,不依赖 React 内存保存运行状态。 + +## 11. 分步实施 + +### 11.0 难度定义与分配原则 + +本文使用 D1–D5 标记开发难度。难度表示任务对工程能力的要求,不代表工作量,也不直接等于最终代码评分。 + +| 难度 | 能力要求 | 典型任务 | 适合人员 | +|---|---|---|---| +| D1 入门 | 能按明确样例修改,影响面很小 | 文案、静态展示、简单配置 | 初级开发,在指导下完成 | +| D2 基础 | 能独立完成单模块 CRUD 或普通组件 | 路由、表单、简单 API、类型定义 | 初级偏上或一般开发 | +| D3 中等 | 能处理完整模块、异常和测试 | Store、指标、普通流式接口、SDK Spike | 中级开发 | +| D4 较难 | 涉及并发、状态恢复、跨层契约或安全 | SSE reducer、权限隔离、工作流、离线交付 | 中高级开发 | +| D5 核心 | 涉及框架适配、一致性、不可重复副作用 | Agent Adapter、checkpoint、幂等外部写入 | 高级开发或技术负责人 | + +任务分配时遵守以下规则: + +- 每个任务包只设一名主负责人,其他人通过评审协作,避免责任不清。 +- D4/D5 任务必须提交设计说明、测试证据和失败场景说明,不能只交代码。 +- 不建议让同一位同事同时负责“外部写入实现”和“该模块最终验收”,需要交叉评审。 +- 为了评价个人水平,每个人应使用独立分支和独立提交,避免多人混在一个提交中。 +- 公共入口文件由集成人统一合并;任务开发者不要各自反复修改 `app.py`、`StrategyRoutes.tsx` 和菜单文件。 +- 代码评价同时记录任务难度、独立完成度和返工次数,不能只比较总代码量。 + +### 阶段 0:依赖与模型连通性 Spike + +阶段难度:**D3(中等)** +建议负责人:熟悉 Python 依赖、异步 SDK 和模型网关的后端开发 +前置依赖:无 +主要评价点:能否用最小代码验证关键假设,并识别 SDK/模型/离线环境的真实限制 + +任务: + +- [ ] `MAF-00-01`(D2)确认并固定 MAF Python 包版本,禁止使用浮动版本。 +- [ ] `MAF-00-02`(D2)在独立临时环境安装,不先修改生产锁文件。 +- [ ] `MAF-00-03`(D3)使用当前一个 OpenAI Chat Completions 兼容模型跑通流式单 Agent。 +- [ ] `MAF-00-04`(D3)使用同一模型跑通两个 Agent 的最小 Group Chat。 +- [ ] `MAF-00-05`(D3)验证自定义 `base_url`、API key、超时和代理配置。 +- [ ] `MAF-00-06`(D4)验证 MAF 的 stream event、checkpoint、cancel 和 resume 接口。 +- [ ] `MAF-00-07`(D4)验证实现 `DeerFlowAgentAdapter` 所需的 Agent/ChatClient 扩展接口。 +- [ ] `MAF-00-08`(D3)收集 Linux CPython 3.12 所需 wheel,执行离线安装测试。 + +退出标准: + +- 模型能流式返回。 +- 两个 Agent 能完成至少两轮群聊。 +- 能保存并恢复 checkpoint。 +- 离线环境可以仅使用 wheelhouse 安装。 +- 形成版本锁定记录;如果 SDK API 与 Samples 不一致,以锁定版本实际 API 为准。 + +### 阶段 1:独立后端骨架与持久化 + +阶段难度:**D4(较难)** +建议负责人:熟悉 FastAPI、SQLAlchemy、鉴权和事件流的中高级后端开发 +前置依赖:阶段 0 版本锁定 +主要评价点:数据隔离、事件顺序、权限边界、事务处理和可测试性 + +任务: + +- [ ] `MAF-01-01`(D2)新增 MAF router、service 目录和健康检查。 +- [ ] `MAF-01-02`(D3)新增六张独立数据表及 store。 +- [ ] `MAF-01-03`(D3)在 Gateway lifespan 中初始化 store,并处理初始化失败。 +- [ ] `MAF-01-04`(D2)增加 `maf_roundtable.enabled` 特性开关。 +- [ ] `MAF-01-05`(D3)实现会话 CRUD、参与者配置和 capability snapshot。 +- [ ] `MAF-01-06`(D4)实现单调递增的事件序号、并发写入和事件补拉 API。 +- [ ] `MAF-01-07`(D4)增加用户隔离、越权测试和管理员诊断权限。 + +退出标准: + +- 可以创建、读取、更新、归档 MAF 会话。 +- MAF 表和旧 roundtable 表完全无关联外键。 +- 关闭特性开关后不加载 MAF SDK,不显示入口。 +- `test_harness_boundary.py` 通过。 + +### 阶段 2:DeerFlow Agent Adapter + +阶段难度:**D5(核心)** +建议负责人:熟悉 LangGraph/Agent Runtime、SSE、异步取消和本项目中间件链的高级后端开发 +前置依赖:阶段 0、阶段 1 +主要评价点:是否真正保留工具/技能/身份/沙箱语义,事件转换是否完整,取消和异常是否泄漏资源 + +任务: + +- [ ] `MAF-02-01`(D3)为每个席位创建、绑定和恢复独立 DeerFlow thread。 +- [ ] `MAF-02-02`(D4)使用 `httpx.ASGITransport(app=request.app)` 进程内调用现有 API。 +- [ ] `MAF-02-03`(D4)注入当前用户 Authorization 和有效 user context,并证明不存在跨用户泄漏。 +- [ ] `MAF-02-04`(D3)透传 model、thinking、reasoning effort、agent id、subagent 等运行参数。 +- [ ] `MAF-02-05`(D3)保持 Agent 原工具组和技能规则,不传统一 `excluded_tools`。 +- [ ] `MAF-02-06`(D4)把 MAF 群聊上下文转换成有边界、可裁剪、可追踪的席位输入。 +- [ ] `MAF-02-07`(D5)解析 DeerFlow SSE,正确转换消息、工具、技能、产物、澄清和错误事件。 +- [ ] `MAF-02-08`(D4)将席位最终消息转换为 MAF 标准响应并保留 usage/metadata。 +- [ ] `MAF-02-09`(D5)实现 MAF 取消、超时、客户端断开时的 DeerFlow run 联动取消和资源回收。 + +退出标准: + +- 一个 MAF 席位能真实调用一个只读工具。 +- 能执行技能并生成沙箱产物。 +- 能调用一个测试环境外部写工具。 +- 前端/接口能看到完整事件顺序。 +- 原 Agent 单聊行为不受影响。 + +### 阶段 3:固定流程编排 + +阶段难度:**D4(较难)** +建议负责人:熟悉异步工作流、结构化输出和失败恢复的中高级后端开发 +前置依赖:阶段 2 +主要评价点:并行正确性、结果校验、部分失败处理、checkpoint 可恢复性 + +任务: + +- [ ] `MAF-03-01`(D4)建立任务规划、并行专家、交叉审查和最终综合节点。 +- [ ] `MAF-03-02`(D3)实现席位输出 Pydantic schema 和可读错误信息。 +- [ ] `MAF-03-03`(D3)实现一次定向格式修复及停止条件。 +- [ ] `MAF-03-04`(D4)建立共享事实账本及证据/动作状态约束。 +- [ ] `MAF-03-05`(D4)实现并行 barrier、部分失败、超时和降级综合。 +- [ ] `MAF-03-06`(D4)将 MAF checkpoint 持久化到 run。 +- [ ] `MAF-03-07`(D5)实现服务重启后的恢复,并证明已完成节点不会重复执行。 + +退出标准: + +- 三个席位可并行执行真实工具和技能。 +- 某一席位失败时,其余席位结果不丢失。 +- 主持人能区分建议动作与已执行动作。 +- 重启后不会重放已成功的外部动作。 + +### 阶段 4:动态 Group Chat + +阶段难度:**D5(核心)** +建议负责人:熟悉多智能体编排、上下文工程和终止状态机的高级开发 +前置依赖:阶段 2;建议先完成阶段 3 以复用事实账本和恢复机制 +主要评价点:弱模型条件下能否收敛、上下文是否准确、硬终止是否可靠 + +任务: + +- [ ] `MAF-04-01`(D3)使用 `GroupChatBuilder` 建立主持人和参与席位。 +- [ ] `MAF-04-02`(D4)实现确定性 selector 与主持人 Agent selector 两种选择方式。 +- [ ] `MAF-04-03`(D4)实现最大轮次、连续发言限制、总超时和单席位超时。 +- [ ] `MAF-04-04`(D5)实现完整上下文同步、长工具输出裁剪、引用保真和 token 预算。 +- [ ] `MAF-04-05`(D4)实现主持人完成判定、schema 校验和结构化终稿。 +- [ ] `MAF-04-06`(D3)把主持人调度原因写入 `manager.decision` 事件。 +- [ ] `MAF-04-07`(D5)构造弱模型不结束、错误选人、重复发言等测试并保证工作流可收敛。 + +退出标准: + +- 可以观察每轮由谁发言以及选择原因。 +- 达成结论时提前结束。 +- 弱模型未正确发出结束信号时,硬终止条件仍能停止。 +- 工具结果不会因上下文裁剪而被误判为未执行或执行成功。 + +### 阶段 5:外部写入、审批与一致性 + +阶段难度:**D5(核心且高风险)** +建议负责人:熟悉分布式一致性、幂等、事务、审计和业务安全的高级后端开发/技术负责人 +前置依赖:阶段 1、阶段 2、阶段 3 的 checkpoint 机制 +主要评价点:不得重复写、不得越权、状态不明时不得盲目重试、所有动作必须可追踪 + +任务: + +- [ ] `MAF-05-01`(D3)建立工具风险元数据、默认分类和覆盖规则。 +- [ ] `MAF-05-02`(D3)实现 `auto`、`confirm_high_risk`、`confirm_all_writes` 策略解析。 +- [ ] `MAF-05-03`(D5)实现 Action Coordinator、唯一幂等键、状态机和目标租约锁。 +- [ ] `MAF-05-04`(D5)实现审批暂停、决策、checkpoint 恢复及过期处理。 +- [ ] `MAF-05-05`(D5)实现网络结果未知时的核验流程与可选补偿动作。 +- [ ] `MAF-05-06`(D4)实现凭据、参数、结果和异常日志脱敏,并编写泄漏测试。 +- [ ] `MAF-05-07`(D3)实现审计查询页面所需 API。 +- [ ] `MAF-05-08`(D5)实现并发/重启/重复事件故障注入测试,证明外部写入不会重复。 + +退出标准: + +- 外部写入可以实际成功执行。 +- 重复事件、断线恢复和服务重启不会造成重复写入。 +- 并行 Agent 写同一目标时不会互相覆盖。 +- 高风险写入在相应策略下能暂停和恢复。 +- 每个外部动作可追溯到用户、会商、run、Agent 和工具调用。 + +### 阶段 6:前端工作台 + +阶段难度:**D4(较难)** +建议负责人:熟悉 React、TanStack Query、SSE 状态归并和复杂工作台交互的中高级前端开发 +前置依赖:阶段 1 已验收的 API 基线与正式事件契约;reducer 可使用本地 fixture 开发,但不得依赖公开 mock 事件接口;可与阶段 2–5 并行开发 +主要评价点:事件状态准确、刷新恢复、审批交互、复杂状态下 UI 不误导用户 + + 任务: + + - [ ] `MAF-06-00`(D4)新建 MAF 专属目录、独立 query key、独立路由与 feature-flag 入口;确认旧会商页面、hooks、store、草稿与路由零改动。 + - [ ] `MAF-06-01`(D2)新增路由、菜单、特性开关和实验标识。 +- [ ] `MAF-06-02`(D3)实现创建会商、模型配置和席位选择。 +- [ ] `MAF-06-03`(D2)实现 MAF 两种模式及写入策略切换。 +- [ ] `MAF-06-04`(D5)实现支持断线、补拉、去重和乱序保护的 SSE reducer。 +- [ ] `MAF-06-05`(D3)实现 Agent、工具、技能和外部动作时间线卡片。 +- [ ] `MAF-06-06`(D4)实现审批卡片、澄清输入及并发操作防重。 +- [ ] `MAF-06-07`(D3)实现事实账本、最终结论和产物展示。 +- [ ] `MAF-06-08`(D4)实现取消、恢复、失败重试及对应状态禁用逻辑。 +- [ ] `MAF-06-09`(D4)实现刷新恢复、历史列表和跨页面返回。 +- [ ] `MAF-06-10`(D3)为 reducer 和关键交互编写前端测试。 + +退出标准: + +- 刷新、切页、重新登录后能恢复当前运行。 +- 工具和技能调用过程可见。 +- 外部写入状态不会仅显示在聊天文本中。 +- 原会商页面视觉和交互没有变化。 + +### 阶段 7:对比评测 + +阶段难度:**D3(中等)** +建议负责人:具备数据分析意识、理解 Agent 错误分类的全栈/测试开发/算法工程师 +前置依赖:阶段 3、阶段 4;指标接口可提前开发 +主要评价点:对照是否公平、指标是否可解释、是否能区分模型/工具/编排错误 + +任务: + +- [ ] `MAF-07-01`(D3)建立同任务、同模型、同能力的配置快照与一致性校验。 +- [ ] `MAF-07-02`(D2)支持绑定原会商 run 与 MAF run。 +- [ ] `MAF-07-03`(D3)采集完成率、格式通过率、轮次、时延、token 和工具成功率。 +- [ ] `MAF-07-04`(D4)建立模型错误、工具错误、编排错误、外部系统错误分类器。 +- [ ] `MAF-07-05`(D3)采集错误工具调用、重复写入、错误写入和人工介入次数。 +- [ ] `MAF-07-06`(D2)增加盲评表:正确性、完整性、可执行性、分歧处理和证据质量。 +- [ ] `MAF-07-07`(D3)准备不少于 10 个包含工具、技能和写入的真实业务测试任务。 + +对照环境规则: + +- 两套系统使用同一模型和能力快照。 +- 外部写入使用 UAT、测试租户或不同命名空间。 +- 不允许两套实验同时修改同一个生产业务对象。 +- 对比报告必须标注模型错误、工具错误、编排错误和外部系统错误,不能全部归因于框架。 + +### 阶段 8:测试、离线打包与灰度 + +阶段难度:**D4(较难)** +建议负责人:熟悉自动化测试、Linux 离线部署、故障注入和上线观测的测试开发/DevOps/后端开发 +前置依赖:各功能阶段完成;离线依赖验证可在阶段 0 后提前进行 +主要评价点:测试有效性、环境可复现、是否真正覆盖恢复和副作用故障 + +任务: + +- [ ] `MAF-08-01`(D4)补齐后端单元测试、API 集成测试和并发测试。 +- [ ] `MAF-08-02`(D3)完成前端 typecheck、组件测试和关键 E2E。 +- [ ] `MAF-08-03`(D3)跑现有 roundtable、position-roundtable 和 task deep-link 回归。 +- [ ] `MAF-08-04`(D4)构建 Linux CPython 3.12 多平台离线 wheel 集合。 +- [ ] `MAF-08-05`(D4)使用 `--no-index --find-links` 做全新环境安装与启动验证。 +- [ ] `MAF-08-06`(D3)配置管理员/白名单用户灰度和回退开关。 +- [ ] `MAF-08-07`(D4)建立失败率、恢复率、重复动作数、事件积压和数据库增长观测。 + +退出标准: + +- 旧会商回归全部通过。 +- 离线部署可重复完成。 +- 外部写入重复执行数为 0。 +- 所有写操作都有审计记录。 +- MAF 功能可通过开关独立关闭。 + +## 12. 测试矩阵 + +### 12.1 必测成功场景 + +- 单席位调用内置读取工具。 +- 多席位并行调用不同工具。 +- 席位发现并执行技能。 +- 技能生成文件并被主持人引用。 +- MCP 读取与 MCP 写入。 +- 外部写操作成功并返回业务对象 ID。 +- 主持人调用自身授权工具。 +- 中途澄清后继续。 +- 高风险操作审批后继续。 +- 服务重启后从 checkpoint 继续。 + +### 12.2 必测失败场景 + +- 模型超时、限流和返回空消息。 +- 弱模型不遵守输出 schema。 +- Agent 连续选择自己或无限讨论。 +- 工具参数错误。 +- 工具超时但外部系统可能已成功。 +- MCP 服务断开。 +- 技能脚本失败。 +- 用户在等待审批时取消。 +- SSE 断线、事件重复和乱序。 +- 两个 Agent 同时更新同一目标。 +- 用户 A 读取或恢复用户 B 的 session。 + +### 12.3 回归场景 + +- 原多智能体会商创建、运行、后台恢复和报告。 +- 岗位会商页面。 +- 任务深链三类业务会商。 +- 普通 Agent 单聊的工具、技能和产物。 +- MCP 初始化与工具搜索。 +- Harness → App import firewall。 + +## 13. 验收指标 + +### 13.1 硬性指标 + +- 外部写入重复执行:0。 +- 未授权跨用户读取:0。 +- 未记录的外部写入:0。 +- 旧会商接口契约变化:0。 +- 关闭特性开关后旧功能行为变化:0。 +- 每个 run 均可根据事件记录解释“谁在何时调用了什么”。 + +### 13.2 效果指标 + +- 结构化结果一次通过率。 +- 会商完成率。 +- 工具调用成功率。 +- 技能任务完成率。 +- 外部写入正确率。 +- 人工澄清次数。 +- 每任务总轮次、总时延和 token。 +- 最终结论中无依据断言数量。 +- 盲评胜率和评审一致性。 + +不预先设定“MAF 必须胜出”。如果 MAF 只在特定任务或特定模型上更好,应保留两种方案并按业务类型选择。 + +## 14. 风险与处理 + +| 风险 | 影响 | 处理 | +|---|---|---| +| MAF SDK API 仍快速变化 | 示例与实际版本不一致 | 阶段 0 固定版本;集中封装 SDK;业务层不直接依赖 SDK 类型 | +| 当前模型只部分兼容 OpenAI 协议 | 流式、tool call 或 structured output 失败 | 分别验证 Chat Completions、流式工具调用和 schema;必要时增加模型适配层 | +| 群聊广播导致上下文膨胀 | 成本高、弱模型更易偏航 | 工具输出摘要、事实账本、轮次硬上限和上下文预算 | +| 多 Agent 重复外部写入 | 业务数据污染 | 幂等键、目标锁、动作状态机、核验和补偿 | +| 直接包装 LangChain 工具丢失运行上下文 | 身份、沙箱或线程错误 | 第一版通过 DeerFlow AgentAdapter 执行,不逐个直包工具 | +| 后台恢复重放工具 | 重复副作用 | action 表先落意图,幂等执行,恢复时先查动作状态 | +| 技能运行时被更新 | 结果不可复现 | 会商启动冻结技能哈希和能力快照 | +| 两套系统对比污染生产数据 | 无法公平比较且可能产生副作用 | 使用 UAT/测试租户/隔离命名空间 | + +## 15. 发布与回退 + +- 配置项 `maf_roundtable.enabled` 控制后端 API 与前端入口。 +- 首期仅管理员或指定白名单可见。 +- 关闭入口时,已有运行可选择继续完成或管理员取消。 +- 回退只需关闭特性开关和停止新 MAF jobs;旧 roundtable 不需要迁移或回滚。 +- MAF 独立表保留用于审计,不在代码回退时删除。 +- MAF SDK 使用延迟导入,关闭功能时不影响 Gateway 启动。 + +## 16. 推荐开发顺序与工作量级 + +建议严格按以下顺序推进: + +1. 依赖/模型/离线 wheel Spike。 +2. 独立 session、event 和 SSE 骨架。 +3. DeerFlow AgentAdapter,先跑通真实工具、技能和一次外部写入。 +4. 固定流程编排。 +5. 动态 Group Chat。 +6. Action Coordinator、审批和恢复。 +7. 前端完整工作台。 +8. 对比评测、回归和灰度。 + +在熟悉当前代码的前提下,这是一个中等偏大的完整功能,建议按 3 个可演示里程碑交付: + +- 里程碑 A:后端命令/API 能完成两席位 MAF 会商,真实工具与技能可用。 +- 里程碑 B:页面可运行、可查看工具/技能/产物,外部写入可审批和恢复。 +- 里程碑 C:固定流程和 Group Chat 都可用,完成与原会商的正式对比报告。 + +### 16.1 推荐人员任务包 + +以下任务包适合分给不同同事。一个人可以领取多个连续任务包,但应避免多人同时拥有同一公共模块。 + +| 任务包 | 内容 | 综合难度 | 建议角色 | 可并行条件 | +|---|---|---:|---|---| +| WP-A | 阶段 0:SDK、模型和离线依赖 Spike | D3 | Python/模型网关开发 | 可立即开始 | +| WP-B | 阶段 1:数据表、Store、API、事件序号、鉴权 | D4 | 后端开发 | WP-A 固定版本后开始 | +| WP-C | 阶段 2:DeerFlow Agent Adapter | D5 | 高级后端/Agent Runtime | 依赖 WP-A、WP-B 契约 | +| WP-D | 阶段 3:固定流程、事实账本、恢复 | D4 | 工作流后端 | 依赖 WP-C,可先用 fake adapter | +| WP-E | 阶段 4:Group Chat 与上下文控制 | D5 | 多智能体高级开发 | 依赖 WP-C;与 WP-D 部分并行 | +| WP-F | 阶段 5:外部写入与一致性 | D5 | 高级后端/技术负责人 | 依赖 WP-B;与 WP-D/E 协同 | +| WP-G | 阶段 6:前端工作台 | D4 | 中高级前端 | WP-B 定稿事件契约后并行 | +| WP-H | 阶段 7:评测与错误分类 | D3 | 测试/算法/全栈 | 指标契约确定后并行 | +| WP-I | 阶段 8:故障测试、离线包、灰度 | D4 | 测试开发/DevOps | 持续介入,最后收口 | +| WP-J | 公共入口与最终集成 | D4 | 集成人/技术负责人 | 各包通过独立验收后 | + +建议让 WP-J 负责人统一修改公共入口、处理冲突和跑全量回归。其他负责人通过最小接口提交自己的模块,减少“合并冲突掩盖代码能力”的情况。 + +### 16.2 交付物要求 + +每个同事交付任务包时必须提供: + +- 独立分支或明确的 commit 列表。 +- 对应任务编号,例如 `MAF-02-07`。 +- 设计说明:关键接口、状态机、边界和取舍。 +- 代码改动清单。 +- 自动化测试及执行结果。 +- 至少一个成功案例和一个失败案例。 +- 已知限制与后续风险。 +- 若修改公共文件,说明修改原因和是否可能影响旧功能。 + +缺少测试或失败场景说明的 D4/D5 任务,不视为完整交付。 + +## 16A. 同事代码水平评价标准 + +后续可以把某位同事的分支、commit 或 diff 交给 Codex,按本节输出统一评价。评价不直接代替团队绩效,只衡量本任务中的工程表现。 + +### 16A.1 评价输入 + +至少提供: + +- 负责人姓名或代号。 +- 任务编号与难度,例如 `MAF-05-03 / D5`。 +- 分支名、commit SHA 或明确 diff。 +- 是否允许查看测试日志。 +- 哪些代码是他人预先提供的。 +- 是否有时间、环境或接口限制。 + +如果多人代码混在一个提交中,只能评价该提交的整体质量,无法可靠评价个人水平。 + +### 16A.2 统一评分(100 分) + +| 维度 | 分值 | 评价内容 | +|---|---:|---| +| 功能正确性 | 25 | 是否满足任务契约、成功/失败路径是否正确 | +| 架构与边界 | 15 | 模块职责、依赖方向、是否污染旧会商、是否遵守 Harness/App 防火墙 | +| 异常与状态处理 | 15 | 超时、取消、重试、恢复、并发、乱序和部分失败 | +| 安全与副作用 | 15 | 身份隔离、凭据脱敏、幂等、审批、外部写入一致性;不适用部分转入正确性 | +| 测试质量 | 15 | 测试是否能发现真实 bug,而非只覆盖 happy path | +| 可维护性 | 10 | 命名、类型、复杂度、注释、复用和可观测性 | +| 交付完整度 | 5 | 设计说明、运行证据、已知限制和提交整洁度 | + +### 16A.3 额外记录项 + +以下项目不直接混入 100 分,但必须随评价一起报告: + +- 任务难度:D1–D5。 +- 独立完成度:`独立完成 | 少量提示 | 多次指导 | 主要由他人修复`。 +- 首次验收结果:`通过 | 小改后通过 | 大改后通过 | 未通过`。 +- 关键缺陷等级:P0/P1/P2/P3 数量。 +- 返工轮次。 +- 是否能清楚解释自己的并发、恢复或安全设计。 + +### 16A.4 能力结论参考 + +必须把代码质量与任务难度结合判断: + +| 表现 | 参考结论 | +|---|---| +| D1/D2 高分 | 基础扎实,但不足以单独证明复杂系统能力 | +| D3 85 分以上且独立完成 | 可独立负责普通完整模块 | +| D4 80 分以上且关键异常覆盖完整 | 具备中高级复杂功能交付能力 | +| D5 80 分以上、无 P0/P1、能解释取舍 | 具备核心模块或技术负责人候选能力 | +| 任意难度出现越权、重复外部写入或伪造测试 | 即使功能可演示,也不能判定为合格交付 | + +### 16A.5 Codex 评审输出模板 + +```markdown +# 代码评价:<负责人> / <任务编号> + +- 任务难度:D4 +- 代码得分:82/100 +- 独立完成度:少量提示 +- 首次验收:小改后通过 +- 建议能力档位:可独立负责普通复杂模块,核心一致性模块仍需评审 + +## 关键发现 + +1. [P1] ... +2. [P2] ... + +## 分项评分 + +- 功能正确性:22/25 +- 架构与边界:13/15 +- 异常与状态处理:10/15 +- 安全与副作用:12/15 +- 测试质量:13/15 +- 可维护性:8/10 +- 交付完整度:4/5 + +## 做得好的地方 + +... + +## 需要本人修正的地方 + +... + +## 复评条件 + +... +``` + +## 17. 开工前最终确认项 + +- [ ] 首批灰度用户范围。 +- [ ] 默认写入策略采用 `confirm_high_risk` 还是 `auto`。 +- [ ] 第一批用于验收的外部写工具及其 UAT 地址。 +- [ ] 第一批主持人和席位 Agent。 +- [ ] 对照实验使用的模型与推理参数。 +- [ ] MAF 版本和离线 wheel 构建平台。 +- [ ] 原会商 run 与 MAF run 的绑定方式。 + +## 18. 参考资料 + +- [Microsoft Agent Framework GitHub](https://github.com/microsoft/agent-framework) +- [MAF Group Chat orchestration](https://learn.microsoft.com/en-us/agent-framework/workflows/orchestrations/group-chat) +- [MAF workflow orchestrations](https://learn.microsoft.com/en-us/agent-framework/workflows/orchestrations/) +- [MAF 连接 OpenAI-compatible endpoints](https://learn.microsoft.com/en-us/agent-framework/integrations/openai-endpoints) diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index fc9ef7d2..c1155048 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -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_run`). Tests: `tests/test_maf_roundtable_api.py`, `tests/test_maf_roundtable_isolation.py`, `tests/test_maf_roundtable_concurrency.py` (single-tx concurrent launch + flush/pre-commit rollback + approval race), `tests/test_maf_roundtable_admin_boundary.py` (admin read-only diagnostic + config defaults), `tests/test_maf_roundtable_http.py` (HTTP 401/403/404/405/409/422/503 + PUT null rejection + identity-forgery), `tests/test_maf_roundtable_config.py` (deploy-config enum/range validation). Plan: `frontend-web/docs/microsoft-agent-framework-多智能体会商-实施计划.md`. **Phase 2+ (not shipped)**: `DeerFlowAgentAdapter` (MAF seat → DeerFlow thread/run stream), `structured` / `group_chat` workflow orchestration, Action Coordinator (idempotent external writes), approval pause/resume with checkpoint restore, SSE stream, frontend workbench, comparison evaluation. | | **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. diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index fc8255e7..31e9fff3 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -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) diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py index a2b4c5f2..dad31e0b 100644 --- a/offline-backend-20260512/backend/app/gateway/deps.py +++ b/offline-backend-20260512/backend/app/gateway/deps.py @@ -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): diff --git a/offline-backend-20260512/backend/app/gateway/routers/maf_roundtable.py b/offline-backend-20260512/backend/app/gateway/routers/maf_roundtable.py new file mode 100644 index 00000000..e30c4418 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/maf_roundtable.py @@ -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) diff --git a/offline-backend-20260512/backend/app/maf_roundtable/__init__.py b/offline-backend-20260512/backend/app/maf_roundtable/__init__.py new file mode 100644 index 00000000..84a5c158 --- /dev/null +++ b/offline-backend-20260512/backend/app/maf_roundtable/__init__.py @@ -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", +] diff --git a/offline-backend-20260512/backend/app/maf_roundtable/schemas.py b/offline-backend-20260512/backend/app/maf_roundtable/schemas.py new file mode 100644 index 00000000..36688c7f --- /dev/null +++ b/offline-backend-20260512/backend/app/maf_roundtable/schemas.py @@ -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 diff --git a/offline-backend-20260512/backend/app/maf_roundtable/service.py b/offline-backend-20260512/backend/app/maf_roundtable/service.py new file mode 100644 index 00000000..8640b075 --- /dev/null +++ b/offline-backend-20260512/backend/app/maf_roundtable/service.py @@ -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 diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index e4c9b6a7..89904597 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -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 会商用于诊断(仅读,不含写) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py index 8deaeb3c..2dcca884 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py @@ -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: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/maf_roundtable_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/maf_roundtable_config.py new file mode 100644 index 00000000..2ede99ac --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/maf_roundtable_config.py @@ -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", +] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/__init__.py new file mode 100644 index 00000000..38db7bf8 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/__init__.py @@ -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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/actions.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/actions.py new file mode 100644 index 00000000..035436ea --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/actions.py @@ -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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/approvals.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/approvals.py new file mode 100644 index 00000000..74c09255 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/approvals.py @@ -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']}") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/events.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/events.py new file mode 100644 index 00000000..43b771a0 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/events.py @@ -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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/model.py new file mode 100644 index 00000000..11966dd0 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/model.py @@ -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", +] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/participants.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/participants.py new file mode 100644 index 00000000..af4a902d --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/participants.py @@ -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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/runs.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/runs.py new file mode 100644 index 00000000..858f40d7 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/runs.py @@ -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() diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/sessions.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/sessions.py new file mode 100644 index 00000000..84ec467c --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/sessions.py @@ -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() diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/store.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/store.py new file mode 100644 index 00000000..61e15bdc --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/store.py @@ -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", +] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py index b32bede6..822043b8 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py @@ -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", diff --git a/offline-backend-20260512/backend/tests/test_maf_roundtable_admin_boundary.py b/offline-backend-20260512/backend/tests/test_maf_roundtable_admin_boundary.py new file mode 100644 index 00000000..563f17fd --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_maf_roundtable_admin_boundary.py @@ -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 diff --git a/offline-backend-20260512/backend/tests/test_maf_roundtable_api.py b/offline-backend-20260512/backend/tests/test_maf_roundtable_api.py new file mode 100644 index 00000000..f53e2253 --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_maf_roundtable_api.py @@ -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") diff --git a/offline-backend-20260512/backend/tests/test_maf_roundtable_concurrency.py b/offline-backend-20260512/backend/tests/test_maf_roundtable_concurrency.py new file mode 100644 index 00000000..1ddf2e77 --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_maf_roundtable_concurrency.py @@ -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 + diff --git a/offline-backend-20260512/backend/tests/test_maf_roundtable_config.py b/offline-backend-20260512/backend/tests/test_maf_roundtable_config.py new file mode 100644 index 00000000..19570a9f --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_maf_roundtable_config.py @@ -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 diff --git a/offline-backend-20260512/backend/tests/test_maf_roundtable_http.py b/offline-backend-20260512/backend/tests/test_maf_roundtable_http.py new file mode 100644 index 00000000..6a959266 --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_maf_roundtable_http.py @@ -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 diff --git a/offline-backend-20260512/backend/tests/test_maf_roundtable_isolation.py b/offline-backend-20260512/backend/tests/test_maf_roundtable_isolation.py new file mode 100644 index 00000000..d7732a92 --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_maf_roundtable_isolation.py @@ -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"})