feat(maf): Phase 5 - action coordinator, approval flow, frontend MAF roundtable pages
- Backend: action_coordinator, capability_snapshot, event_normalizer, tool_executor updates - Backend: approved_tool_executor with write guard, action leases, migration script - Frontend: maf-roundtable module (API client, event timeline, approval cards, fact flow panel) - Frontend: MafRoutes, MafSessionsPage, MafRunDetailPage - Tests: action_leases, migration_phase5, phase5_entry_e2e, real_write_e2e/http_e2e - Docs: update implementation plan Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
21c4c898e7
commit
f82363d81f
55 files changed
+6304
-167
No files matched your search
@@ -0,0 +1,122 @@
|
||||
# MAF 多智能体会商 Phase-4 前端实施计划
|
||||
|
||||
## 决策回顾(已与用户确认)
|
||||
- **入口**:独立顶级菜单 `/page/maf/*`,新增侧边栏一级项「MAF 会商」
|
||||
- **实时更新**:纯前端轮询,`GET /runs/{id}/events?after_seq=N&limit=1000`,间隔 1.8s,复用现有后端,零 SSE、零后端改动
|
||||
- **流程图**:要。基于 `confirmed_facts`(来自 `run.maf_checkpoint.fact_ledger`)+ 末轮 `final_result.content` 解析的 `flow-json`,复用 `@xyflow/react` + `flow-graph.ts` + `FlowGraphPanel`
|
||||
- **审批**:卡片式,仿 `IntentClarificationCard` 的视觉/交互模式(参数摘要 + 批准/拒绝按钮 + 备注 textarea),落到 `POST /approvals/{id}/decision`
|
||||
|
||||
## 关键数据契约(已核实后端源码)
|
||||
- **后端只读**:16 个端点全部命中,无改动。`GET /health` 返回 `enabled` 决定页面内容(入口仍可见,未启用时显示空态)。
|
||||
- **事件流**:单调 `(run_id, seq)`,`GET /events?after_seq=N` 返回 `{events:[...], latest_seq}`;前端推进 `after_seq=latest_seq`。payload 已在后端**写入时脱敏**(`sanitize_payload`),前端直接渲染。
|
||||
- **事件分类**(`event_normalizer.py`):`run.started/completed/failed/cancelled`、`agent.turn_started/message.delta/message.completed`、`tool.requested/started/completed/failed`、`skill.started/completed/failed/selected`、`artifact.created`、`action.waiting_approval/approved/rejected`、`workflow.checkpointed`。
|
||||
- **事实账本**(`fact_ledger.empty_ledger`):`candidate_facts`/`confirmed_facts`/`evidence`/`tool_results`/`executed_actions`/`pending_actions`/`failed_or_unknown_actions`/`conflicts`。`confirmed_facts` 条目含 `{candidate_id, text, agent_id, confirmed_by, evidence_refs[]}`。
|
||||
- **Run 字段**(`run_to_dict`):`id, session_id, status, maf_checkpoint(含 fact_ledger + final_result + seat_outcomes), last_event_seq, started_at, finished_at, error_code, error_message_safe, token_usage, latency_ms`。
|
||||
- **终态判定**:`runIsLive(status) = ["pending","running","waiting_approval","cancelling","cancel_failed"].includes(status)`,非 live 即停轮询。
|
||||
- **Action/Approval**:`GET /runs/{id}/actions`、`GET /runs/{id}/approvals`;`POST /approvals/{id}/decision` `{decision:"approved"|"rejected"}`。
|
||||
|
||||
## 复用清单(已定位到文件:行)
|
||||
- `apiFetch`(`core/api/fetch-client.ts`):内部自动注入 `Authorization`(`getAuthorizationHeaderValue()`)+ `credentials:"include"`,**返回 Response 不解析、不拼 URL**。调用方拼 `${getBackendBaseURL()}/api/maf-roundtable/...`,自行 `res.ok` 检查 + `res.json()`。
|
||||
- `getBackendBaseURL`(`core/config/index.ts`):未设时返回 `""`,相对路径同源可用。
|
||||
- TanStack Query 模式(`strategy-components/api/task-buttons.ts`):`useQuery`+`refetchOnWindowFocus:false`;`useMutation`+`onSuccess invalidate`;queryKey 用嵌套数组 `["maf-roundtable", runId, "events"]`。
|
||||
- 轮询模式(`core/scheduled-tasks/hooks.ts:105-115`):函数式 `refetchInterval: (query) => runIsLive(query.state.data?.status) ? ms : false`。
|
||||
- `FlowGraphPanel`(`roundtable-planning/components/FlowGraphPanel.tsx`):props `{data: FlowGraphData, streaming, onClose, ...}`,自动 `useTheme()` 取明暗。`FlowGraphData = {title, nodes:[{id,label,type,detail?,group?}], edges:[{from,to,label?}]}`。`extractFlowGraph(text)`(`flow-graph.ts:263`)容错解析 ```flow-json``` 代码块(含半截 JSON)。
|
||||
- `IntentClarificationCard`(`roundtable-planning/components/IntentClarificationCard.tsx`):卡片视觉模板(`rounded-xl border-red-100` + 「需要确认」徽标 + 选项按钮 + textarea + 提交)。
|
||||
- 时间线 feed(`roundtable-planning/components/Step1Panel.tsx` + `MessageBubble` + `useAutoScroll` + `ScrollToBottomButton`):数组入、按 seq 序渲染。
|
||||
- `useDesensitizeSubtree`(`roundtable-planning/lib/use-desensitize-subtree.ts`):仅挂 header/面板框架;时间线 body 不挂(payload 已脱敏 + hook 自身警告勿用于消息列表)。
|
||||
- 路由(`pages/PageRoutes.tsx:143-169`):`<Route path="maf/*" element={<MafRoutes/>}/>`(仿 `RoundtableRoutes.tsx`)。
|
||||
- 菜单(`core/page-layout/sidebar-menu.ts`):在 `sidebarMenuItems` 数组加一级项,`icon` 用 lucide 组件(非字符串 key)。新节点经 `use-sidebar-menu.ts` 自动走 static→inject→override→role→desensitize 流水线,无需额外注册。
|
||||
|
||||
## 文件夹结构(镜像 `roundtable-planning/`,全新 `src/maf-roundtable/`)
|
||||
```
|
||||
frontend-web/src/maf-roundtable/
|
||||
├── api/
|
||||
│ ├── types.ts # TS 类型(snake_case 直传,后端即 snake_case)
|
||||
│ ├── client.ts # BASE()=getBackendBaseURL()+"/api/maf-roundtable";apiFetch 封装 + readJson<T>/parseDetail
|
||||
│ ├── sessions.ts # 会话 CRUD hooks(list/get/create/update/delete)
|
||||
│ ├── runs.ts # run hooks(get/list/create/cancel/resume/recover)+ useMafHealth
|
||||
│ ├── events.ts # listEvents(runId, afterSeq) 原始 fetch(供轮询 hook 用)
|
||||
│ └── approvals.ts # 审批 hooks(list + decide mutation)
|
||||
├── hooks/
|
||||
│ ├── use-maf-run-status.ts # GET /runs/{id} useQuery,函数式 refetchInterval 停终态
|
||||
│ └── use-maf-events.ts # ★ cursor 轮询 hook(核心)
|
||||
├── lib/
|
||||
│ ├── event-timeline.ts # 纯函数:events[] → TimelineEntry[](delta 聚合、tool 生命周期折叠、approval 锚点)
|
||||
│ └── fact-flow-graph.ts # 纯函数:confirmed_facts+evidence → FlowGraphData
|
||||
├── components/
|
||||
│ ├── MafRunHeader.tsx # 状态徽标 + token/延迟 + 取消/恢复/重试
|
||||
│ ├── MafEventTimeline.tsx # 时间线 feed(MessageBubble + useAutoScroll)
|
||||
│ ├── MafSeatStatusPanel.tsx # 席位状态矩阵 + execution_stage 徽标
|
||||
│ ├── MafFactFlowPanel.tsx # 包一层 FlowGraphPanel,数据来自 fact-flow-graph
|
||||
│ ├── MafApprovalCard.tsx # 卡片式审批(批准/拒绝 + 备注)
|
||||
│ └── MafHealthGate.tsx # health.enabled=false → 空态
|
||||
├── pages/
|
||||
│ ├── MafSessionsPage.tsx # 会话列表 + 新建
|
||||
│ └── MafRunDetailPage.tsx # 单 run 工作区(三栏)
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
## 核心:cursor 轮询 hook(`hooks/use-maf-events.ts`)
|
||||
不用纯 `useQuery+refetchInterval`:cursor `after_seq` 需每次推进,queryFn 不持有「上次 latest_seq」,塞 queryKey 会抖动。**用自定义 hook**(结合 scheduled-tasks 的 refetchInterval 思路 + roundtable-planning 的 reader-loop 家族):
|
||||
```ts
|
||||
export function useMafEvents(runId?: string, active=false) {
|
||||
const [events, setEvents] = useState<MafEvent[]>([]);
|
||||
const afterSeqRef = useRef(0); // 避免 setInterval 闭包旧值
|
||||
useEffect(() => {
|
||||
if (!runId || !active) return;
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
const res = await listEvents(runId, afterSeqRef.current);
|
||||
if (cancelled || !res.events.length) return;
|
||||
setEvents(prev => mergeBySeq(prev, res.events)); // seq 去重
|
||||
afterSeqRef.current = res.latest_seq;
|
||||
};
|
||||
poll(); // 进入即补拉(after_seq=0 全量历史)
|
||||
const id = setInterval(poll, 1800);
|
||||
return () => { cancelled = true; clearInterval(id); };
|
||||
}, [runId, active]);
|
||||
return events;
|
||||
}
|
||||
```
|
||||
- **补拉历史**:进入页面 `after_seq=0` 立即拉全部已落库事件 → 天然支持断线补拉/刷新恢复。
|
||||
- **终态停止**:`active` 由 `useMafRunStatus` 的 `runIsLive(status)` 传入,终态时 effect 清理 interval。
|
||||
- **去重**:后端 `(run_id,seq)` + `source_event_id` 双重去重,前端再按 `seq` 做 `mergeBySeq`,幂等。
|
||||
- **切换 run**:`runId` 变 → effect 重置(events 清空、afterSeq 归零)。
|
||||
|
||||
## 流程图数据流(`lib/fact-flow-graph.ts`)
|
||||
两源按优先级合并 → `FlowGraphData`:
|
||||
1. **`confirmed_facts`(主)**:每条已确认事实 → `task` 节点(label=text 截断、detail=全文、group=confirmed_by);`evidence_refs` 连到 `evidence`(→ `role`)或 `tool_results`(`tool:{name}:{turn}` → `deliverable`)节点;证据根用 `start`。
|
||||
2. **`final_result.content` 的 ```flow-json```(次)**:用现成 `extractFlowGraph(text)` 解析叠加为「结论流程图」。
|
||||
|
||||
输出喂 `<FlowGraphPanel data={...} streaming={!terminal}/>`(非终态蚂蚁线动)。两源皆空 → 空态「尚无可绘制事实」。
|
||||
|
||||
## 页面布局(`MafRunDetailPage`,三栏 / 窄屏堆叠)
|
||||
- **左:时间线**(`MafEventTimeline`)—— `event-timeline.ts` 折叠成可读条目(delta 聚合气泡、tool 折叠「调用 X→完成」、action.waiting_approval 插锚点),复用 MessageBubble + useAutoScroll + ScrollToBottomButton。
|
||||
- **中上:席位状态**(`MafSeatStatusPanel`)—— `maf_checkpoint.seat_outcomes` 的 agent × status 矩阵 + `execution_stage` 徽标。
|
||||
- **中下:事实/结论流程图**(`MafFactFlowPanel`)—— 折叠面板,默认展开。
|
||||
- **右:审批卡片**(`MafApprovalCard` 列表)—— 仅 pending 时显示;含工具名、参数摘要(脱敏)、批准/拒绝按钮 + 备注 textarea → `POST /approvals/{id}/decision`,成功后 invalidate `["maf-roundtable","approvals",runId]`,事件轮询自然看到 `action.approved/rejected`。
|
||||
|
||||
## 路由 + 菜单接入(3 处)
|
||||
1. **新建** `pages/MafRoutes.tsx`(仿 `RoundtableRoutes.tsx`):`index→MafSessionsPage`、`runs/:runId→MafRunDetailPage`、`*→重定向`。
|
||||
2. **改** `pages/PageRoutes.tsx`:加 `import MafRoutes` + `<Route path="maf/*" element={<MafRoutes/>}/>`(插 `notebook` 后)。
|
||||
3. **改** `core/page-layout/sidebar-menu.ts`:数组末尾加 `{ id:"maf-center", label:"MAF 会商", icon:Network, children:[{id:"maf-roundtable", label:"会商列表", icon:MessageSquareText, path:"/page/maf"}] }`。**不加 `adminOnly`**(全员可见;`/health.enabled=false` 由 `MafHealthGate` 客户端控制页面空态)。
|
||||
|
||||
## 实施顺序
|
||||
1. `api/types.ts` + `api/client.ts`(地基)
|
||||
2. `api/{sessions,runs,events,approvals}.ts`(端点 hook)
|
||||
3. `hooks/{use-maf-run-status,use-maf-events}.ts`(轮询核心)
|
||||
4. `lib/{event-timeline,fact-flow-graph}.ts`(纯函数)
|
||||
5. `components/*`(6 个组件)
|
||||
6. `pages/*`(2 个页面)+ `index.ts`
|
||||
7. `pages/MafRoutes.tsx` + 接入 `PageRoutes.tsx` + `sidebar-menu.ts`
|
||||
8. `pnpm typecheck` + 手动核对路由/菜单显隐
|
||||
|
||||
## 文件清单
|
||||
- **新建**(14):`maf-roundtable/api/{types,client,sessions,runs,events,approvals}.ts`、`maf-roundtable/hooks/{use-maf-run-status,use-maf-events}.ts`、`maf-roundtable/lib/{event-timeline,fact-flow-graph}.ts`、`maf-roundtable/components/{MafRunHeader,MafEventTimeline,MafSeatStatusPanel,MafFactFlowPanel,MafApprovalCard,MafHealthGate}.tsx`、`maf-roundtable/pages/{MafSessionsPage,MafRunDetailPage}.tsx`、`maf-roundtable/index.ts`、`pages/MafRoutes.tsx`
|
||||
- **改动**(2):`pages/PageRoutes.tsx`、`core/page-layout/sidebar-menu.ts`
|
||||
- **后端**:无改动
|
||||
|
||||
## 验证
|
||||
- `pnpm typecheck`(tsc --noEmit)通过
|
||||
- 手动:菜单「MAF 会商」→ 列表 → 新建会话 → 启动 run → 时间线滚动 → 席位状态更新 → 流程图随 confirmed_facts 增长 → 审批可批准 → 终态停轮询
|
||||
- 退路:`/health.enabled=false` → 入口可见但页面显示未启用提示;旧 `roundtable-planning` 零改动
|
||||
@@ -1,17 +1,87 @@
|
||||
# Microsoft Agent Framework 多智能体会商对照页实施计划
|
||||
|
||||
> 文档状态:实施中;阶段 1(独立后端骨架与持久化)已验收通过
|
||||
> 更新时间:2026-08-01
|
||||
> 文档状态:实施中;已按当前工作区代码重新盘点,阶段 5 安全闭环处于收口验收,阶段 4 动态会商仍未达到退出标准
|
||||
> 更新时间:2026-08-02
|
||||
> 目标版本:实验性独立功能,不替换现有多智能体会商
|
||||
> 建议页面名称:`MAF 会商(实验)`
|
||||
|
||||
## 0. 当前实施状态与前后端隔离决议
|
||||
|
||||
### 0.1 已完成并验收的阶段 1
|
||||
### 0.1 代码盘点结论(以 2026-08-02 当前工作区为准)
|
||||
|
||||
新的后端骨架已经独立落在 `/api/maf-roundtable` 及 `maf_roundtable_*` 专用表中;会话、运行、事件、审批、用户隔离、启动/取消原子事务与配置校验均不复用旧会商的持久化状态。当前验收证据为:78 项 MAF 后端回归测试通过,且 Harness → App 导入边界检查通过。
|
||||
本节的状态以当前 `master-dev`、尚未提交的 MAF 改动和本次实际运行结果为准,不沿用同事口头的阶段编号或测试数量。`已实现`不等于`退出验收通过`;只有满足本阶段退出标准、测试证据完整且没有 P0/P1 缺口时才能进入下一阶段的正式验收。
|
||||
|
||||
阶段 1 完成并不表示功能已对用户开放:`maf_roundtable.enabled` 继续默认关闭,直到后续 MAF 编排和新前端页面均完成验收。
|
||||
| 阶段 | 当前状态 | 已有代码/证据 | 尚未满足的退出条件 |
|
||||
|---|---|---|---|
|
||||
| 0 依赖 Spike | 部分完成 | `agent-framework-core` 已锁定到 lock 中的 `1.13.0`;真实 SDK `WorkflowBuilder` 路径可由测试运行。 | 尚无当前部署模型的 OpenAI 兼容流式实测、离线 wheelhouse 全新安装实测和正式 Spike 记录。 |
|
||||
| 1 独立后端骨架 | 代码完成 | `/api/maf-roundtable`、独立 `maf_roundtable_*` 表、事务化启动/取消、事件序号、用户隔离、管理员只读诊断均已实现。 | 本工作区缺少文档提及的 `tests/test_harness_boundary.py`,需在 CI 或恢复该用例后补一份边界验证证据。 |
|
||||
| 2 DeerFlow Agent Adapter | 代码完成,集成验收待补 | 独立 thread、用户 principal、运行参数、SSE 标准化、事件脱敏、取消级联、seat-run 持久化均已实现;相关单测覆盖。 | 尚未用已部署模型和真实 MCP/技能完成一条从 API 到 DeerFlow Runtime 的人工验收记录。 |
|
||||
| 3 固定流程编排 | 代码完成,测试通过 | `WorkflowBuilder` 的 planner → 并行席位 → 交叉审查 → 审批 → finalizer、事实账本、格式修复、checkpoint/恢复与 `execution_stage` 身份均已实现。 | 需在真实模型/真实能力快照下完成一次演示验收;不阻塞阶段 5 的安全收口。 |
|
||||
| 4 动态 Group Chat | 部分实现,不能验收 | `group_chat` 模式能走真实 SDK 工作流路径,且会写 `manager.decision` 事件。 | 当前实质是 `WorkflowBuilder` 的单次 round-robin:没有主持人 Agent selector、没有多轮同席位发言策略、没有完整的总超时/弱模型收敛测试;详见阶段 4。 |
|
||||
| 5 外部写入与审批 | 框架闭环完成,UAT/部署待输入 | 已有可信工具元数据、WriteGuard、加密参数、审批超时、服务端派生的目标租约、原子一次执行、`unknown` 失败关闭、审计 API、真实 `ToolNode` outbox 及 HTTP 决策→恢复全链路测试。 | 尚未指定 UAT 写入工具、最小权限凭据和核验/补偿协议;生产部署尚未注入 `MAF_ACTION_SECRET_KEY`。 |
|
||||
| 6 前端工作台 | 原型已存在,未按新约束验收 | 独立 `/page/maf/*` 路由、健康门、会话/运行页、纯轮询、审批卡、事件代次隔离已实现;前端逻辑测试 19 项通过。 | 现有可视组件主要仍使用旧的本地 shadcn/Radix 封装,未改为用户指定的 `tdesign-react`;仍直接引用旧会商目录中的脱敏 hook;恢复/失败重试和组件级 API 验收未完成。 |
|
||||
| 7 对比评测 | 未开始 | 无。 | 依赖阶段 4、5、6 的正式验收。 |
|
||||
| 8 测试、离线打包与灰度 | 未开始 | 无。 | 依赖可验收版本。 |
|
||||
|
||||
本次重新运行的证据:
|
||||
|
||||
- 后端安全闭环重点用例:`293 passed in 140.10s`(包含真实入口 ToolNode/outbox、能力快照、WriteGuard、审批过期与目标租约、旧 DeerFlow 回归)。
|
||||
- 后端编排重点用例:`40 passed in 5.46s`(真实 SDK structured/group_chat 路径、Agent Adapter、阶段 3 主持人流程)。
|
||||
- 前端 MAF 逻辑用例:`19 passed`;`pnpm build` 通过。
|
||||
- 全量 `test_maf_*.py` 已在无短时命令限制的终端完成,最终结果为 `293 passed in 140.10s`;PowerShell 下使用文档中的文件枚举方式展开测试文件。
|
||||
|
||||
阶段 1 完成并不表示旧会商被替换。当前配置已打开新的 MAF 功能开关;前端入口由健康检查和权限控制。旧 DeerFlow、旧 roundtable、AI 写作不携带 `maf_run_id`,因此不进入 MAF WriteGuard 分支。
|
||||
|
||||
### 0.3 阶段 5:真实工具、技能与外部写入审批闭环(当前收口)
|
||||
|
||||
本阶段已补齐以下安全边界:
|
||||
|
||||
- `use-maf-events.ts` 为每次 `runId`、`enabled`、`polling`、`resetSignal` 变化分配请求代次;旧请求返回后不能污染新 run,终态补拉不会被旧请求阻塞。
|
||||
- 能力快照的工具副作用元数据只从服务端读取,优先级为 MAF 专用精确工具名配置、已有 `ToolConfig.extra`、内置白名单、保守默认 `write/R3`。浏览器、会话请求和模型输出不能提供或覆盖这些字段。
|
||||
- 生产写工具登记时,必须在同一服务端元数据中配置 `target_key_fields`(稳定的顶层租户/业务对象字段)和可选 `target_key_namespace`;这些字段只参与服务端稳定哈希,禁止直接提交 `target_key`。未登记或无法识别对象的写操作共用保守串行目标,并在 `unknown` 后保持人工核验屏障。
|
||||
- 当前真实发现的只读 MCP 工具名为 `weknora-retrieval_weknora_list_knowledge_bases` 和 `weknora-retrieval_weknora_search`,已按精确名称登记为 `read/R0`;不按 server 名或名称前缀猜测。
|
||||
- 测试环境提供真实 LangGraph `ToolNode` 写工具 `maf_test_outbox_write`,只写临时 outbox;它经过真实 WriteGuard、加密参数、审批、`execute_approved_tool` 和一次性执行器。生产外部写入目标仍需业务方指定,不能用检索 MCP 代替,也不能擅自猜测业务系统。
|
||||
- `write_guard.enabled=true` 后,缺少 `MAF_ACTION_SECRET_KEY` 时只读工具和技能仍可工作,外部写入在执行前失败关闭;健康检查只返回 `enabled`、`ready`、`secret_configured` 和状态码,不返回密钥。
|
||||
|
||||
本轮框架代码已完成阶段 5 的可自行实现项;下列项目必须由业务方/运维补齐后才能将阶段 5 标记为完整完成:
|
||||
|
||||
- `maf_test_outbox_write` 是测试专用写工具,已经证明真实 LangGraph `ToolNode` 与一次性执行器可用,但不等于已接入业务系统。
|
||||
- 当前机器未注入生产 `MAF_ACTION_SECRET_KEY`,健康状态会显示 `missing_secret`;这符合失败关闭设计,但不能用于生产写入验收。
|
||||
|
||||
上线前必须由部署环境注入 `MAF_ACTION_SECRET_KEY`,不得写入仓库、`config.yaml`、前端、事件、checkpoint 或模型上下文。当前测试验收命令为:
|
||||
|
||||
```powershell
|
||||
$env:PYTHONPATH="."
|
||||
$env:MAF_ACTION_SECRET_KEY="仅测试环境临时值"
|
||||
$mafTests = Get-ChildItem tests -Filter "test_maf_*.py" | ForEach-Object FullName
|
||||
uv run --extra maf pytest $mafTests -q -W error
|
||||
uv run ruff check app/maf_roundtable packages/harness/deerflow
|
||||
```
|
||||
|
||||
### 0.3.1 同事 A:MAF-05-03A / 03B / 04A / 08A 收口记录
|
||||
|
||||
本轮已完成前阶段 5 的代码与自动化验收项,但按约定仍不将阶段 5 标记为最终验收完成:真实 UAT 写入系统、测试租户、最小权限凭据、核验 API/补偿协议以及生产环境密钥注入仍待业务方和运维提供。
|
||||
|
||||
- `MAF-05-03A`:Action 服务层使用公开白名单 DTO;`target_key` 与 `idempotency_key` 仅留在持久层和内部仲裁路径,不能通过两个 actions GET 接口、事件或前端类型返回。目标键只读取服务端登记的稳定标量字段,并使用 `MAF_ACTION_SECRET_KEY` 做域隔离 HMAC-SHA-256;缺少密钥时真实写入失败关闭。
|
||||
- `MAF-05-03B`:`scripts/migrate_maf_roundtable_phase5.py` 按数据库方言生成 DDL:PostgreSQL 使用 `TIMESTAMP WITH TIME ZONE`,MySQL/MariaDB 使用 `DATETIME(6)`,SQLite 使用 `DATETIME`。迁移可重复执行,表、缺失索引和可安全补齐的列会自动补齐;已覆盖 SQLite 实际迁移以及三种方言 DDL 测试。
|
||||
- `MAF-05-04A`:Gateway lifespan 启动和关闭 MAF 专用审批过期扫描任务,间隔由 `maf_roundtable.approval_scan_interval_seconds` 配置。扫描使用已有条件式过期 CAS,然后只对受影响 run 调用 `dispatcher.resume()`;普通审批 GET 保持只读,决定/恢复仍在操作前执行失败关闭过期检查。
|
||||
- `MAF-05-08A`:入口级测试从 HTTP 创建 session/run 开始,确定性假实现只位于模型边界;真实 Agent Runtime、WriteGuard、LangGraph ToolNode、加密参数和 outbox 均参与执行。覆盖 HTTP 审批后恰好一次写入,以及同目标并发时第二个 run 的 `waiting_target_lease` 与显式 resume。
|
||||
|
||||
本轮证据:全部 `test_maf_*.py` 共 `293 passed`;阶段 5 定向回归 `15 passed`;`git diff --check` 通过。仓库现有的全目录 Ruff 基线仍有与本轮无关的历史问题,提交前需在 CI 中单独清理;本轮新增/修改文件已做定向检查。
|
||||
|
||||
### 0.4 下一批开工顺序与责任边界
|
||||
|
||||
当前应正式进入**阶段 5 收口**;阶段 4 的动态会商可与其中不依赖业务系统的任务并行。不要把阶段 6 原型继续扩展成正式页面,直到阶段 5 的动作一致性契约固定。
|
||||
|
||||
| 工作包 | 对应任务 | 难度 | 是否可立即开始 | 交付边界 |
|
||||
|---|---|---:|---|---|
|
||||
| WP-F1 目标一致性 P1 收口 | `MAF-05-03A`、`MAF-05-03B` | D4/D5 | 已完成 | 已封闭 `target_key`/内部幂等键公开边界,并使 Phase-5 迁移兼容 PostgreSQL/MySQL;真实 UAT 目标仍由 WP-F3 负责。 |
|
||||
| WP-F2 审批恢复验收补强 | `MAF-05-04A`、`MAF-05-08A` | D4/D5 | 已完成 | 已完成主动过期、并发/重启补扫,以及从真实工作流入口生成审批到 HTTP 决策、ToolNode/outbox 的全链路验收。 |
|
||||
| WP-F3 真实 UAT 工具 | `MAF-05-05`、`MAF-05-09`、`MAF-05-10` | D5 | 需业务方输入 | 仅在拿到目标系统、最小权限凭据、幂等与查询核验协议后实施;这是唯一不能自行假设的工作包。 |
|
||||
| WP-E 动态会商 | `MAF-04-01` ~ `MAF-04-07` | D5 | 可以并行 | 真实多轮 selector、上下文/终止状态机和弱模型收敛测试;不得修改阶段 5 的 Action Coordinator。 |
|
||||
| WP-G 前端重构准备 | `MAF-06-00` | D3 | 可以并行 | 仅拆除对旧会商 hook 的直接依赖、固定 API view-model 和测试基线;TDesign 页面重构等阶段 5 契约稳定后开始。 |
|
||||
|
||||
WP-F1、WP-F2 与 WP-E 必须分别使用独立分支/提交;公共 `config.yaml`、Gateway 注册和前端路由由集成人统一合并。WP-F3 进入开发前需由业务方书面确认第一个写入目标,缺少该信息时应保持阻塞状态而不是接入猜测的 MCP。
|
||||
|
||||
### 0.2 新前端页面的强制隔离原则
|
||||
|
||||
@@ -42,7 +112,7 @@
|
||||
|
||||
### 2.1 本期必须实现
|
||||
|
||||
- 新页面:`/page/strategy/qa/maf-roundtable`。
|
||||
- 新页面:`/page/maf/*`(当前已落地为独立顶级菜单,不嵌入旧会商页)。
|
||||
- 两种 MAF 编排模式:
|
||||
- `structured`:专家并行分析 → 交叉审查 → 主持人综合。
|
||||
- `group_chat`:主持人动态选择下一位发言人,多轮讨论后收敛。
|
||||
@@ -79,7 +149,7 @@
|
||||
|
||||
### 2.4 新前端与新后台的对接边界
|
||||
|
||||
新前端页面固定使用独立路由 `/page/strategy/qa/maf-roundtable`,并且只允许请求以下新接口前缀:
|
||||
新前端页面固定使用独立路由 `/page/maf/*`,并且只允许请求以下新接口前缀:
|
||||
|
||||
```text
|
||||
GET /api/maf-roundtable/health
|
||||
@@ -278,6 +348,18 @@ flowchart LR
|
||||
|
||||
快照用于审计和复现,不代表运行期间复制工具实例。
|
||||
|
||||
MAF 的副作用分类是服务端可信配置,不由上述请求体或模型生成的能力描述决定。部署配置使用实际运行时工具名作为键,例如:
|
||||
|
||||
```yaml
|
||||
maf_roundtable:
|
||||
tool_metadata:
|
||||
weknora-retrieval_weknora_search:
|
||||
side_effect: read
|
||||
risk_level: R0
|
||||
```
|
||||
|
||||
未登记工具保持保守的 `write/R3`,避免未知外部工具被误放行。技能可以继续使用,但技能触发的实际外部副作用必须落到已分类工具调用,不能绕过 WriteGuard。
|
||||
|
||||
### 6.2 技能接入
|
||||
|
||||
- 继续使用 Agent 当前技能白名单。
|
||||
@@ -313,6 +395,8 @@ Action Coordinator 只负责执行一致性,不替代工具:
|
||||
|
||||
这套分级不禁止外部写入,而是保证它在不同运行策略下可控、可恢复、可审计。
|
||||
|
||||
当前已完成的可执行验收工具是测试专用 `maf_test_outbox_write`(`R2/write`),副作用限制在 `MAF_E2E_OUTBOX_DIR` 指定的临时目录。生产验收还需要业务方明确目标系统、测试租户、最小权限凭证和幂等/核验接口;在此之前不得宣称已经完成生产业务写入接入。
|
||||
|
||||
### 6.4 沙箱与产物冲突
|
||||
|
||||
- 一个席位一个 thread,一个 thread 一个沙箱上下文。
|
||||
@@ -559,7 +643,7 @@ offline-backend-20260512/backend/
|
||||
|
||||
### 10.0 独立前端模块、路由和状态
|
||||
|
||||
新增目录固定为 `frontend-web/src/maf-roundtable/`,其 API client 只能封装 `/api/maf-roundtable/*`。新页面根组件为 `MafRoundtablePage.tsx`,路由仅新增 `/page/strategy/qa/maf-roundtable`;不得替换、重命名、迁移或修改旧 `multi-agent-roundtable` 路由及其页面组件。
|
||||
新增目录固定为 `frontend-web/src/maf-roundtable/`,其 API client 只能封装 `/api/maf-roundtable/*`。当前页面根路由为 `MafRoutes.tsx`,路由仅新增 `/page/maf/*`;不得替换、重命名、迁移或修改旧 `multi-agent-roundtable` 路由及其页面组件。
|
||||
|
||||
入口展示前先读取 `GET /api/maf-roundtable/health`:`enabled=false` 时隐藏入口并禁止创建会商;开启后页面必须明确展示“实验版 / 独立后端”标识。TanStack Query 的 key 使用独立命名空间,例如 `['maf-roundtable', 'sessions', ...]`,不得复用任何旧 `roundtable`、`draft` 或 `job` key。所有页面本地状态、事件 reducer、轮询/补拉游标、取消控制器和历史恢复逻辑也必须归属于 MAF 模块。
|
||||
|
||||
@@ -745,15 +829,17 @@ frontend-web/src/maf-roundtable/
|
||||
前置依赖:阶段 2;建议先完成阶段 3 以复用事实账本和恢复机制
|
||||
主要评价点:弱模型条件下能否收敛、上下文是否准确、硬终止是否可靠
|
||||
|
||||
当前代码状态:`workflow_factory.py` 已为 `group_chat` 建了真实 MAF `WorkflowBuilder` 图,`CoordinatorExecutor` 会写 `manager.decision`,并有一条 SDK 路径测试。但它目前按 round-robin 让每个席位最多发言一次;这只是可运行的占位编排,不是本阶段承诺的动态多轮会商。
|
||||
|
||||
任务:
|
||||
|
||||
- [ ] `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)构造弱模型不结束、错误选人、重复发言等测试并保证工作流可收敛。
|
||||
- [ ] `MAF-04-01`(D4)改为使用锁定 MAF 版本实际提供的 Group Chat 编排 API;若该版本没有名为 `GroupChatBuilder` 的公开类,必须在设计说明中给出等价的官方 API、版本和最小可运行证明。禁止把手写 `WorkflowBuilder` 分支称为已完成的 Group Chat。
|
||||
- [ ] `MAF-04-02`(D5)实现两种 selector:可复现的确定性 selector,以及由主持人 Agent 根据已验证事实账本选择下一发言人的 selector。两者均须验证候选席位、返回结构和失败降级,不能相信模型随意返回的 agent id。
|
||||
- [ ] `MAF-04-03`(D4)实现最大轮次、连续发言上限、总超时和单席位超时;状态必须持久化到 checkpoint,恢复后继续计数而不是重置。
|
||||
- [ ] `MAF-04-04`(D5)以事实账本和摘要引用作为群聊输入来源,保留已执行/待审批/失败动作语义;超长工具结果只传播摘要和可追溯引用,并执行明确 token/字符预算。
|
||||
- [ ] `MAF-04-05`(D4)由主持人显式输出完成判断与结构化终稿;不合法输出仅允许一次定向修复,仍失败时走确定性降级并留下原因事件。
|
||||
- [x] `MAF-04-06`(D3)已写入 `manager.decision` 事件;后续须把现有 `round_robin` 原因扩展为 selector 类型、候选集、拒绝原因和终止依据。
|
||||
- [ ] `MAF-04-07`(D5)新增弱模型不结束、幻觉席位、重复选人、席位超时、空输出和恢复后继续轮次的测试;至少包含一次真实 SDK 的多轮(同一席位可在合规策略下再次发言)验收。
|
||||
|
||||
退出标准:
|
||||
|
||||
@@ -771,14 +857,22 @@ frontend-web/src/maf-roundtable/
|
||||
|
||||
任务:
|
||||
|
||||
- [ ] `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)实现并发/重启/重复事件故障注入测试,证明外部写入不会重复。
|
||||
- [x] `MAF-05-01`(D3)已建立服务端可信的工具风险元数据、精确名称覆盖和未知工具 `write/R3` 保守分类;会话请求、浏览器和模型输出均不可覆盖它。
|
||||
- [x] `MAF-05-02`(D3)已实现 `auto`、`confirm_high_risk`、`confirm_all_writes` 策略解析,并在 WriteGuard 之前执行分类。
|
||||
- [x] `MAF-05-03`(D5)服务端目标租约与一次性执行核心已实现,并已完成 03A/03B 的公开边界和跨数据库迁移收口;阶段 5 仍受真实 UAT 工具与部署输入约束,不能据此标记为最终验收完成。
|
||||
- [x] `MAF-05-03A`(D4,分配同事 A)已封闭目标租约键的公开边界:`target_key` 与内部 `idempotency_key` 仅留在持久层和内部仲裁路径,两个 actions GET、事件、日志和前端类型均不返回;目标键只接受服务端登记的稳定标量业务字段,并使用域隔离 HMAC-SHA-256 派生。HTTP 回归同时证明存储层仍能使用目标键完成租约仲裁。
|
||||
- [x] `MAF-05-03B`(D4,分配同事 A)已修复 `migrate_maf_roundtable_phase5.py` 的跨数据库 DDL:PostgreSQL 使用 `TIMESTAMP WITH TIME ZONE`、MySQL/MariaDB 使用 `DATETIME(6)`、SQLite 使用 `DATETIME`;表、缺失索引和可安全补齐列可重复自动补齐。已覆盖 SQLite 实际迁移及 PostgreSQL/MySQL DDL 测试。
|
||||
- [x] `MAF-05-04`(D5)审批创建时写入部署级超时,到期后在同一事务内标记 Approval=`expired`、Action=`rejected` 和审计事件。已覆盖真实 HTTP:`POST /approvals/{id}/decision` → `dispatcher.resume()` → `WorkflowRunner.resume()` → `ToolExecutor` → LangGraph `ToolNode` → 测试 outbox;重复决策/恢复仍只执行一次。
|
||||
- [x] `MAF-05-04A`(D4,分配同事 A)已将审批过期收口为 Gateway lifespan 中可取消的 MAF 周期扫描任务;间隔可配置,扫描使用条件式 CAS 后恢复受影响 run,普通 GET 保持只读。已覆盖并发扫描、重复/重启补扫、决策并发和过期后 Action 拒绝且工具执行次数为零,测试不使用 `sleep`。
|
||||
- [ ] `MAF-05-05`(D5)为指定的第一个真实写工具实现结果未知核验:外部请求必须携带业务幂等 operation id;网络超时/断连后先调用目标系统查询接口核验,再决定 `succeeded`、`failed` 或保留 `unknown`。补偿动作必须独立审批,不能由通用框架自动猜测或自动重试。
|
||||
- [x] `MAF-05-06`(D4)参数加密、API/事件/日志脱敏和泄漏测试已覆盖;继续在新增真实工具适配器中复用,禁止把明文凭据或原始参数放进 checkpoint、事件或模型上下文。
|
||||
- [x] `MAF-05-07`(D3)已提供按 run 查询 actions、approvals、事件及健康状态的 API,前端原型可显示审批和外部动作审计信息。
|
||||
- [x] `MAF-05-08`(D5)已覆盖两个 Agent 同目标租约串行、进程重启后的过期租约 `unknown` 恢复、外部成功但本地终态落库失败时禁止重放、审批超时、HTTP 决策全链路、并发占用、重复 `resume()`、拒绝、缺失/损坏密钥、真实 ToolNode/outbox 与旧 DeerFlow 不回归。
|
||||
- [x] `MAF-05-08A`(D5,分配同事 A)已补齐入口级验收:测试从 HTTP 创建 MAF session/run 和真实工作流入口开始,仅在模型边界使用确定性假实现;真实 WriteGuard、ToolNode、加密参数、HTTP 审批和测试 outbox 均参与,单次写入恰好一次。同目标双 run 覆盖 `waiting_target_lease`,第一个完成后第二个只能显式 `/runs/{id}/resume`,且无重复执行;公开 API 同时断言没有 `target_key`/内部幂等键。
|
||||
- [ ] `MAF-05-09`(D5,需业务方输入)接入首个真实 UAT 写工具/MCP。开始前必须明确工具名与参数 schema、目标 UAT 地址/租户、最小权限凭据的注入方式、目标键提取规则、外部幂等键、查询核验 API、可选补偿边界和回滚联系人;未提供这些信息不得猜测并接入生产系统。
|
||||
- [ ] `MAF-05-10`(D3)在部署环境通过环境变量注入 `MAF_ACTION_SECRET_KEY`,重启后确认 `/api/maf-roundtable/health` 返回 `write_guard_ready=true`;密钥不得提交到仓库或写入 `config.yaml`。
|
||||
|
||||
阶段 5 的当前可交付边界:测试专用 `maf_test_outbox_write` 已验证 WriteGuard → 审批超时/决策 → dispatcher → checkpoint 恢复 → 目标租约 → 直接受控 ToolNode 执行一次的链路,03A、03B、04A、08A 的框架代码与自动化验收项已完成。该结论仍仅代表**框架核心已实现**:真实 UAT 写工具、业务系统查询/补偿协议、最小权限凭据和生产 `MAF_ACTION_SECRET_KEY` 注入尚未提供,因此阶段 5 不得写为“验收完成”,也不得在生产执行真实外部写入。部署时先运行 `PYTHONPATH=. uv run python scripts/migrate_maf_roundtable_phase5.py`;它只证明框架安全性,不替代 `MAF-05-09` 的真实业务系统验收。
|
||||
|
||||
退出标准:
|
||||
|
||||
@@ -787,40 +881,68 @@ frontend-web/src/maf-roundtable/
|
||||
- 并行 Agent 写同一目标时不会互相覆盖。
|
||||
- 高风险写入在相应策略下能暂停和恢复。
|
||||
- 每个外部动作可追溯到用户、会商、run、Agent 和工具调用。
|
||||
- `target_key`、内部幂等键和明文目标标识均不从公开 API、事件、日志或前端状态泄露;Phase-5 迁移能在项目支持的 SQLite、PostgreSQL、MySQL 上幂等执行。
|
||||
- 审批即使无人刷新页面也会在到期后被后台任务关闭,且完整工作流入口到 HTTP 决策、真实 ToolNode 的链路经一次性测试验证。
|
||||
|
||||
### 阶段 6:前端工作台
|
||||
|
||||
阶段难度:**D4(较难)**
|
||||
建议负责人:熟悉 React、TanStack Query、SSE 状态归并和复杂工作台交互的中高级前端开发
|
||||
建议负责人:熟悉 React、TanStack Query、轮询事件归并和复杂工作台交互的中高级前端开发
|
||||
前置依赖:阶段 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 和关键交互编写前端测试。
|
||||
组件与实现约束:
|
||||
|
||||
- 新 MAF 工作台保持独立路由和独立目录,**不修改**原多智能体会商的页面、组件、状态或视觉。
|
||||
- 所有新增的可视 UI 组件优先使用项目本地已安装的 `tdesign-react`(React TDesign);可沿用项目既有的 TDesign 用法与主题配置。
|
||||
- 不得接入远程 UI 组件库、CDN 样式/脚本、在线组件生成服务或运行时远程加载的组件。图标、样式和交互资源必须随前端构建产物离线交付。
|
||||
- 第一版实时更新采用已确定的**纯前端轮询**:基于 `GET events?after_seq=N` 补拉、去重和游标推进;本阶段不新增 SSE 端点。若后续改用 SSE,另立任务并保持轮询回退路径。
|
||||
- 使用 TDesign 时应复用 `Dialog`、`Form`、`Input`、`Select`、`Checkbox`、`Button`、`Tag`、`Card`、`Timeline`、`Loading`、`Message` 等本地组件;业务状态、权限判断、事件归并仍由 MAF 专属 hooks/API 层负责,不能写入组件内部的临时全局状态。
|
||||
|
||||
当前代码状态:已有独立 `frontend-web/src/maf-roundtable/`、`MafRoutes.tsx`、`/page/maf/*`、会话/运行页、轮询事件 hook、审批卡和 19 项逻辑测试。这些只能视作可编译原型,不能直接作为阶段 6 交付:当前页面主要使用既有 shadcn/Radix 封装,且直接引用 `roundtable-planning/lib/use-desensitize-subtree`,均与本阶段的独立性和 React TDesign 约束不一致。
|
||||
|
||||
任务:
|
||||
|
||||
- [ ] `MAF-06-00`(D4,部分完成)保留已有 MAF 专属目录、query key 和 `/page/maf/*`,把直接引用旧会商目录的脱敏 hook 抽到中性共享位置或通过公共服务使用;证明旧会商页面、hooks、store、草稿与路由零改动。
|
||||
- [ ] `MAF-06-01`(D3,部分完成)保留现有独立菜单、健康门和实验标识,但把新增可视 UI 统一迁移到项目已安装的本地 `tdesign-react`;不加载任何远程 UI/CDN/在线资源。
|
||||
- [ ] `MAF-06-02`(D3,部分完成)以 TDesign `Dialog`/`Form`/`Input`/`Select`/`Checkbox` 重做创建会商、模型配置和席位选择;保留经理与席位互斥、智能体 loading/error/empty 状态和 1–64 轮次校验。
|
||||
- [ ] `MAF-06-03`(D2,部分完成)以 TDesign 表单控件呈现 `structured`/`group_chat` 与写入策略;后端配置默认值必须可见,前端不得自行伪造能力快照或风险等级。
|
||||
- [x] `MAF-06-04`(D5)纯轮询事件归并已实现:断线重试、分页补拉、按 seq 去重、游标推进、终态补拉和 run/generation 隔离均已有逻辑测试。后续 UI 改造不得改弱这些语义。
|
||||
- [ ] `MAF-06-05`(D3,部分完成)以 TDesign `Timeline`/`Card`/`Tag` 重做 Agent、工具、技能和外部动作时间线;事件展示必须只读取 MAF API,且不得显示原始敏感参数。
|
||||
- [ ] `MAF-06-06`(D4,部分完成)以 TDesign `Card`/`Dialog`/`Button` 重做审批卡;批准/拒绝必须禁用重复提交、以实际 run id 刷新 actions/approvals,并展示后端的失败关闭/未知状态。澄清输入仅在后端提供正式 `waiting_input` 契约后接入,不能先造假接口。
|
||||
- [ ] `MAF-06-07`(D3,部分完成)以本地 TDesign 呈现事实账本、最终结论和产物;明确区分“候选事实”“已确认事实”“待审批动作”“已执行动作”和“失败/未知动作”。
|
||||
- [ ] `MAF-06-08`(D4)补齐取消、审批后恢复、失败后创建新 run/重试的交互与状态禁用;不存在的后端 retry 接口不得由前端伪造为已恢复。
|
||||
- [ ] `MAF-06-09`(D4,部分完成)完成刷新恢复、会话历史、run 深链和跨页面返回;切换 run 时不串台,终态页面必须补齐完整历史。
|
||||
- [ ] `MAF-06-10`(D4,部分完成)保留现有 19 项纯逻辑测试,并新增挂载 hook/组件级 API mock 测试:慢请求切换 run、终态补拉、审批重复点击、health 缺密钥提示、取消/恢复和错误状态。完整 `pnpm typecheck` 的既有非 MAF 错误需单独基线记录,不能以它们掩盖 MAF 新错误。
|
||||
|
||||
退出标准:
|
||||
|
||||
- 刷新、切页、重新登录后能恢复当前运行。
|
||||
- 工具和技能调用过程可见。
|
||||
- 外部写入状态不会仅显示在聊天文本中。
|
||||
- 页面不请求或加载任何远程 UI 组件、CDN 样式或脚本;断网环境下仍可完整呈现 MAF 工作台。
|
||||
- 新增工作台 UI 组件来自本地 `tdesign-react` 或项目已有本地基础组件,未引入第二套远程 UI 依赖。
|
||||
- 原会商页面视觉和交互没有变化。
|
||||
|
||||
### 阶段 6.5:旧会商体验融合(阶段 6 验收后)
|
||||
|
||||
阶段难度:**D4(较难)**
|
||||
建议负责人:熟悉现有会商产品交互、React 状态边界和 React TDesign 的中高级前端开发
|
||||
前置依赖:阶段 5 的审批闭环验收、阶段 6 的独立工作台验收
|
||||
主要评价点:保留用户认可的体验,不把旧业务状态或旧 API 重新耦合进 MAF
|
||||
|
||||
目标是让 MAF 页面借鉴旧会商的视觉层级、意图理解入口和“总控智能体派活”的可理解性,而不是把旧页面或其数据层搬过来。技术 session/thread 是 MAF 内部实现,普通用户不需要看见“创建线程”。
|
||||
|
||||
- [ ] `MAF-06.5-01`(D3)梳理旧会商的用户已认可布局、文案、信息层级和关键交互,形成只读视觉/交互对照清单;不复制旧的 store、jobs、草稿或 API 调用。
|
||||
- [ ] `MAF-06.5-02`(D4)以本地 `tdesign-react` 重构 MAF 创建入口:用户输入任务/意图后,由 MAF 后端静默创建 session、冻结能力快照并启动 run,不展示底层 thread 创建步骤。
|
||||
- [ ] `MAF-06.5-03`(D4)把旧体验中的“意图理解 → 总控智能体分工 → 席位执行 → 会商结论”映射到 MAF 的 planner/manager、事实账本和事件时间线;映射只在 MAF view-model/API adapter 层完成。
|
||||
- [ ] `MAF-06.5-04`(D4)对照旧会商完成视觉和交互回归:新旧两个入口、路由、草稿和运行状态完全独立;关闭 `maf_roundtable.enabled` 后旧会商零变化。
|
||||
|
||||
### 阶段 7:对比评测
|
||||
|
||||
阶段难度:**D3(中等)**
|
||||
建议负责人:具备数据分析意识、理解 Agent 错误分类的全栈/测试开发/算法工程师
|
||||
前置依赖:阶段 3、阶段 4;指标接口可提前开发
|
||||
前置依赖:阶段 3、阶段 4、阶段 5、阶段 6;指标接口可提前开发
|
||||
主要评价点:对照是否公平、指标是否可解释、是否能区分模型/工具/编排错误
|
||||
|
||||
任务:
|
||||
@@ -944,24 +1066,26 @@ frontend-web/src/maf-roundtable/
|
||||
## 15. 发布与回退
|
||||
|
||||
- 配置项 `maf_roundtable.enabled` 控制后端 API 与前端入口。
|
||||
- `write_guard.enabled` 已设为 `true`,但只有带 `maf_run_id` 的新 MAF 会商进入 WriteGuard;旧 DeerFlow、旧 roundtable 和 AI 写作保持透传。
|
||||
- 上线前由启动环境注入 `MAF_ACTION_SECRET_KEY`;不在仓库或配置文件保存该值。访问 `/api/maf-roundtable/health`,确认 `write_guard_status=ready`、`write_guard_ready=true` 后,才允许验证真实外部写入。
|
||||
- 上线顺序:先部署只读 MAF 与精确工具元数据 → 注入密钥并检查健康状态 → 在 UAT/测试租户启用已指定的真实写工具 → 验证审批、恢复、拒绝、重启和 unknown 场景 → 再扩大灰度。
|
||||
- 首期仅管理员或指定白名单可见。
|
||||
- 关闭入口时,已有运行可选择继续完成或管理员取消。
|
||||
- 回退只需关闭特性开关和停止新 MAF jobs;旧 roundtable 不需要迁移或回滚。
|
||||
- 出现异常时先关闭 `maf_roundtable.enabled` 停止新的 MAF 写入;必要时再关闭 `write_guard.enabled`。若密钥或外部系统异常,保持 MAF 开启也会因失败关闭而拒绝写操作。旧 roundtable 不需要迁移或回滚。
|
||||
- MAF 独立表保留用于审计,不在代码回退时删除。
|
||||
- MAF SDK 使用延迟导入,关闭功能时不影响 Gateway 启动。
|
||||
|
||||
## 16. 推荐开发顺序与工作量级
|
||||
|
||||
建议严格按以下顺序推进:
|
||||
完整项目的依赖顺序仍是阶段 0 → 8;但基于当前代码盘点,**当前恢复开发的顺序**调整为:
|
||||
|
||||
1. 依赖/模型/离线 wheel Spike。
|
||||
2. 独立 session、event 和 SSE 骨架。
|
||||
3. DeerFlow AgentAdapter,先跑通真实工具、技能和一次外部写入。
|
||||
4. 固定流程编排。
|
||||
5. 动态 Group Chat。
|
||||
6. Action Coordinator、审批和恢复。
|
||||
7. 前端完整工作台。
|
||||
8. 对比评测、回归和灰度。
|
||||
1. 阶段 5:先完成 target-key 租约锁和 HTTP 审批恢复全链路;真实 UAT 工具仅在业务方提供协议后接入。
|
||||
2. 阶段 4:与阶段 5 的无外部依赖任务并行,补齐真实多轮 Group Chat、selector 和弱模型收敛。
|
||||
3. 阶段 6:在阶段 5 的动作/API 契约稳定后,将现有原型改为本地 React TDesign 工作台并完成组件级测试。
|
||||
4. 阶段 6.5:以独立 MAF view-model 复用旧会商的视觉与交互思路,隐藏技术 session/thread,不复用旧状态。
|
||||
5. 阶段 0 遗留项和阶段 1–3 的真实环境演示验收可穿插进行,但不再阻塞上述代码收口。
|
||||
6. 阶段 7:在两种编排和前端均验收后做公平对比。
|
||||
7. 阶段 8:最后完成全量回归、离线安装、灰度和观测。
|
||||
|
||||
在熟悉当前代码的前提下,这是一个中等偏大的完整功能,建议按 3 个可演示里程碑交付:
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, Network, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
|
||||
|
||||
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
@@ -196,6 +196,18 @@ export function WorkspaceNavChatList({
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/maf")}
|
||||
asChild
|
||||
className={NAV_ITEM_CLASS}
|
||||
>
|
||||
<Link href="/page/maf">
|
||||
<Network />
|
||||
<span>MAF 会商</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
List,
|
||||
ListChecks,
|
||||
ListTodo,
|
||||
MessageSquareText,
|
||||
Network,
|
||||
Radar,
|
||||
Settings,
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* MAF 会商前端回归测试。
|
||||
*
|
||||
* 运行(当前前端未引入 Vitest):
|
||||
* pnpm exec esbuild src/maf-roundtable/__tests__/maf-roundtable.test.ts --bundle --platform=node --format=esm --outfile=.maf-roundtable.test.mjs --banner:js="import.meta.env={};"
|
||||
* node --test .maf-roundtable.test.mjs
|
||||
*
|
||||
* 覆盖:
|
||||
* 1. 经理与席位重复 → participants 构建时自动排除
|
||||
* 2. 事件翻页 → 超过 PAGE_LIMIT 时 mergeBySeq 不丢不重
|
||||
* 3. 终态历史 → 终态 run 的事件仍被折叠为时间线
|
||||
* 4. 延迟出现的审批卡 → pending 审批在 actions 到达后正确关联
|
||||
* 5. 风险等级映射 → R0–R4 正确映射
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, test } from "node:test";
|
||||
|
||||
// @ts-ignore Node's test runner resolves .ts directly.
|
||||
import { foldEventsToTimeline, type TimelineEntry } from "../lib/event-timeline.ts";
|
||||
// @ts-ignore
|
||||
import { runIsLive, type MafEvent } from "../api/types.ts";
|
||||
// @ts-ignore Node's test runner resolves .ts directly.
|
||||
import { createMafEventRequestCoordinator } from "../hooks/use-maf-events.ts";
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeEvent(overrides: Partial<MafEvent> & { seq: number }): MafEvent {
|
||||
return {
|
||||
id: `ev-${overrides.seq}`,
|
||||
session_id: "s1",
|
||||
run_id: "r1",
|
||||
event_type: "agent.message.delta",
|
||||
agent_id: "agent-a",
|
||||
turn_index: 0,
|
||||
payload: { text: `msg-${overrides.seq}` },
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** 模拟 mergeBySeq(与 hook 内部逻辑一致)。 */
|
||||
function mergeBySeq(prev: MafEvent[], incoming: MafEvent[]): MafEvent[] {
|
||||
if (incoming.length === 0) return prev;
|
||||
const bySeq = new Map<number, MafEvent>();
|
||||
for (const e of prev) bySeq.set(e.seq, e);
|
||||
for (const e of incoming) bySeq.set(e.seq, e);
|
||||
return [...bySeq.values()].sort((a, b) => a.seq - b.seq);
|
||||
}
|
||||
|
||||
// ── 1. 经理与席位重复 ──────────────────────────────────────────────────────
|
||||
|
||||
describe("participants 构建:经理与席位互斥", () => {
|
||||
test("切换经理后,席位列表自动排除该 agent", () => {
|
||||
// 模拟 UI 逻辑:managerAgentId 变化时 filter
|
||||
const seatAgentIds = ["a1", "a2", "a3"];
|
||||
const managerAgentId = "a2";
|
||||
const filtered = seatAgentIds.filter((id) => id !== managerAgentId);
|
||||
assert.deepEqual(filtered, ["a1", "a3"]);
|
||||
assert.ok(!filtered.includes(managerAgentId));
|
||||
});
|
||||
|
||||
test("toggleSeat 拒绝添加当前经理", () => {
|
||||
const managerAgentId = "a1";
|
||||
const seatAgentIds = ["a2", "a3"];
|
||||
// 模拟 toggleSeat 逻辑
|
||||
const agentId = "a1";
|
||||
const result = agentId === managerAgentId
|
||||
? seatAgentIds // 不变
|
||||
: seatAgentIds.includes(agentId)
|
||||
? seatAgentIds.filter((id) => id !== agentId)
|
||||
: [...seatAgentIds, agentId];
|
||||
assert.deepEqual(result, ["a2", "a3"]);
|
||||
assert.ok(!result.includes("a1"));
|
||||
});
|
||||
});
|
||||
|
||||
// ── 2. 事件翻页(超过 PAGE_LIMIT 不丢不重)─────────────────────────────────
|
||||
|
||||
describe("事件翻页 mergeBySeq", () => {
|
||||
test("两页各 1000 条,合并后 2000 条无重复", () => {
|
||||
const page1 = Array.from({ length: 1000 }, (_, i) => makeEvent({ seq: i + 1 }));
|
||||
const page2 = Array.from({ length: 1000 }, (_, i) => makeEvent({ seq: i + 1001 }));
|
||||
|
||||
let merged: MafEvent[] = [];
|
||||
merged = mergeBySeq(merged, page1);
|
||||
assert.equal(merged.length, 1000);
|
||||
merged = mergeBySeq(merged, page2);
|
||||
assert.equal(merged.length, 2000);
|
||||
// 验证升序
|
||||
for (let i = 1; i < merged.length; i++) {
|
||||
assert.ok(merged[i].seq > merged[i - 1].seq, `seq not ascending at ${i}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("重叠页(网络重传)不产生重复", () => {
|
||||
const page1 = Array.from({ length: 100 }, (_, i) => makeEvent({ seq: i + 1 }));
|
||||
// 第二页重叠了 seq 91-100,并新增 101-120
|
||||
const page2 = Array.from({ length: 30 }, (_, i) => makeEvent({ seq: i + 91 }));
|
||||
|
||||
let merged: MafEvent[] = [];
|
||||
merged = mergeBySeq(merged, page1);
|
||||
merged = mergeBySeq(merged, page2);
|
||||
assert.equal(merged.length, 120);
|
||||
});
|
||||
|
||||
test("游标只推进到实际收到的最大 seq", () => {
|
||||
// 模拟 fetchBacklog 的游标推进逻辑
|
||||
const page = [makeEvent({ seq: 5 }), makeEvent({ seq: 3 }), makeEvent({ seq: 7 })];
|
||||
let cursor = 0;
|
||||
let maxSeq = cursor;
|
||||
for (const e of page) {
|
||||
if (e.seq > maxSeq) maxSeq = e.seq;
|
||||
}
|
||||
assert.equal(maxSeq, 7);
|
||||
// 不是 latest_seq(可能是 100),而是实际最大 seq
|
||||
assert.ok(maxSeq <= 7);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 3. 终态历史(终态 run 的事件仍被折叠)──────────────────────────────────
|
||||
|
||||
describe("终态 run 事件折叠", () => {
|
||||
test("completed run 的事件正常折叠为时间线", () => {
|
||||
const events: MafEvent[] = [
|
||||
makeEvent({ seq: 1, event_type: "run.started", payload: {} }),
|
||||
makeEvent({ seq: 2, event_type: "agent.message.delta", payload: { text: "Hello " } }),
|
||||
makeEvent({ seq: 3, event_type: "agent.message.delta", payload: { text: "world" } }),
|
||||
makeEvent({ seq: 4, event_type: "agent.message.completed", payload: { content: "Hello world" } }),
|
||||
makeEvent({ seq: 5, event_type: "run.completed", payload: {} }),
|
||||
];
|
||||
|
||||
const timeline = foldEventsToTimeline(events);
|
||||
assert.ok(timeline.length >= 3, `expected >=3 entries, got ${timeline.length}`);
|
||||
|
||||
// run.started 和 run.completed 应存在
|
||||
const kinds = timeline.map((e) => e.eventType);
|
||||
assert.ok(kinds.includes("run.started"));
|
||||
assert.ok(kinds.includes("run.completed"));
|
||||
|
||||
// delta 聚合为一条 message
|
||||
const messages = timeline.filter((e) => e.kind === "message");
|
||||
assert.equal(messages.length, 1);
|
||||
assert.equal(messages[0].body, "Hello world");
|
||||
});
|
||||
|
||||
test("runIsLive 对终态返回 false", () => {
|
||||
assert.equal(runIsLive("completed"), false);
|
||||
assert.equal(runIsLive("failed"), false);
|
||||
assert.equal(runIsLive("cancelled"), false);
|
||||
assert.equal(runIsLive(null), false);
|
||||
assert.equal(runIsLive(undefined), false);
|
||||
});
|
||||
|
||||
test("runIsLive 对活跃态返回 true", () => {
|
||||
assert.equal(runIsLive("running"), true);
|
||||
assert.equal(runIsLive("pending"), true);
|
||||
assert.equal(runIsLive("waiting_approval"), true);
|
||||
assert.equal(runIsLive("cancelling"), true);
|
||||
assert.equal(runIsLive("cancel_failed"), true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 4. 延迟出现的审批卡 ────────────────────────────────────────────────────
|
||||
|
||||
describe("审批关联", () => {
|
||||
test("pending 审批在 actions 到达后正确关联", () => {
|
||||
// 模拟 indexApprovalsByAction + indexActions
|
||||
const approvals = [
|
||||
{ id: "ap1", action_id: "act1", status: "pending", summary: "写入文件" },
|
||||
{ id: "ap2", action_id: null, status: "pending", summary: "无关联动作" },
|
||||
];
|
||||
const actions = [
|
||||
{ id: "act1", tool_name: "write_file", risk_level: "R3", arguments: { path: "/tmp/x" } },
|
||||
];
|
||||
|
||||
// indexApprovalsByAction:只索引有 action_id 的 pending
|
||||
const byAction = new Map<string, typeof approvals[0]>();
|
||||
for (const a of approvals) {
|
||||
if (a.status === "pending" && a.action_id) byAction.set(a.action_id, a);
|
||||
}
|
||||
assert.equal(byAction.size, 1);
|
||||
assert.ok(byAction.has("act1"));
|
||||
assert.ok(!byAction.has("")); // null action_id 不索引
|
||||
|
||||
// indexActions
|
||||
const actMap = new Map<string, typeof actions[0]>();
|
||||
for (const a of actions) actMap.set(a.id, a);
|
||||
|
||||
// 卡片取 action
|
||||
const ap1 = approvals[0];
|
||||
const linkedAction = ap1.action_id ? actMap.get(ap1.action_id) : undefined;
|
||||
assert.equal(linkedAction?.tool_name, "write_file");
|
||||
assert.equal(linkedAction?.risk_level, "R3");
|
||||
|
||||
// 无 action_id 的审批不报错
|
||||
const ap2 = approvals[1];
|
||||
const noAction = ap2.action_id ? actMap.get(ap2.action_id) : undefined;
|
||||
assert.equal(noAction, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 5. 风险等级映射 R0–R4 ─────────────────────────────────────────────────
|
||||
|
||||
describe("风险等级映射", () => {
|
||||
const RISK_TONE: Record<string, string> = {
|
||||
R0: "text-emerald-600 bg-emerald-50",
|
||||
R1: "text-emerald-600 bg-emerald-50",
|
||||
R2: "text-amber-600 bg-amber-50",
|
||||
R3: "text-red-600 bg-red-50",
|
||||
R4: "text-red-600 bg-red-50",
|
||||
low: "text-emerald-600 bg-emerald-50",
|
||||
medium: "text-amber-600 bg-amber-50",
|
||||
high: "text-red-600 bg-red-50",
|
||||
};
|
||||
|
||||
test("R0/R1 → 低风险(绿)", () => {
|
||||
assert.ok(RISK_TONE["R0"].includes("emerald"));
|
||||
assert.ok(RISK_TONE["R1"].includes("emerald"));
|
||||
});
|
||||
|
||||
test("R2 → 中风险(琥珀)", () => {
|
||||
assert.ok(RISK_TONE["R2"].includes("amber"));
|
||||
});
|
||||
|
||||
test("R3/R4 → 高风险(红)", () => {
|
||||
assert.ok(RISK_TONE["R3"].includes("red"));
|
||||
assert.ok(RISK_TONE["R4"].includes("red"));
|
||||
});
|
||||
|
||||
test("未知等级有兜底", () => {
|
||||
// 未知等级不在 map 中,组件用 ?? 兜底
|
||||
const fallback = RISK_TONE["R99"] ?? "text-slate-500 bg-slate-50";
|
||||
assert.ok(fallback.includes("slate"));
|
||||
});
|
||||
});
|
||||
|
||||
// ── 6. 快速切换 run 时事件不串台(generation 隔离逻辑验证)─────────────────
|
||||
|
||||
describe("事件流代次隔离", () => {
|
||||
test("generation 递增后旧回调被丢弃", () => {
|
||||
// 模拟 generationRef 逻辑
|
||||
let generation = 0;
|
||||
const results: string[] = [];
|
||||
|
||||
// 模拟 run A 的请求(慢)
|
||||
const genA = generation;
|
||||
// 切换到 run B → generation++
|
||||
generation += 1;
|
||||
const genB = generation;
|
||||
|
||||
// run A 的响应到达(检查代次)
|
||||
if (generation === genA) {
|
||||
results.push("A-stale");
|
||||
}
|
||||
// run B 的响应到达
|
||||
if (generation === genB) {
|
||||
results.push("B-fresh");
|
||||
}
|
||||
|
||||
assert.deepEqual(results, ["B-fresh"]);
|
||||
assert.ok(!results.includes("A-stale"));
|
||||
});
|
||||
|
||||
test("同一代次内多次 poll 正常累积", () => {
|
||||
let generation = 0;
|
||||
const gen = generation;
|
||||
const events: number[] = [];
|
||||
|
||||
// 同一代次内三次 poll
|
||||
for (const seq of [1, 2, 3]) {
|
||||
if (generation === gen) {
|
||||
events.push(seq);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(events, [1, 2, 3]);
|
||||
});
|
||||
|
||||
test("旧请求延迟返回时,终态新请求仍执行且旧事件被丢弃", async () => {
|
||||
const coordinator = createMafEventRequestCoordinator();
|
||||
let resolveOld!: () => void;
|
||||
const oldResponse = new Promise<void>((resolve) => {
|
||||
resolveOld = resolve;
|
||||
});
|
||||
const acceptedEvents: number[] = [];
|
||||
let latestSeq = 0;
|
||||
const acceptPage = (generation: number, page: number[]) => {
|
||||
if (!coordinator.isCurrent(generation)) return;
|
||||
acceptedEvents.push(...page);
|
||||
latestSeq = Math.max(latestSeq, ...page);
|
||||
};
|
||||
|
||||
const oldGeneration = coordinator.nextGeneration();
|
||||
assert.equal(coordinator.start(oldGeneration), true);
|
||||
const oldRequest = (async () => {
|
||||
await oldResponse;
|
||||
// This is the delayed live poll response from the old run.
|
||||
acceptPage(oldGeneration, [99]);
|
||||
coordinator.finish(oldGeneration);
|
||||
})();
|
||||
|
||||
// running -> completed creates a new generation. Its backlog request must
|
||||
// not see the stale generation's in-flight slot.
|
||||
const terminalGeneration = coordinator.nextGeneration();
|
||||
assert.equal(coordinator.start(terminalGeneration), true);
|
||||
// The terminal request starts from cursor 0 and returns the complete
|
||||
// history, even though the old live request is still unresolved.
|
||||
acceptPage(terminalGeneration, [1, 2, 3]);
|
||||
coordinator.finish(terminalGeneration);
|
||||
|
||||
resolveOld();
|
||||
await oldRequest;
|
||||
|
||||
assert.deepEqual(acceptedEvents, [1, 2, 3]);
|
||||
assert.equal(latestSeq, 3);
|
||||
assert.equal(coordinator.start(terminalGeneration), true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 7. maxRounds 校验 ─────────────────────────────────────────────────────
|
||||
|
||||
describe("maxRounds 前端校验", () => {
|
||||
function validateMaxRounds(input: string): { valid: boolean; value?: number } {
|
||||
if (!input.trim()) return { valid: true }; // 空 = 使用默认
|
||||
const n = Number(input);
|
||||
if (!Number.isInteger(n) || n < 1 || n > 64) return { valid: false };
|
||||
return { valid: true, value: n };
|
||||
}
|
||||
|
||||
test("空字符串 → 有效(使用默认)", () => {
|
||||
assert.ok(validateMaxRounds("").valid);
|
||||
assert.ok(validateMaxRounds(" ").valid);
|
||||
});
|
||||
|
||||
test("1–64 整数 → 有效", () => {
|
||||
assert.ok(validateMaxRounds("1").valid);
|
||||
assert.ok(validateMaxRounds("64").valid);
|
||||
assert.ok(validateMaxRounds("32").valid);
|
||||
});
|
||||
|
||||
test("0 / 65 / 负数 / 小数 / 非数字 → 无效", () => {
|
||||
assert.ok(!validateMaxRounds("0").valid);
|
||||
assert.ok(!validateMaxRounds("65").valid);
|
||||
assert.ok(!validateMaxRounds("-1").valid);
|
||||
assert.ok(!validateMaxRounds("3.5").valid);
|
||||
assert.ok(!validateMaxRounds("abc").valid);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* MAF 审批 —— `/runs/{id}/approvals`、`/approvals/{id}/decision`、`/runs/{id}/actions`。
|
||||
*
|
||||
* decide 成功后 invalidate approvals + actions + events(事件轮询会自然看到
|
||||
* action.approved/rejected,但显式 invalidate 让卡片状态即时翻转)。
|
||||
*
|
||||
* P0-4:hook 签名改为 (runId, isLive)。run 运行期间每 2s 刷新一次;终态停止。
|
||||
* 不能以"当前有没有 pending 审批"决定是否刷新——审批通常是进入页面后才由工作流产生的。
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { BASE, getJson, sendJson } from "./client";
|
||||
import type {
|
||||
MafAction,
|
||||
MafActionListResponse,
|
||||
MafApproval,
|
||||
MafApprovalDecisionRequest,
|
||||
MafApprovalListResponse,
|
||||
} from "./types";
|
||||
|
||||
const KEY = "maf-roundtable";
|
||||
|
||||
/** live 时审批/动作轮询间隔(ms)。 */
|
||||
const LIVE_POLL_INTERVAL = 2000;
|
||||
|
||||
// ── 裸 fetch 函数 ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function listApprovals(runId: string): Promise<MafApprovalListResponse> {
|
||||
return getJson<MafApprovalListResponse>(
|
||||
`${BASE()}/runs/${runId}/approvals`,
|
||||
"获取审批列表",
|
||||
);
|
||||
}
|
||||
|
||||
export async function listActions(runId: string): Promise<MafActionListResponse> {
|
||||
return getJson<MafActionListResponse>(
|
||||
`${BASE()}/runs/${runId}/actions`,
|
||||
"获取动作列表",
|
||||
);
|
||||
}
|
||||
|
||||
export async function decideApproval(
|
||||
approvalId: string,
|
||||
body: MafApprovalDecisionRequest,
|
||||
): Promise<MafApproval> {
|
||||
return sendJson<MafApproval>(
|
||||
`${BASE()}/approvals/${approvalId}/decision`,
|
||||
"POST",
|
||||
body,
|
||||
"提交审批决策",
|
||||
);
|
||||
}
|
||||
|
||||
// ── React Query hooks ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 审批列表。run live 时每 LIVE_POLL_INTERVAL 刷新;终态停止。
|
||||
* 不能以"当前有没有 pending"决定是否刷新——审批是进入页面后才产生的。
|
||||
*/
|
||||
export function useMafApprovals(runId: string | undefined, isLive: boolean) {
|
||||
return useQuery({
|
||||
queryKey: [KEY, "runs", runId, "approvals"],
|
||||
queryFn: () => listApprovals(runId!),
|
||||
enabled: !!runId,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: isLive ? LIVE_POLL_INTERVAL : false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 动作列表。run live 时每 LIVE_POLL_INTERVAL 刷新;终态停止。
|
||||
*/
|
||||
export function useMafActions(runId: string | undefined, isLive: boolean) {
|
||||
return useQuery({
|
||||
queryKey: [KEY, "runs", runId, "actions"],
|
||||
queryFn: () => listActions(runId!),
|
||||
enabled: !!runId,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: isLive ? LIVE_POLL_INTERVAL : false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMafApprovalActions(runId: string | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
return {
|
||||
decide: useMutation({
|
||||
mutationFn: ({
|
||||
approvalId,
|
||||
decision,
|
||||
}: {
|
||||
approvalId: string;
|
||||
decision: "approved" | "rejected";
|
||||
}) => decideApproval(approvalId, { decision }),
|
||||
onSuccess: () => {
|
||||
if (!runId) return;
|
||||
queryClient.invalidateQueries({ queryKey: [KEY, "runs", runId, "approvals"] });
|
||||
queryClient.invalidateQueries({ queryKey: [KEY, "runs", runId, "actions"] });
|
||||
// run 状态会因 resume 改变(approved → running),强制下一次轮询立即生效。
|
||||
queryClient.invalidateQueries({ queryKey: [KEY, "runs", runId] });
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 辅助:按 action_id 把 approval 和 action 关联起来 ─────────────────────
|
||||
|
||||
/** 把 pending 审批按 action_id 索引,便于卡片查找对应 action 的工具名/参数。 */
|
||||
export function indexApprovalsByAction(
|
||||
approvals: MafApproval[] | undefined,
|
||||
): Map<string, MafApproval> {
|
||||
const map = new Map<string, MafApproval>();
|
||||
for (const a of approvals ?? []) {
|
||||
if (a.status === "pending" && a.action_id) map.set(a.action_id, a);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 按 id 索引 action,供审批卡片取 tool_name/arguments。 */
|
||||
export function indexActions(
|
||||
actions: MafAction[] | undefined,
|
||||
): Map<string, MafAction> {
|
||||
const map = new Map<string, MafAction>();
|
||||
for (const a of actions ?? []) map.set(a.id, map.get(a.id) ?? a);
|
||||
return map;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* MAF 多智能体会商 API 客户端地基。
|
||||
*
|
||||
* 复用全局 `apiFetch`(自动注入 Authorization + credentials:include),这里只做:
|
||||
* - 拼 URL(`${getBackendBaseURL()}/api/maf-roundtable/...`)
|
||||
* - 统一 `res.ok` 检查 + detail 提取(仿 task-buttons 的 readJson)
|
||||
* - JSON 解析
|
||||
*
|
||||
* 不在此做 snake_case ⇄ camelCase 转换——后端即 snake_case,类型层直传(见 types.ts)。
|
||||
*/
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
/** MAF roundtable 后端根路径(无尾斜杠)。 */
|
||||
export const BASE = (): string => `${getBackendBaseURL()}/api/maf-roundtable`;
|
||||
|
||||
/**
|
||||
* 检查 Response 并解析 JSON;失败时抛出带后端 `detail` 的 Error。
|
||||
*
|
||||
* - 404 = 不存在或非本人(后端统一返回 404 防止泄漏他人资源存在性)
|
||||
* - 403 = 非所有者写(admin 也不能写别人的)
|
||||
* - 503 = 功能关闭 / store 未就绪
|
||||
*
|
||||
* 这里不区分状态码语义,统一把 detail 透传给上层 toast/错误展示。
|
||||
*/
|
||||
export async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
const detail =
|
||||
data && typeof data === "object" && "detail" in data
|
||||
? String((data as { detail: unknown }).detail)
|
||||
: `${ctx} 失败(HTTP ${res.status})`;
|
||||
throw new Error(detail);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
/** GET JSON 的快捷封装。 */
|
||||
export async function getJson<T>(url: string, ctx: string): Promise<T> {
|
||||
const res = await apiFetch(url, { method: "GET" });
|
||||
return readJson<T>(res, ctx);
|
||||
}
|
||||
|
||||
/** 发送 JSON body 的快捷封装。 */
|
||||
export async function sendJson<T>(
|
||||
url: string,
|
||||
method: "POST" | "PUT" | "DELETE" | "PATCH",
|
||||
body: unknown,
|
||||
ctx: string,
|
||||
): Promise<T> {
|
||||
const res = await apiFetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
return readJson<T>(res, ctx);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* MAF 事件断线补拉 —— `GET /runs/{id}/events?after_seq=N&limit=...`。
|
||||
*
|
||||
* 这是**裸 fetch 函数**(非 hook):cursor `after_seq` 需在每次拉取后推进,
|
||||
* useQuery 的 queryFn 不持有「上次 latest_seq」,所以轮询逻辑放在
|
||||
* hooks/use-maf-events.ts(自定义 interval hook),这里只负责一次请求。
|
||||
*
|
||||
* 后端契约(events.py list_events):返回 `seq > after_seq` 升序;payload 已脱敏。
|
||||
*/
|
||||
|
||||
import { BASE, getJson } from "./client";
|
||||
import type { MafEventListResponse } from "./types";
|
||||
|
||||
/**
|
||||
* 拉取 `after_seq` 之后的事件(不含 after_seq 本身)。
|
||||
*
|
||||
* @param runId run id
|
||||
* @param afterSeq 上一次拉到的 latest_seq;首次传 0 补拉全部历史
|
||||
* @param limit 单次上限,默认 1000、最大 10000(后端 Query 约束)
|
||||
*/
|
||||
export async function listEvents(
|
||||
runId: string,
|
||||
afterSeq = 0,
|
||||
limit = 1000,
|
||||
): Promise<MafEventListResponse> {
|
||||
const qs = new URLSearchParams({
|
||||
after_seq: String(afterSeq),
|
||||
limit: String(limit),
|
||||
});
|
||||
return getJson<MafEventListResponse>(
|
||||
`${BASE()}/runs/${runId}/events?${qs.toString()}`,
|
||||
"获取会商事件",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* MAF run 生命周期 + health 探测 —— `/sessions/{id}/runs`、`/runs/{id}`、`/health`。
|
||||
*
|
||||
* 轮询策略:
|
||||
* - `useMafRun` 用函数式 `refetchInterval`:run 仍 live(pending/running/
|
||||
* waiting_approval/cancelling/cancel_failed)时每 1.8s 重拉;终态停。
|
||||
* 镜像 core/scheduled-tasks/hooks.ts:105-115 的模式。
|
||||
* - events 轮询不在此处,见 hooks/use-maf-events.ts(cursor 推进需自定义 hook)。
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { BASE, getJson, sendJson } from "./client";
|
||||
import { runIsLive, type MafHealthResponse, type MafRun, type MafRunListResponse } from "./types";
|
||||
|
||||
const KEY = "maf-roundtable";
|
||||
|
||||
/** run 轮询间隔(ms)。1.8s —— 平衡实时感与后端负载。 */
|
||||
export const RUN_POLL_INTERVAL = 1800;
|
||||
|
||||
// ── health ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getMafHealth(): Promise<MafHealthResponse> {
|
||||
return getJson<MafHealthResponse>(`${BASE()}/health`, "获取 MAF 健康状态");
|
||||
}
|
||||
|
||||
/** health 探测:决定入口页是否显示「功能未启用」空态。失败时视为 disabled。 */
|
||||
export function useMafHealth() {
|
||||
return useQuery({
|
||||
queryKey: [KEY, "health"],
|
||||
queryFn: getMafHealth,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 60_000,
|
||||
retry: false, // 探测失败不重试,直接走 disabled 空态
|
||||
});
|
||||
}
|
||||
|
||||
// ── 裸 fetch 函数 ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function getRun(runId: string): Promise<MafRun> {
|
||||
return getJson<MafRun>(`${BASE()}/runs/${runId}`, "获取会商 run");
|
||||
}
|
||||
|
||||
export async function listRuns(sessionId: string): Promise<MafRunListResponse> {
|
||||
return getJson<MafRunListResponse>(
|
||||
`${BASE()}/sessions/${sessionId}/runs`,
|
||||
"获取 run 列表",
|
||||
);
|
||||
}
|
||||
|
||||
export async function createRun(
|
||||
sessionId: string,
|
||||
workflowVersion?: string,
|
||||
): Promise<MafRun> {
|
||||
return sendJson<MafRun>(
|
||||
`${BASE()}/sessions/${sessionId}/runs`,
|
||||
"POST",
|
||||
workflowVersion ? { workflow_version: workflowVersion } : {},
|
||||
"启动会商 run",
|
||||
);
|
||||
}
|
||||
|
||||
export async function cancelRun(runId: string): Promise<MafRun> {
|
||||
return sendJson<MafRun>(`${BASE()}/runs/${runId}/cancel`, "POST", {}, "取消会商");
|
||||
}
|
||||
|
||||
export async function resumeRun(runId: string): Promise<MafRun> {
|
||||
return sendJson<MafRun>(`${BASE()}/runs/${runId}/resume`, "POST", {}, "恢复会商");
|
||||
}
|
||||
|
||||
export async function recoverRun(runId: string): Promise<MafRun> {
|
||||
return sendJson<MafRun>(`${BASE()}/runs/${runId}/recover`, "POST", {}, "重试会商");
|
||||
}
|
||||
|
||||
// ── React Query hooks ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 单个 run —— live 时每 1.8s 轮询,终态停。
|
||||
* 这是事件轮询的「开关」:父组件用 `runIsLive(run.status)` 决定是否拉事件。
|
||||
*/
|
||||
export function useMafRun(runId: string | undefined | null) {
|
||||
return useQuery({
|
||||
queryKey: [KEY, "runs", runId],
|
||||
queryFn: () => getRun(runId!),
|
||||
enabled: !!runId,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: (query) =>
|
||||
runIsLive(query.state.data?.status) ? RUN_POLL_INTERVAL : false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMafRuns(sessionId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [KEY, "sessions", sessionId, "runs"],
|
||||
queryFn: () => listRuns(sessionId!),
|
||||
enabled: !!sessionId,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMafRunActions() {
|
||||
const queryClient = useQueryClient();
|
||||
return {
|
||||
start: useMutation({
|
||||
mutationFn: ({ sessionId, workflowVersion }: { sessionId: string; workflowVersion?: string }) =>
|
||||
createRun(sessionId, workflowVersion),
|
||||
onSuccess: (run) => {
|
||||
queryClient.invalidateQueries({ queryKey: [KEY, "sessions", run.session_id, "runs"] });
|
||||
queryClient.invalidateQueries({ queryKey: [KEY, "runs", run.id] });
|
||||
},
|
||||
}),
|
||||
cancel: useMutation({
|
||||
mutationFn: cancelRun,
|
||||
onSuccess: (run) => {
|
||||
queryClient.invalidateQueries({ queryKey: [KEY, "runs", run.id] });
|
||||
},
|
||||
}),
|
||||
resume: useMutation({
|
||||
mutationFn: resumeRun,
|
||||
onSuccess: (run) => {
|
||||
queryClient.invalidateQueries({ queryKey: [KEY, "runs", run.id] });
|
||||
},
|
||||
}),
|
||||
recover: useMutation({
|
||||
mutationFn: recoverRun,
|
||||
onSuccess: (run) => {
|
||||
queryClient.invalidateQueries({ queryKey: [KEY, "runs", run.id] });
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* MAF 会话 CRUD —— `/sessions`。
|
||||
*
|
||||
* 镜像 strategy-components/api/task-buttons.ts 的 colocated hook 模式:
|
||||
* queryKey 用嵌套数组;mutation 成功后 invalidate 同前缀。
|
||||
* snake_case 直传(见 types.ts)。
|
||||
*
|
||||
* 唯一依据:offline-backend-20260512/backend/app/maf_roundtable/schemas.py
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { BASE, getJson, sendJson } from "./client";
|
||||
import type {
|
||||
MafSession,
|
||||
MafSessionListResponse,
|
||||
} from "./types";
|
||||
|
||||
const KEY = "maf-roundtable";
|
||||
|
||||
// ── 请求体(对齐 schemas.py CreateSessionRequest / UpdateSessionRequest)──
|
||||
|
||||
export interface ParticipantInput {
|
||||
agent_id: string;
|
||||
display_name?: string;
|
||||
role: "manager" | "seat";
|
||||
sort_order?: number;
|
||||
excluded_tools?: string[];
|
||||
}
|
||||
|
||||
export interface CreateSessionBody {
|
||||
title: string;
|
||||
task_text: string;
|
||||
task_payload?: Record<string, unknown> | null;
|
||||
mode?: "structured" | "group_chat";
|
||||
model_name?: string | null;
|
||||
reasoning_effort?: string | null;
|
||||
manager_agent_id?: string | null;
|
||||
max_rounds?: number;
|
||||
write_policy?: "auto" | "confirm_high_risk" | "confirm_all_writes";
|
||||
participants: ParticipantInput[];
|
||||
excluded_tools?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateSessionBody {
|
||||
title?: string;
|
||||
task_text?: string;
|
||||
task_payload?: Record<string, unknown> | null;
|
||||
mode?: "structured" | "group_chat";
|
||||
model_name?: string | null;
|
||||
reasoning_effort?: string | null;
|
||||
manager_agent_id?: string | null;
|
||||
max_rounds?: number;
|
||||
write_policy?: "auto" | "confirm_high_risk" | "confirm_all_writes";
|
||||
participants?: ParticipantInput[];
|
||||
fact_ledger?: Record<string, unknown> | null;
|
||||
final_result?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
// ── 裸 fetch 函数 ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function listSessions(params?: {
|
||||
status?: string;
|
||||
mode?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<MafSessionListResponse> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.status) qs.set("status", params.status);
|
||||
if (params?.mode) qs.set("mode", params.mode);
|
||||
if (params?.limit != null) qs.set("limit", String(params.limit));
|
||||
if (params?.offset != null) qs.set("offset", String(params.offset));
|
||||
const query = qs.toString();
|
||||
return getJson<MafSessionListResponse>(
|
||||
`${BASE()}/sessions${query ? `?${query}` : ""}`,
|
||||
"获取会话列表",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSession(id: string): Promise<MafSession> {
|
||||
return getJson<MafSession>(`${BASE()}/sessions/${id}`, "获取会话");
|
||||
}
|
||||
|
||||
export async function createSession(body: CreateSessionBody): Promise<MafSession> {
|
||||
return sendJson<MafSession>(`${BASE()}/sessions`, "POST", body, "创建会话");
|
||||
}
|
||||
|
||||
export async function updateSession(
|
||||
id: string,
|
||||
body: UpdateSessionBody,
|
||||
): Promise<MafSession> {
|
||||
return sendJson<MafSession>(`${BASE()}/sessions/${id}`, "PUT", body, "更新会话");
|
||||
}
|
||||
|
||||
export async function deleteSession(id: string): Promise<void> {
|
||||
await sendJson<{ deleted: boolean }>(
|
||||
`${BASE()}/sessions/${id}`,
|
||||
"DELETE",
|
||||
undefined,
|
||||
"删除会话",
|
||||
);
|
||||
}
|
||||
|
||||
// ── React Query hooks ─────────────────────────────────────────────────────
|
||||
|
||||
export function useMafSessions(params?: {
|
||||
status?: string;
|
||||
mode?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: [KEY, "sessions", params],
|
||||
queryFn: () => listSessions(params),
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMafSession(id: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [KEY, "sessions", id],
|
||||
queryFn: () => getSession(id!),
|
||||
enabled: !!id,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMafSessionActions() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: [KEY, "sessions"] });
|
||||
return {
|
||||
create: useMutation({ mutationFn: createSession, onSuccess: invalidate }),
|
||||
update: useMutation({
|
||||
mutationFn: ({ id, body }: { id: string; body: UpdateSessionBody }) =>
|
||||
updateSession(id, body),
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
remove: useMutation({ mutationFn: deleteSession, onSuccess: invalidate }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* MAF 多智能体会商 —— 后端 `/api/maf-roundtable` 的 TypeScript 类型。
|
||||
*
|
||||
* 唯一依据:offline-backend-20260512/backend/app/maf_roundtable/schemas.py。
|
||||
* 后端持久化即 snake_case(见 packages/harness/deerflow/persistence/maf_roundtable),
|
||||
* 这里**直传不转换**:与后端字段一一对应,避免边界处多一层映射。
|
||||
* 所有 `payload` 已在后端写入时脱敏(`sanitize_payload`),前端拿到的就是可渲染的。
|
||||
*/
|
||||
|
||||
// ── health(GET /health)───────────────────────────────────────────────────
|
||||
export interface MafHealthResponse {
|
||||
enabled: boolean;
|
||||
store_available: boolean;
|
||||
default_mode: string;
|
||||
default_write_policy: string;
|
||||
default_max_rounds: number;
|
||||
admin_diagnostic_access: boolean;
|
||||
write_guard_enabled: boolean;
|
||||
write_guard_ready: boolean;
|
||||
action_secret_configured: boolean;
|
||||
write_guard_status:
|
||||
| "disabled"
|
||||
| "provider_unavailable"
|
||||
| "missing_secret"
|
||||
| "ready";
|
||||
}
|
||||
|
||||
// ── session(/sessions)────────────────────────────────────────────────────
|
||||
export type MafSessionStatus =
|
||||
| "draft"
|
||||
| "running"
|
||||
| "waiting_approval"
|
||||
| "waiting_input"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
|
||||
export interface MafParticipant {
|
||||
id: string;
|
||||
session_id: string;
|
||||
agent_id: string;
|
||||
display_name: string;
|
||||
role: "manager" | "seat";
|
||||
sort_order: number;
|
||||
thread_id: string | null;
|
||||
status: string;
|
||||
last_turn_index: number | null;
|
||||
capability_snapshot: Record<string, unknown> | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface MafSession {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
task_text: string;
|
||||
task_payload: Record<string, unknown> | null;
|
||||
mode: "structured" | "group_chat";
|
||||
status: MafSessionStatus;
|
||||
model_name: string | null;
|
||||
reasoning_effort: string | null;
|
||||
manager_agent_id: string | null;
|
||||
max_rounds: number;
|
||||
write_policy: "auto" | "confirm_high_risk" | "confirm_all_writes";
|
||||
capability_snapshot: Record<string, unknown> | null;
|
||||
fact_ledger: Record<string, unknown> | null;
|
||||
final_result: Record<string, unknown> | null;
|
||||
participants: MafParticipant[];
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface MafSessionListResponse {
|
||||
sessions: MafSession[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ── run(/sessions/{id}/runs, /runs/{id})──────────────────────────────────
|
||||
/**
|
||||
* Run 状态机。`runIsLive` 判定为 true 的状态会继续轮询;其余(completed/failed/
|
||||
* cancelled)是终态,停止轮询。cancelling / cancel_failed 是非终态中间态,仍需轮询。
|
||||
*/
|
||||
export type MafRunStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "waiting_approval"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "cancelling"
|
||||
| "cancel_failed";
|
||||
|
||||
export interface MafRun {
|
||||
id: string;
|
||||
session_id: string;
|
||||
status: MafRunStatus;
|
||||
workflow_version: string | null;
|
||||
/**
|
||||
* MAF 工作流检查点。结构见 RoundtableState:含 fact_ledger / final_result /
|
||||
* seat_outcomes / current_round 等。这里用宽松类型——前端只读其中几个已知键。
|
||||
*/
|
||||
maf_checkpoint: MafCheckpoint | null;
|
||||
last_event_seq: number;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
error_code: string | null;
|
||||
error_message_safe: string | null;
|
||||
token_usage: Record<string, number> | null;
|
||||
latency_ms: number | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface MafRunListResponse {
|
||||
runs: MafRun[];
|
||||
}
|
||||
|
||||
// ── checkpoint 内部结构(maf_checkpoint,宽松类型)──────────────────────────
|
||||
/** 事实账本(fact_ledger.empty_ledger 的形状)。 */
|
||||
export interface MafFactLedger {
|
||||
candidate_facts: MafCandidateFact[];
|
||||
confirmed_facts: MafConfirmedFact[];
|
||||
evidence: MafEvidence[];
|
||||
tool_results: MafToolResult[];
|
||||
executed_actions: MafLedgerAction[];
|
||||
pending_actions: MafLedgerAction[];
|
||||
failed_or_unknown_actions: MafLedgerAction[];
|
||||
conflicts: MafLedgerConflict[];
|
||||
proposed_actions: MafLedgerAction[];
|
||||
}
|
||||
|
||||
export interface MafCandidateFact {
|
||||
candidate_id: string;
|
||||
text: string;
|
||||
agent_id?: string;
|
||||
source?: string;
|
||||
confirmed?: boolean;
|
||||
}
|
||||
|
||||
export interface MafConfirmedFact {
|
||||
candidate_id: string;
|
||||
text: string;
|
||||
agent_id?: string;
|
||||
source?: string;
|
||||
confirmed_by?: string;
|
||||
evidence_refs: string[];
|
||||
}
|
||||
|
||||
export interface MafEvidence {
|
||||
ref?: string;
|
||||
text?: string;
|
||||
agent_id?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MafToolResult {
|
||||
tool_name: string;
|
||||
status: string; // "completed" | "waiting_approval" | "failed" | "unknown"
|
||||
agent_id?: string;
|
||||
turn_index?: number;
|
||||
detail?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MafLedgerAction {
|
||||
action_id?: string;
|
||||
tool_name?: string;
|
||||
status?: string;
|
||||
agent_id?: string;
|
||||
external_object_id?: string;
|
||||
text?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MafLedgerConflict {
|
||||
text: string;
|
||||
agent_id?: string;
|
||||
}
|
||||
|
||||
/** 席位产出(maf_checkpoint.seat_outcomes 的 value)。 */
|
||||
export interface MafSeatOutcome {
|
||||
status: string; // "completed" | "running" | "failed" | ...
|
||||
execution_stage?: string;
|
||||
agent_id?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MafFinalResult {
|
||||
content: string;
|
||||
agent_id?: string;
|
||||
partial_failure?: boolean;
|
||||
degraded?: boolean;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface MafCheckpoint {
|
||||
fact_ledger?: MafFactLedger;
|
||||
seat_outcomes?: Record<string, MafSeatOutcome>;
|
||||
final_result?: MafFinalResult;
|
||||
current_round?: number;
|
||||
seat_agent_ids?: string[];
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
// ── event(/runs/{id}/events)──────────────────────────────────────────────
|
||||
/**
|
||||
* 事件分类(见后端 app/maf_roundtable/event_normalizer.py)。
|
||||
* payload 已脱敏,结构因 event_type 而异,这里用 Record 宽松承载。
|
||||
*/
|
||||
export type MafEventType =
|
||||
| "run.started"
|
||||
| "run.completed"
|
||||
| "run.failed"
|
||||
| "run.cancelled"
|
||||
| "agent.turn_started"
|
||||
| "agent.message.delta"
|
||||
| "agent.message.completed"
|
||||
| "tool.requested"
|
||||
| "tool.started"
|
||||
| "tool.completed"
|
||||
| "tool.failed"
|
||||
| "skill.selected"
|
||||
| "skill.started"
|
||||
| "skill.completed"
|
||||
| "skill.failed"
|
||||
| "artifact.created"
|
||||
| "action.waiting_approval"
|
||||
| "action.approved"
|
||||
| "action.rejected"
|
||||
| "workflow.checkpointed"
|
||||
| string; // 兜底:后端可能新增,前端不强断
|
||||
|
||||
export interface MafEvent {
|
||||
id: string;
|
||||
session_id: string;
|
||||
run_id: string;
|
||||
seq: number;
|
||||
event_type: MafEventType;
|
||||
agent_id: string | null;
|
||||
turn_index: number | null;
|
||||
payload: Record<string, unknown> | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface MafEventListResponse {
|
||||
events: MafEvent[];
|
||||
latest_seq: number;
|
||||
}
|
||||
|
||||
// ── action(/runs/{id}/actions, /actions/{id})─────────────────────────────
|
||||
export type MafActionStatus =
|
||||
| "pending"
|
||||
| "waiting_approval"
|
||||
| "approved"
|
||||
| "rejected"
|
||||
| "executed"
|
||||
| "failed"
|
||||
| "unknown"
|
||||
| "expired"
|
||||
| string;
|
||||
|
||||
export interface MafAction {
|
||||
id: string;
|
||||
session_id: string;
|
||||
run_id: string | null;
|
||||
agent_id: string | null;
|
||||
tool_name: string;
|
||||
logical_action_id: string | null;
|
||||
risk_level: string;
|
||||
status: MafActionStatus;
|
||||
arguments: Record<string, unknown> | null;
|
||||
result: Record<string, unknown> | null;
|
||||
external_object_id: string | null;
|
||||
approval_id: string | null;
|
||||
attempt_count: number;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface MafActionListResponse {
|
||||
actions: MafAction[];
|
||||
}
|
||||
|
||||
// ── approval(/runs/{id}/approvals, /approvals/{id}/decision)──────────────
|
||||
export type MafApprovalStatus = "pending" | "approved" | "rejected" | string;
|
||||
|
||||
export interface MafApproval {
|
||||
id: string;
|
||||
session_id: string;
|
||||
run_id: string | null;
|
||||
action_id: string | null;
|
||||
requested_by_agent_id: string | null;
|
||||
status: MafApprovalStatus;
|
||||
summary: string;
|
||||
payload: Record<string, unknown> | null;
|
||||
decided_by: string | null;
|
||||
decided_at: string | null;
|
||||
expires_at: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface MafApprovalListResponse {
|
||||
approvals: MafApproval[];
|
||||
}
|
||||
|
||||
export interface MafApprovalDecisionRequest {
|
||||
decision: "approved" | "rejected";
|
||||
}
|
||||
|
||||
// ── 终态判定 ───────────────────────────────────────────────────────────────
|
||||
const LIVE_STATUSES: ReadonlySet<MafRunStatus> = new Set([
|
||||
"pending",
|
||||
"running",
|
||||
"waiting_approval",
|
||||
"cancelling",
|
||||
"cancel_failed",
|
||||
]);
|
||||
|
||||
/** run 是否仍可能产生新事件 —— true 时继续轮询。 */
|
||||
export function runIsLive(status: string | null | undefined): boolean {
|
||||
return !!status && (LIVE_STATUSES as Set<string>).has(status);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* MAF 写工具审批卡片。
|
||||
*
|
||||
* P0-4 对齐后端 schemas.py:
|
||||
* - 标题:action.tool_name,没有 action 时显示 approval.summary
|
||||
* - 风险:展示 action.risk_level(R0–R4 配色)
|
||||
* - 参数:展示 action.arguments(已脱敏)
|
||||
* - 审批摘要:展示 approval.summary
|
||||
* - 审批 payload:仅在有值时展示已脱敏 JSON
|
||||
* - 备注 textarea 已删除(后端 ApprovalDecisionRequest 只接收 decision,不保存备注)
|
||||
*
|
||||
* P1:风险等级按 R0–R4 配色(R0/R1 低、R2 中、R3/R4 高)。
|
||||
* P1:invalidate 使用页面传入的 runId,不依赖 approval.run_id 可选字段。
|
||||
*
|
||||
* 仅 pending 审批渲染;approved/rejected 后由父组件过滤掉。
|
||||
*/
|
||||
|
||||
import { Check, Loader2, ShieldCheck, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useMafApprovalActions } from "../api/approvals";
|
||||
import type { MafAction, MafApproval } from "../api/types";
|
||||
|
||||
interface MafApprovalCardProps {
|
||||
approval: MafApproval;
|
||||
action: MafAction | undefined;
|
||||
/** 当前页面的 runId(用于 invalidate,不依赖 approval.run_id 可选字段)。 */
|
||||
runId: string;
|
||||
}
|
||||
|
||||
function shortId(id: string | null): string {
|
||||
if (!id) return "—";
|
||||
return id.length > 8 ? `${id.slice(0, 8)}…` : id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 风险等级配色。后端实际使用 R0–R4(不是 low/medium/high):
|
||||
* R0/R1 → 低风险(绿)
|
||||
* R2 → 中风险(琥珀)
|
||||
* R3/R4 → 高风险(红)
|
||||
*/
|
||||
const RISK_TONE: Record<string, string> = {
|
||||
R0: "text-emerald-600 bg-emerald-50",
|
||||
R1: "text-emerald-600 bg-emerald-50",
|
||||
R2: "text-amber-600 bg-amber-50",
|
||||
R3: "text-red-600 bg-red-50",
|
||||
R4: "text-red-600 bg-red-50",
|
||||
// 兜底:后端可能返回旧值
|
||||
low: "text-emerald-600 bg-emerald-50",
|
||||
medium: "text-amber-600 bg-amber-50",
|
||||
high: "text-red-600 bg-red-50",
|
||||
};
|
||||
|
||||
const RISK_LABEL: Record<string, string> = {
|
||||
R0: "R0 · 低",
|
||||
R1: "R1 · 低",
|
||||
R2: "R2 · 中",
|
||||
R3: "R3 · 高",
|
||||
R4: "R4 · 高",
|
||||
};
|
||||
|
||||
export function MafApprovalCard({ approval, action, runId }: MafApprovalCardProps) {
|
||||
const actions = useMafApprovalActions(runId);
|
||||
|
||||
const pending = actions.decide.isPending;
|
||||
const onDecide = (decision: "approved" | "rejected") => {
|
||||
actions.decide.mutate(
|
||||
{ approvalId: approval.id, decision },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(decision === "approved" ? "已批准" : "已拒绝");
|
||||
},
|
||||
onError: (e: Error) => toast.error(`决策失败:${e.message}`),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// 标题:action.tool_name 优先,没有 action 时用 approval.summary
|
||||
const title = action?.tool_name ?? approval.summary;
|
||||
const riskLevel = action?.risk_level;
|
||||
const args = action?.arguments;
|
||||
const approvalPayload = approval.payload;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-red-100 bg-white p-3 shadow-sm">
|
||||
<header className="mb-2 flex items-center gap-1.5">
|
||||
<Badge className="bg-red-50 text-red-600 hover:bg-red-50">
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
需要你的确认
|
||||
</Badge>
|
||||
{action?.id && (
|
||||
<span className="text-[10px] font-mono text-slate-400">
|
||||
#{shortId(approval.action_id ?? action.id)}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* 标题 */}
|
||||
<div className="mb-1.5 text-sm font-semibold text-slate-800">
|
||||
{title || "未知操作"}
|
||||
</div>
|
||||
|
||||
{/* 风险等级(R0–R4) */}
|
||||
{riskLevel && (
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-slate-400">风险等级:</span>
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${RISK_TONE[riskLevel] ?? "text-slate-500 bg-slate-50"}`}>
|
||||
{RISK_LABEL[riskLevel] ?? riskLevel}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 审批摘要 */}
|
||||
{approval.summary && (
|
||||
<div className="mb-2 text-xs text-slate-600">
|
||||
{approval.summary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 参数(来自 action.arguments,已脱敏) */}
|
||||
{args && Object.keys(args).length > 0 && (
|
||||
<div className="mb-2 rounded-md border border-slate-100 bg-slate-50/70 p-2">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-slate-400">
|
||||
参数(已脱敏)
|
||||
</div>
|
||||
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-all text-xs text-slate-600">
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 审批 payload(仅有值时展示) */}
|
||||
{approvalPayload && Object.keys(approvalPayload).length > 0 && (
|
||||
<div className="mb-2 rounded-md border border-slate-100 bg-slate-50/70 p-2">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-slate-400">
|
||||
审批载荷(已脱敏)
|
||||
</div>
|
||||
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-all text-xs text-slate-600">
|
||||
{JSON.stringify(approvalPayload, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onDecide("rejected")}
|
||||
disabled={pending}
|
||||
className="border-red-200 text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
>
|
||||
{pending ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <X className="mr-1 h-3.5 w-3.5" />}
|
||||
拒绝
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onDecide("approved")}
|
||||
disabled={pending}
|
||||
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
>
|
||||
{pending ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Check className="mr-1 h-3.5 w-3.5" />}
|
||||
批准
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* MAF 事件时间线 —— 把折叠后的 TimelineEntry[] 渲染成可读的对话流。
|
||||
*
|
||||
* 复用 roundtable-planning 的 useAutoScroll + ScrollToBottomButton(滚动跟随语义)。
|
||||
* 视觉上借鉴 MessageBubble(头像 + header + 正文),但 MAF 事件类型多样(消息/工具/
|
||||
* 审批/run),这里用统一的「条目卡片」而非消息气泡——每条带类型徽标 + 时间 + 内容。
|
||||
*/
|
||||
|
||||
import { useRef } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Hammer,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Package,
|
||||
ShieldQuestion,
|
||||
Sparkles,
|
||||
Workflow,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
||||
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { shortTime, type TimelineEntry, type TimelineEntryKind } from "../lib/event-timeline";
|
||||
|
||||
interface MafEventTimelineProps {
|
||||
entries: TimelineEntry[];
|
||||
/** 仍在流式(run live)→ 显示底部 spinner;停则不显示。 */
|
||||
streaming: boolean;
|
||||
}
|
||||
|
||||
const KIND_ICON: Record<TimelineEntryKind, React.ReactNode> = {
|
||||
message: <MessageSquare className="h-3.5 w-3.5" />,
|
||||
tool: <Hammer className="h-3.5 w-3.5" />,
|
||||
skill: <Sparkles className="h-3.5 w-3.5" />,
|
||||
approval: <ShieldQuestion className="h-3.5 w-3.5" />,
|
||||
artifact: <Package className="h-3.5 w-3.5" />,
|
||||
run: <Workflow className="h-3.5 w-3.5" />,
|
||||
checkpoint: <GitBranch className="h-3.5 w-3.5" />,
|
||||
turn: <FileText className="h-3.5 w-3.5" />,
|
||||
};
|
||||
|
||||
const KIND_TONE: Record<TimelineEntryKind, string> = {
|
||||
message: "border-blue-100 bg-blue-50/40",
|
||||
tool: "border-cyan-100 bg-cyan-50/40",
|
||||
skill: "border-purple-100 bg-purple-50/40",
|
||||
approval: "border-amber-100 bg-amber-50/50",
|
||||
artifact: "border-emerald-100 bg-emerald-50/40",
|
||||
run: "border-slate-200 bg-slate-50/60",
|
||||
checkpoint: "border-slate-200 bg-slate-50/40",
|
||||
turn: "border-slate-100 bg-white",
|
||||
};
|
||||
|
||||
function toolStatusIcon(entry: TimelineEntry): React.ReactNode {
|
||||
const status = (entry.meta?.status as string) ?? "";
|
||||
if (status === "completed") return <CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />;
|
||||
if (status === "failed") return <XCircle className="h-3.5 w-3.5 text-red-600" />;
|
||||
if (status === "started" || status === "requested")
|
||||
return <Loader2 className="h-3.5 w-3.5 animate-spin text-blue-500" />;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function MafEventTimeline({ entries, streaming }: MafEventTimelineProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [entries], {
|
||||
contentRef,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden bg-slate-50/30"
|
||||
>
|
||||
<div ref={contentRef} className="space-y-2 p-4">
|
||||
{entries.length === 0 && !streaming && (
|
||||
<div className="flex h-full items-center justify-center py-12 text-sm text-slate-400">
|
||||
<AlertTriangle className="mr-1.5 h-4 w-4" />
|
||||
暂无事件
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries.map((entry) => (
|
||||
<TimelineEntryCard key={entry.key} entry={entry} />
|
||||
))}
|
||||
|
||||
{streaming && (
|
||||
<div className="flex items-center gap-2 py-2 pl-7 text-xs text-blue-500">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
会商进行中…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!atBottom && (
|
||||
<ScrollToBottomButton onClick={scrollToBottom} label="回到底部" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineEntryCard({ entry }: { entry: TimelineEntry }) {
|
||||
const isMessage = entry.kind === "message";
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 transition",
|
||||
KIND_TONE[entry.kind],
|
||||
)}
|
||||
>
|
||||
<header className="mb-1 flex items-center gap-1.5">
|
||||
<span className="text-slate-500">{KIND_ICON[entry.kind]}</span>
|
||||
{entry.kind === "tool" && toolStatusIcon(entry)}
|
||||
<span className="text-xs font-semibold text-slate-700">{entry.title}</span>
|
||||
{entry.agentId && (
|
||||
<Badge variant="outline" className="ml-1 text-[10px] text-slate-500">
|
||||
{entry.agentId}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="ml-auto text-[10px] text-slate-400">{shortTime(entry.createdAt)}</span>
|
||||
</header>
|
||||
|
||||
{entry.body && (
|
||||
isMessage ? (
|
||||
<div className="prose prose-sm max-w-none text-slate-700 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{entry.body}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap break-words text-xs text-slate-600">{entry.body}</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* MAF 事实/结论流程图面板。
|
||||
*
|
||||
* 包一层 roundtable-planning 的 FlowGraphPanel:数据来自 lib/fact-flow-graph.ts
|
||||
* (confirmed_facts 证据图 + final_result 的 flow-json 结论图)。run live 时
|
||||
* streaming=true 让蚂蚁线动;终态转实线。
|
||||
*
|
||||
* FlowGraphPanel 是 portal 全屏弹窗风格(自带 onClose),这里用 Collapsible 折叠面板
|
||||
* 内嵌一个固定高度的画布预览 + 「展开大图」按钮触发 FlowGraphPanel。
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { GitBranch, Maximize2 } from "lucide-react";
|
||||
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FlowGraphPanel } from "@/roundtable-planning/components/FlowGraphPanel";
|
||||
import type { FlowGraphData } from "@/roundtable-planning/lib/flow-graph";
|
||||
|
||||
import type { MafCheckpoint } from "../api/types";
|
||||
import { buildMafFlowGraph } from "../lib/fact-flow-graph";
|
||||
|
||||
interface MafFactFlowPanelProps {
|
||||
checkpoint: MafCheckpoint | null;
|
||||
/** run 仍 live → 蚂蚁线动;终态 → 实线。 */
|
||||
streaming: boolean;
|
||||
}
|
||||
|
||||
export function MafFactFlowPanel({ checkpoint, streaming }: MafFactFlowPanelProps) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const graph: FlowGraphData | null = buildMafFlowGraph(checkpoint);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className="rounded-lg border border-slate-200 bg-white"
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<GitBranch className="h-4 w-4 text-slate-500" />
|
||||
<span className="text-sm font-semibold text-slate-700">事实/结论流程图</span>
|
||||
{graph && (
|
||||
<span className="text-xs text-slate-400">
|
||||
({graph.nodes.length} 节点 / {graph.edges.length} 连线)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-slate-400">{open ? "收起" : "展开"}</span>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className="relative h-64 border-t border-slate-100">
|
||||
{graph ? (
|
||||
<>
|
||||
{/* 内嵌预览:复用 FlowGraphPanel 的横向布局,占满面板高度 */}
|
||||
<FlowGraphPreview graph={graph} streaming={streaming} />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="absolute right-2 top-2 z-10 h-7 gap-1 bg-white/90 text-xs"
|
||||
onClick={() => setFullscreen(true)}
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
大图
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-xs text-slate-400">
|
||||
{streaming ? "等待已确认事实 / 结论输出…" : "尚无可绘制的事实或结论"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
|
||||
{/* 全屏弹窗(portal 到 body,仿 Step3SummaryMode 用法) */}
|
||||
{fullscreen && graph && (
|
||||
<FlowGraphPanel
|
||||
data={graph}
|
||||
streaming={streaming}
|
||||
onClose={() => setFullscreen(false)}
|
||||
orientation="vertical"
|
||||
miniMap
|
||||
/>
|
||||
)}
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 内嵌预览。FlowGraphPanel 是 portal 全屏组件,不适合直接嵌进小面板;
|
||||
* 这里用一个轻量的「节点列表」预览代替画布(点击「大图」看完整交互画布)。
|
||||
*/
|
||||
function FlowGraphPreview({ graph, streaming }: { graph: FlowGraphData; streaming: boolean }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden p-2">
|
||||
{graph.title && (
|
||||
<div className="mb-1 truncate text-xs font-medium text-slate-500">{graph.title}</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<ul className="space-y-1">
|
||||
{graph.nodes.map((n) => {
|
||||
const incomers = graph.edges.filter((e) => e.to === n.id).map((e) => e.from);
|
||||
return (
|
||||
<li key={n.id} className="rounded border border-slate-100 bg-slate-50/70 px-2 py-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="rounded bg-white px-1 py-0.5 text-[9px] font-mono text-slate-400">
|
||||
{n.type}
|
||||
</span>
|
||||
<span className="truncate text-xs text-slate-700">{n.label}</span>
|
||||
{n.group && (
|
||||
<span className="ml-auto shrink-0 text-[10px] text-slate-400">{n.group}</span>
|
||||
)}
|
||||
</div>
|
||||
{incomers.length > 0 && (
|
||||
<div className="mt-0.5 text-[10px] text-slate-400">← {incomers.join(", ")}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
{streaming && (
|
||||
<div className="mt-1 text-center text-[10px] text-blue-400">绘制中…</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* MAF 功能开关守卫。
|
||||
*
|
||||
* P1:分别处理三种状态:
|
||||
* - enabled=false → 「功能未启用」
|
||||
* - enabled=true && store_available=false → 「功能已开启,但持久化存储未就绪」
|
||||
* - 请求异常 → 「无法连接 MAF 服务」+ 重试按钮
|
||||
*/
|
||||
|
||||
import { DatabaseZap, Loader2, RefreshCw, ShieldAlert, ShieldOff } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useMafHealth } from "../api/runs";
|
||||
|
||||
interface MafHealthGateProps {
|
||||
/** enabled 时渲染的子树。 */
|
||||
children: React.ReactNode;
|
||||
/** 探测中时渲染的占位(默认 spinner)。 */
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MafHealthGate({ children, fallback }: MafHealthGateProps) {
|
||||
const { data, isLoading, isError, refetch, isFetching } = useMafHealth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>{fallback ?? (
|
||||
<div className="flex h-full w-full items-center justify-center text-slate-400">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
正在检查 MAF 会商状态…
|
||||
</div>
|
||||
)}</>
|
||||
);
|
||||
}
|
||||
|
||||
// 请求异常 → 无法连接,给重试按钮
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<ShieldOff className="h-10 w-10 text-slate-300" />
|
||||
<div className="text-base font-medium text-slate-500">无法连接 MAF 服务</div>
|
||||
<p className="max-w-md text-sm text-slate-400">
|
||||
请求 /api/maf-roundtable/health 失败,请检查后端服务是否正常运行。
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
|
||||
{isFetching ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="mr-1 h-3.5 w-3.5" />}
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 明确 disabled → 功能未启用
|
||||
if (!data?.enabled) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<ShieldOff className="h-10 w-10 text-slate-300" />
|
||||
<div className="text-base font-medium text-slate-500">MAF 会商功能未启用</div>
|
||||
<p className="max-w-md text-sm text-slate-400">
|
||||
后端未开启多智能体会商功能(maf_roundtable.enabled=false)。
|
||||
请联系管理员在 config.yaml 中启用后重试。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// enabled=true 但 store_available=false → 存储未就绪
|
||||
if (!data.store_available) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<DatabaseZap className="h-10 w-10 text-amber-400" />
|
||||
<div className="text-base font-medium text-slate-500">持久化存储未就绪</div>
|
||||
<p className="max-w-md text-sm text-slate-400">
|
||||
MAF 会商功能已开启,但持久化存储(数据库)尚未配置或连接失败。
|
||||
写操作将返回 503。请检查数据库配置后重试。
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
|
||||
{isFetching ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="mr-1 h-3.5 w-3.5" />}
|
||||
重新检测
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const writeGuardNotReady =
|
||||
data.write_guard_enabled && !data.write_guard_ready;
|
||||
const writeGuardMessage =
|
||||
data.write_guard_status === "missing_secret"
|
||||
? "WriteGuard 未就绪:未注入 MAF_ACTION_SECRET_KEY,外部写入将失败关闭;只读工具和技能仍可用。"
|
||||
: "WriteGuard 未就绪:外部写入将失败关闭,请检查审批桥接配置;只读工具和技能仍可用。";
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{writeGuardNotReady && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-amber-200 bg-amber-50 px-4 py-2 text-xs text-amber-800">
|
||||
<ShieldAlert className="h-4 w-4 shrink-0" />
|
||||
{writeGuardMessage}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* MAF run 顶部状态条:状态徽标 + token/延迟 + 生命周期操作按钮。
|
||||
*
|
||||
* 操作:
|
||||
* - live(pending/running/waiting_approval)→ 取消
|
||||
* - waiting_approval → 恢复(resume,配合审批后流程)
|
||||
* - cancel_failed → 重试取消(仍可点取消)
|
||||
* - failed → 重试(recover)
|
||||
*
|
||||
* 状态徽标配色按 run.status 区分。token_usage/latency_ms 展示在右侧。
|
||||
*/
|
||||
|
||||
import { AlertTriangle, CheckCircle2, Loader2, RotateCcw, Square, XCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useMafRunActions } from "../api/runs";
|
||||
import { runIsLive, type MafRun, type MafRunStatus } from "../api/types";
|
||||
|
||||
interface MafRunHeaderProps {
|
||||
run: MafRun;
|
||||
}
|
||||
|
||||
/** run 状态 → 中文 + 徽标 variant + 图标。 */
|
||||
function statusMeta(status: MafRunStatus): {
|
||||
label: string;
|
||||
variant: "default" | "secondary" | "destructive" | "outline";
|
||||
icon: React.ReactNode;
|
||||
tone: string;
|
||||
} {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return { label: "排队中", variant: "secondary", icon: <Loader2 className="h-3 w-3 animate-spin" />, tone: "text-slate-500" };
|
||||
case "running":
|
||||
return { label: "进行中", variant: "default", icon: <Loader2 className="h-3 w-3 animate-spin" />, tone: "text-blue-600" };
|
||||
case "waiting_approval":
|
||||
return { label: "等待审批", variant: "default", icon: <AlertTriangle className="h-3 w-3" />, tone: "text-amber-600" };
|
||||
case "completed":
|
||||
return { label: "已完成", variant: "outline", icon: <CheckCircle2 className="h-3 w-3" />, tone: "text-emerald-600" };
|
||||
case "failed":
|
||||
return { label: "失败", variant: "destructive", icon: <XCircle className="h-3 w-3" />, tone: "text-red-600" };
|
||||
case "cancelled":
|
||||
return { label: "已取消", variant: "outline", icon: <Square className="h-3 w-3" />, tone: "text-slate-500" };
|
||||
case "cancelling":
|
||||
return { label: "取消中", variant: "secondary", icon: <Loader2 className="h-3 w-3 animate-spin" />, tone: "text-amber-600" };
|
||||
case "cancel_failed":
|
||||
return { label: "取消失败", variant: "destructive", icon: <AlertTriangle className="h-3 w-3" />, tone: "text-red-600" };
|
||||
default:
|
||||
return { label: status, variant: "outline", icon: null, tone: "text-slate-500" };
|
||||
}
|
||||
}
|
||||
|
||||
export function MafRunHeader({ run }: MafRunHeaderProps) {
|
||||
const actions = useMafRunActions();
|
||||
const meta = statusMeta(run.status);
|
||||
const live = runIsLive(run.status);
|
||||
|
||||
const onCancel = () => {
|
||||
actions.cancel.mutate(run.id, {
|
||||
onSuccess: () => toast.success("已发起取消"),
|
||||
onError: (e: Error) => toast.error(`取消失败:${e.message}`),
|
||||
});
|
||||
};
|
||||
const onResume = () => {
|
||||
actions.resume.mutate(run.id, {
|
||||
onSuccess: () => toast.success("已恢复会商"),
|
||||
onError: (e: Error) => toast.error(`恢复失败:${e.message}`),
|
||||
});
|
||||
};
|
||||
const onRecover = () => {
|
||||
actions.recover.mutate(run.id, {
|
||||
onSuccess: () => toast.success("已重试会商"),
|
||||
onError: (e: Error) => toast.error(`重试失败:${e.message}`),
|
||||
});
|
||||
};
|
||||
|
||||
// token 汇总
|
||||
const tokenTotal = run.token_usage
|
||||
? Object.values(run.token_usage).reduce((a, b) => a + (b || 0), 0)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-slate-200 bg-white px-4 py-2.5">
|
||||
<div className={`flex items-center gap-1.5 ${meta.tone}`}>
|
||||
{meta.icon}
|
||||
<span className="text-sm font-semibold">{meta.label}</span>
|
||||
</div>
|
||||
<Badge variant={meta.variant} className="text-[11px]">
|
||||
{run.workflow_version ?? "maf"}
|
||||
</Badge>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3 text-xs text-slate-500">
|
||||
{tokenTotal != null && (
|
||||
<span title="累计 token">tokens: {tokenTotal.toLocaleString()}</span>
|
||||
)}
|
||||
{run.latency_ms != null && (
|
||||
<span title="耗时">{(run.latency_ms / 1000).toFixed(1)}s</span>
|
||||
)}
|
||||
{run.last_event_seq > 0 && (
|
||||
<span title="最新事件序号">seq#{run.last_event_seq}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{run.status === "waiting_approval" && (
|
||||
<Button size="sm" variant="outline" onClick={onResume} disabled={actions.resume.isPending}>
|
||||
恢复
|
||||
</Button>
|
||||
)}
|
||||
{run.status === "failed" && (
|
||||
<Button size="sm" variant="outline" onClick={onRecover} disabled={actions.recover.isPending}>
|
||||
<RotateCcw className="mr-1 h-3.5 w-3.5" />
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
{(live || run.status === "cancel_failed") && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={actions.cancel.isPending}
|
||||
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
>
|
||||
<Square className="mr-1 h-3.5 w-3.5" />
|
||||
取消
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{run.error_message_safe && (
|
||||
<div className="w-full text-xs text-red-600">
|
||||
错误:{run.error_message_safe}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* MAF 席位状态矩阵。
|
||||
*
|
||||
* 数据来自 `run.maf_checkpoint.seat_outcomes`({agent_id: {status, execution_stage, ...}})。
|
||||
* 每个席位一行:agent_id + status 徽标 + execution_stage 徽标。
|
||||
*
|
||||
* execution_stage 取值(Phase-3 闭合枚举):
|
||||
* legacy / planner / expert / format_repair / cross_review / cross_review_repair /
|
||||
* finalizer / group_chat
|
||||
*/
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { MafCheckpoint, MafSeatOutcome } from "../api/types";
|
||||
|
||||
interface MafSeatStatusPanelProps {
|
||||
checkpoint: MafCheckpoint | null;
|
||||
/** 当前轮次(展示用)。 */
|
||||
currentRound?: number;
|
||||
}
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
legacy: "旧版",
|
||||
planner: "规划",
|
||||
expert: "专家分析",
|
||||
format_repair: "格式修复",
|
||||
cross_review: "交叉评审",
|
||||
cross_review_repair: "评审修复",
|
||||
finalizer: "总结",
|
||||
group_chat: "群聊",
|
||||
};
|
||||
|
||||
const SEAT_STATUS_TONE: Record<string, string> = {
|
||||
completed: "text-emerald-600",
|
||||
running: "text-blue-600",
|
||||
pending: "text-slate-500",
|
||||
failed: "text-red-600",
|
||||
cancelled: "text-slate-400",
|
||||
};
|
||||
|
||||
function seatStatusBadge(status: string) {
|
||||
const variant =
|
||||
status === "completed"
|
||||
? ("outline" as const)
|
||||
: status === "failed"
|
||||
? ("destructive" as const)
|
||||
: ("secondary" as const);
|
||||
return <Badge variant={variant} className="text-[10px]">{status}</Badge>;
|
||||
}
|
||||
|
||||
export function MafSeatStatusPanel({ checkpoint, currentRound }: MafSeatStatusPanelProps) {
|
||||
const outcomes = checkpoint?.seat_outcomes;
|
||||
const entries: Array<[string, MafSeatOutcome]> = outcomes
|
||||
? Object.entries(outcomes)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-3">
|
||||
<header className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-700">席位状态</h3>
|
||||
{typeof currentRound === "number" && (
|
||||
<span className="text-xs text-slate-400">第 {currentRound + 1} 轮</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<p className="py-4 text-center text-xs text-slate-400">尚无席位产出</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{entries.map(([agentId, outcome]) => (
|
||||
<li
|
||||
key={agentId}
|
||||
className="flex items-center gap-2 rounded-md border border-slate-100 bg-slate-50/60 px-2.5 py-1.5"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-slate-700" title={agentId}>
|
||||
{agentId}
|
||||
</span>
|
||||
{outcome.execution_stage && (
|
||||
<Badge variant="outline" className="text-[10px] text-slate-500">
|
||||
{STAGE_LABELS[outcome.execution_stage] ?? outcome.execution_stage}
|
||||
</Badge>
|
||||
)}
|
||||
<span className={SEAT_STATUS_TONE[outcome.status ?? ""] ?? "text-slate-500"}>
|
||||
{seatStatusBadge(outcome.status ?? "unknown")}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* MAF 事件 cursor 轮询 hook —— Phase-4 实时更新的核心。
|
||||
*
|
||||
* 为什么不用 `useQuery + refetchInterval`:
|
||||
* cursor `after_seq` 必须在每次拉取后推进。useQuery 的 queryFn 不持有「上次的
|
||||
* latest_seq」;把 after_seq 塞进 queryKey 会导致每次推进都重置查询、反复重拉全量,
|
||||
* 产生抖动。所以用自定义 interval hook:闭包里用 ref 持有 cursor。
|
||||
*
|
||||
* P0-3 修复:
|
||||
* 1. 终态 run 也拉历史事件(enabled=true 即补拉,不管是否 live)。
|
||||
* 2. 一次超过 PAGE_LIMIT 条事件时,循环翻页直到返回数 < PAGE_LIMIT 或无更大 seq。
|
||||
* 3. inFlightRef 防止网络慢时上一个请求尚未结束、下一个 interval 又开始请求。
|
||||
* 4. 游标只推进到「本次实际收到的最大 seq」,不直接跳到 latest_seq。
|
||||
*
|
||||
* P0 竞态修复:
|
||||
* 用 generationRef 隔离不同 runId/请求代次。切换 run 时 generation++,
|
||||
* 所有异步回调在写 state 前检查 generation 是否仍匹配——旧 run 的慢响应
|
||||
* 到达时 generation 已变,直接丢弃,不会把旧事件写进新页面。
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { listEvents } from "../api/events";
|
||||
import { useMafRun } from "../api/runs";
|
||||
import { runIsLive, type MafEvent } from "../api/types";
|
||||
|
||||
/** 事件轮询间隔(ms)。1.5s —— 略快于 run 状态轮询,保证事件即时涌现。 */
|
||||
const POLL_INTERVAL = 1500;
|
||||
|
||||
/** 单次拉取上限(后端最大 10000)。1000 足够覆盖一轮多席位的并发事件。 */
|
||||
const PAGE_LIMIT = 1000;
|
||||
|
||||
/** 按 seq 去重合并(新事件覆盖同 seq 旧事件,正常不会发生但做幂等保险)。 */
|
||||
function mergeBySeq(prev: MafEvent[], incoming: MafEvent[]): MafEvent[] {
|
||||
if (incoming.length === 0) return prev;
|
||||
const bySeq = new Map<number, MafEvent>();
|
||||
for (const e of prev) bySeq.set(e.seq, e);
|
||||
for (const e of incoming) bySeq.set(e.seq, e);
|
||||
return [...bySeq.values()].sort((a, b) => a.seq - b.seq);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request-generation gate shared by the hook and its race-condition tests.
|
||||
* Each generation has its own in-flight slot, so a stale request can finish
|
||||
* independently without blocking a newer run's terminal-state backlog pull.
|
||||
*/
|
||||
export interface MafEventRequestCoordinator {
|
||||
readonly currentGeneration: number;
|
||||
nextGeneration: () => number;
|
||||
start: (generation: number) => boolean;
|
||||
finish: (generation: number) => void;
|
||||
isCurrent: (generation: number) => boolean;
|
||||
}
|
||||
|
||||
export function createMafEventRequestCoordinator(): MafEventRequestCoordinator {
|
||||
let currentGeneration = 0;
|
||||
const inFlight = new Set<number>();
|
||||
return {
|
||||
get currentGeneration() {
|
||||
return currentGeneration;
|
||||
},
|
||||
nextGeneration() {
|
||||
currentGeneration += 1;
|
||||
return currentGeneration;
|
||||
},
|
||||
start(generation) {
|
||||
if (inFlight.has(generation)) return false;
|
||||
inFlight.add(generation);
|
||||
return true;
|
||||
},
|
||||
finish(generation) {
|
||||
inFlight.delete(generation);
|
||||
},
|
||||
isCurrent(generation) {
|
||||
return generation === currentGeneration;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseMafEventsResult {
|
||||
/** 按 seq 升序的事件列表(已去重)。 */
|
||||
events: MafEvent[];
|
||||
/** 当前已拉到的最大 seq(供调试/展示)。 */
|
||||
latestSeq: number;
|
||||
/** 手动重置(清空事件、cursor 归零、立即补拉)。 */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询某 run 的事件流。
|
||||
*
|
||||
* @param runId run id;undefined/null 时不轮询
|
||||
* @param opts.enabled 是否启用(有 run 即 true,无论终态/live——终态也要补拉历史)
|
||||
* @param opts.polling 是否启动定时器(一般传 `runIsLive(run.status)`)
|
||||
*/
|
||||
export function useMafEvents(
|
||||
runId: string | undefined | null,
|
||||
opts: { enabled: boolean; polling: boolean },
|
||||
): UseMafEventsResult {
|
||||
const { enabled, polling } = opts;
|
||||
const [events, setEvents] = useState<MafEvent[]>([]);
|
||||
const [latestSeq, setLatestSeq] = useState(0);
|
||||
const afterSeqRef = useRef(0);
|
||||
const requestCoordinatorRef = useRef(createMafEventRequestCoordinator());
|
||||
const [resetSignal, setResetSignal] = useState(0);
|
||||
const initializedRef = useRef(false);
|
||||
const lastRunIdRef = useRef<string | undefined | null>(runId);
|
||||
const lastResetSignalRef = useRef(resetSignal);
|
||||
|
||||
const reset = useCallback(() => setResetSignal((n) => n + 1), []);
|
||||
|
||||
// Every runId/enabled/polling/resetSignal change starts a new request
|
||||
// generation. Only run changes and explicit reset clear the accumulated
|
||||
// data; toggling enabled/polling must cancel old work without losing the
|
||||
// already accepted backlog.
|
||||
useEffect(() => {
|
||||
const identityChanged =
|
||||
!initializedRef.current ||
|
||||
lastRunIdRef.current !== runId ||
|
||||
lastResetSignalRef.current !== resetSignal;
|
||||
requestCoordinatorRef.current.nextGeneration();
|
||||
if (identityChanged) {
|
||||
setEvents([]);
|
||||
setLatestSeq(0);
|
||||
afterSeqRef.current = 0;
|
||||
}
|
||||
lastRunIdRef.current = runId;
|
||||
lastResetSignalRef.current = resetSignal;
|
||||
initializedRef.current = true;
|
||||
}, [runId, enabled, polling, resetSignal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runId || !enabled) return;
|
||||
const myGeneration = requestCoordinatorRef.current.currentGeneration;
|
||||
let cancelled = false;
|
||||
|
||||
/**
|
||||
* 从 afterSeqRef.current 开始循环翻页拉取,直到:
|
||||
* - 返回事件数 < PAGE_LIMIT(没有更多了)
|
||||
* - 本页最大 seq <= cursor(无进展,防死循环)
|
||||
* - generation 已变(切换了 run,丢弃结果)
|
||||
*/
|
||||
async function fetchBacklog() {
|
||||
if (!requestCoordinatorRef.current.start(myGeneration)) return;
|
||||
|
||||
try {
|
||||
let cursor = afterSeqRef.current;
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
if (
|
||||
cancelled ||
|
||||
!requestCoordinatorRef.current.isCurrent(myGeneration)
|
||||
) return;
|
||||
const res = await listEvents(runId!, cursor, PAGE_LIMIT);
|
||||
// 响应回来后再次检查代次(网络慢时可能已切换 run)
|
||||
if (
|
||||
cancelled ||
|
||||
!requestCoordinatorRef.current.isCurrent(myGeneration)
|
||||
) return;
|
||||
|
||||
const page = res.events;
|
||||
|
||||
// 本页最大 seq(只推进到实际收到的最大 seq,不跳 latest_seq)
|
||||
let maxSeq = cursor;
|
||||
for (const e of page) {
|
||||
if (e.seq > maxSeq) maxSeq = e.seq;
|
||||
}
|
||||
|
||||
if (page.length > 0) {
|
||||
setEvents((prev) => mergeBySeq(prev, page));
|
||||
}
|
||||
|
||||
// 无进展 → 停
|
||||
if (maxSeq <= cursor) break;
|
||||
|
||||
cursor = maxSeq;
|
||||
afterSeqRef.current = cursor;
|
||||
setLatestSeq(cursor);
|
||||
|
||||
// 不满一页 → 没有更多了
|
||||
if (page.length < PAGE_LIMIT) break;
|
||||
}
|
||||
} catch {
|
||||
// 单次拉取失败(网络抖动/瞬断)静默——下次 interval 重试。
|
||||
} finally {
|
||||
// Never clear a newer generation's in-flight marker from a stale
|
||||
// request. This is what lets a terminal-state refetch start while
|
||||
// the old live poll is still unresolved.
|
||||
requestCoordinatorRef.current.finish(myGeneration);
|
||||
}
|
||||
}
|
||||
|
||||
// 进入即补拉历史(after_seq=0 全量,终态 run 也走这里)
|
||||
void fetchBacklog();
|
||||
|
||||
// polling=true 才启动定时器
|
||||
if (!polling) {
|
||||
return () => { cancelled = true; };
|
||||
}
|
||||
const timer = window.setInterval(() => void fetchBacklog(), POLL_INTERVAL);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [runId, enabled, polling, resetSignal]);
|
||||
|
||||
return { events, latestSeq, reset };
|
||||
}
|
||||
|
||||
/**
|
||||
* 组合 hook:同时跟踪 run 状态 + 轮询事件。
|
||||
*
|
||||
* 用法:`const { run, events } = useMafRunWithEvents(runId)`。
|
||||
* run live 时自动轮询事件;终态时 run 仍按 refetchInterval 停,事件补拉历史后停。
|
||||
*/
|
||||
export function useMafRunWithEvents(runId: string | undefined | null) {
|
||||
const runQuery = useMafRun(runId);
|
||||
const live = runIsLive(runQuery.data?.status);
|
||||
const hasRun = !!runQuery.data;
|
||||
const { events, latestSeq, reset } = useMafEvents(runId, {
|
||||
enabled: hasRun,
|
||||
polling: live,
|
||||
});
|
||||
return {
|
||||
run: runQuery.data,
|
||||
isLoadingRun: runQuery.isLoading,
|
||||
runError: runQuery.error,
|
||||
events,
|
||||
latestSeq,
|
||||
resetEvents: reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* MAF 多智能体会商前端 —— barrel 导出。
|
||||
*
|
||||
* 对外只暴露页面(路由用)和必要的 hook/类型;内部 api/lib 组件按需从子路径导入。
|
||||
*/
|
||||
|
||||
export { default as MafSessionsPage } from "./pages/MafSessionsPage";
|
||||
export { default as MafRunDetailPage } from "./pages/MafRunDetailPage";
|
||||
|
||||
// 常用 hook(页面/外部消费)
|
||||
export { useMafHealth, useMafRun, useMafRuns, useMafRunActions } from "./api/runs";
|
||||
export { useMafSessions, useMafSession, useMafSessionActions } from "./api/sessions";
|
||||
export { useMafApprovals, useMafActions, useMafApprovalActions } from "./api/approvals";
|
||||
export { useMafEvents, useMafRunWithEvents } from "./hooks/use-maf-events";
|
||||
|
||||
// 类型
|
||||
export type {
|
||||
MafRun,
|
||||
MafRunStatus,
|
||||
MafSession,
|
||||
MafEvent,
|
||||
MafEventType,
|
||||
MafApproval,
|
||||
MafAction,
|
||||
MafCheckpoint,
|
||||
MafFactLedger,
|
||||
MafHealthResponse,
|
||||
} from "./api/types";
|
||||
export { runIsLive } from "./api/types";
|
||||
|
||||
// 纯函数(可单测)
|
||||
export { foldEventsToTimeline } from "./lib/event-timeline";
|
||||
export type { TimelineEntry, TimelineEntryKind } from "./lib/event-timeline";
|
||||
export { buildMafFlowGraph, buildFactFlowGraph, extractConclusionFlow } from "./lib/fact-flow-graph";
|
||||
|
||||
// 组件
|
||||
export { MafHealthGate } from "./components/MafHealthGate";
|
||||
export { MafRunHeader } from "./components/MafRunHeader";
|
||||
export { MafEventTimeline } from "./components/MafEventTimeline";
|
||||
export { MafSeatStatusPanel } from "./components/MafSeatStatusPanel";
|
||||
export { MafFactFlowPanel } from "./components/MafFactFlowPanel";
|
||||
export { MafApprovalCard } from "./components/MafApprovalCard";
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* 把 MAF 原始事件流(按 seq 升序)折叠成时间线可读条目。
|
||||
*
|
||||
* 后端事件分类见 app/maf_roundtable/event_normalizer.py:
|
||||
* agent.message.delta —— 流式 token 片段,需聚合为一条消息
|
||||
* agent.message.completed —— 一轮完整回复(含 content)
|
||||
* tool.* —— 工具生命周期,折叠为单条「调用 → 结果」
|
||||
* action.waiting_approval —— 审批锚点(右侧审批卡片入口)
|
||||
* run.* / skill.* —— 状态/生命周期条目
|
||||
*
|
||||
* 设计:**纯函数**,无 React、无副作用,便于单测。输入 events[](已去重升序),
|
||||
* 输出 TimelineEntry[](时间线渲染顺序)。
|
||||
*/
|
||||
|
||||
import type { MafEvent } from "../api/types";
|
||||
|
||||
export type TimelineEntryKind =
|
||||
| "message" // 智能体消息(delta 聚合 或 completed)
|
||||
| "tool" // 工具调用生命周期(requested→completed/failed 折叠)
|
||||
| "skill" // 技能生命周期
|
||||
| "approval" // 审批锚点(waiting/approved/rejected)
|
||||
| "artifact" // 产出物
|
||||
| "run" // run 生命周期(started/completed/failed/cancelled)
|
||||
| "checkpoint" // 工作流检查点
|
||||
| "turn"; // 席位轮次开始
|
||||
|
||||
export interface TimelineEntry {
|
||||
/** 用于 React key,全局唯一。 */
|
||||
key: string;
|
||||
kind: TimelineEntryKind;
|
||||
/** 原始 event_type(供组件按类型配色/图标)。 */
|
||||
eventType: string;
|
||||
/** 触发该条目的 agent_id(席位/智能体标识)。 */
|
||||
agentId: string | null;
|
||||
/** 轮次索引(便于分组)。 */
|
||||
turnIndex: number | null;
|
||||
seq: number;
|
||||
createdAt: string | null;
|
||||
/** 主标题(工具名 / 状态文案 / 席位名)。 */
|
||||
title: string;
|
||||
/** 正文(消息内容 / 工具结果摘要 / 错误信息)。 */
|
||||
body?: string;
|
||||
/** 关联的二级信息(action_id/approval_id/tool_call_id 等)。 */
|
||||
meta?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/** delta 聚合的分组键:(agent_id, turn_index) —— 同一席位同一轮的 delta 拼成一条。 */
|
||||
function deltaGroupKey(ev: MafEvent): string {
|
||||
return `delta:${ev.agent_id ?? ""}:${ev.turn_index ?? 0}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 折叠事件流为时间线条目。
|
||||
*
|
||||
* 聚合规则:
|
||||
* 1. `agent.message.delta` 按 (agent,turn) 分组拼接 → 一条 message(取最大 seq 为排序键)。
|
||||
* 若同组有 `agent.message.completed`,则用 completed 的完整 content 覆盖。
|
||||
* 2. `tool.requested`/`tool.started`/`tool.completed`/`tool.failed` 按 tool_call_id 折叠
|
||||
* 为一条 tool 条目(最终状态 + 名称)。
|
||||
* 3. 其余每事件一条。
|
||||
*/
|
||||
export function foldEventsToTimeline(events: MafEvent[]): TimelineEntry[] {
|
||||
const entries: TimelineEntry[] = [];
|
||||
// delta 分组缓冲:groupKey → { texts[], lastSeq, agent, turn, createdAt }
|
||||
const deltaBuf = new Map<
|
||||
string,
|
||||
{ texts: string[]; seq: number; ev: MafEvent; completed?: MafEvent }
|
||||
>();
|
||||
// tool 折叠:tool_call_id → entry(先建占位,后续更新)
|
||||
const toolMap = new Map<string, TimelineEntry>();
|
||||
|
||||
for (const ev of events) {
|
||||
const p = ev.payload ?? {};
|
||||
switch (ev.event_type) {
|
||||
case "agent.message.delta": {
|
||||
const text = typeof p.text === "string" ? p.text : "";
|
||||
if (!text) break;
|
||||
const k = deltaGroupKey(ev);
|
||||
const cur = deltaBuf.get(k);
|
||||
if (cur) {
|
||||
cur.texts.push(text);
|
||||
if (ev.seq > cur.seq) {
|
||||
cur.seq = ev.seq;
|
||||
cur.ev = ev;
|
||||
}
|
||||
} else {
|
||||
deltaBuf.set(k, { texts: [text], seq: ev.seq, ev });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "agent.message.completed": {
|
||||
const k = deltaGroupKey(ev);
|
||||
const cur = deltaBuf.get(k);
|
||||
const content = typeof p.content === "string" ? p.content : "";
|
||||
if (cur) {
|
||||
cur.completed = ev;
|
||||
if (content) cur.texts = [content]; // completed 的完整内容覆盖 delta 拼接
|
||||
if (ev.seq > cur.seq) cur.seq = ev.seq;
|
||||
} else {
|
||||
deltaBuf.set(k, {
|
||||
texts: content ? [content] : [],
|
||||
seq: ev.seq,
|
||||
ev,
|
||||
completed: ev,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool.requested":
|
||||
case "tool.started":
|
||||
case "tool.completed":
|
||||
case "tool.failed": {
|
||||
const tcId = typeof p.tool_call_id === "string" ? p.tool_call_id : "";
|
||||
const toolName = typeof p.tool_name === "string" ? p.tool_name : "";
|
||||
const mapKey = tcId || `${ev.event_type}:${ev.seq}`;
|
||||
const status =
|
||||
ev.event_type === "tool.failed"
|
||||
? "failed"
|
||||
: ev.event_type === "tool.completed"
|
||||
? "completed"
|
||||
: ev.event_type === "tool.started"
|
||||
? "started"
|
||||
: "requested";
|
||||
const existing = toolMap.get(mapKey);
|
||||
if (existing) {
|
||||
// 更新到最新状态(seq 更大者覆盖)
|
||||
if (ev.seq >= existing.seq) {
|
||||
existing.seq = ev.seq;
|
||||
existing.title = toolName || existing.title;
|
||||
existing.body =
|
||||
typeof p.content_preview === "string"
|
||||
? p.content_preview
|
||||
: existing.body;
|
||||
existing.meta = { ...existing.meta, ...p, status };
|
||||
}
|
||||
} else {
|
||||
toolMap.set(mapKey, {
|
||||
key: `tool:${mapKey}`,
|
||||
kind: "tool",
|
||||
eventType: ev.event_type,
|
||||
agentId: ev.agent_id,
|
||||
turnIndex: ev.turn_index,
|
||||
seq: ev.seq,
|
||||
createdAt: ev.created_at,
|
||||
title: toolName || "工具调用",
|
||||
body: typeof p.content_preview === "string" ? p.content_preview : undefined,
|
||||
meta: { ...p, status },
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// 其它事件一一映射(含 approval/run/skill/artifact/checkpoint/turn)。
|
||||
const entry = mapSingleEvent(ev);
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// delta 缓冲落盘
|
||||
for (const { texts, seq, ev, completed } of deltaBuf.values()) {
|
||||
entries.push({
|
||||
key: `msg:${deltaGroupKey(ev)}`,
|
||||
kind: "message",
|
||||
eventType: completed ? "agent.message.completed" : "agent.message.delta",
|
||||
agentId: ev.agent_id,
|
||||
turnIndex: ev.turn_index,
|
||||
seq,
|
||||
createdAt: ev.created_at,
|
||||
title: ev.agent_id ?? "智能体",
|
||||
body: texts.join("").trim() || undefined,
|
||||
meta: completed?.payload,
|
||||
});
|
||||
}
|
||||
// tool 折叠落盘
|
||||
for (const entry of toolMap.values()) entries.push(entry);
|
||||
|
||||
// 按 seq 升序(聚合条目用其最大 seq,自然排到出现时刻之后)
|
||||
entries.sort((a, b) => a.seq - b.seq);
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** 把单个非聚合事件映射成 TimelineEntry。 */
|
||||
function mapSingleEvent(ev: MafEvent): TimelineEntry | null {
|
||||
const p = ev.payload ?? {};
|
||||
const base = {
|
||||
key: `${ev.event_type}:${ev.id}`,
|
||||
agentId: ev.agent_id,
|
||||
turnIndex: ev.turn_index,
|
||||
seq: ev.seq,
|
||||
createdAt: ev.created_at,
|
||||
};
|
||||
switch (ev.event_type) {
|
||||
case "agent.turn_started":
|
||||
return {
|
||||
...base,
|
||||
kind: "turn",
|
||||
eventType: ev.event_type,
|
||||
title: `${ev.agent_id ?? "席位"} 开始第 ${(ev.turn_index ?? 0) + 1} 轮`,
|
||||
meta: p,
|
||||
};
|
||||
case "run.started":
|
||||
return { ...base, kind: "run", eventType: ev.event_type, title: "会商开始", meta: p };
|
||||
case "run.completed":
|
||||
return { ...base, kind: "run", eventType: ev.event_type, title: "会商完成", meta: p };
|
||||
case "run.failed":
|
||||
return {
|
||||
...base,
|
||||
kind: "run",
|
||||
eventType: ev.event_type,
|
||||
title: "会商失败",
|
||||
body: typeof p.message === "string" ? p.message : undefined,
|
||||
meta: p,
|
||||
};
|
||||
case "run.cancelled":
|
||||
return { ...base, kind: "run", eventType: ev.event_type, title: "会商已取消", meta: p };
|
||||
case "skill.selected":
|
||||
case "skill.started":
|
||||
case "skill.completed":
|
||||
case "skill.failed": {
|
||||
const skillName = typeof p.skill_name === "string" ? p.skill_name : "技能";
|
||||
const phase = ev.event_type.split(".")[1];
|
||||
const labelMap: Record<string, string> = {
|
||||
selected: "选择",
|
||||
started: "开始",
|
||||
completed: "完成",
|
||||
failed: "失败",
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
kind: "skill",
|
||||
eventType: ev.event_type,
|
||||
title: `技能「${skillName}」${labelMap[phase] ?? phase}`,
|
||||
body:
|
||||
typeof p.result_preview === "string"
|
||||
? p.result_preview
|
||||
: typeof p.error === "string"
|
||||
? p.error
|
||||
: undefined,
|
||||
meta: p,
|
||||
};
|
||||
}
|
||||
case "artifact.created":
|
||||
return {
|
||||
...base,
|
||||
kind: "artifact",
|
||||
eventType: ev.event_type,
|
||||
title: `产出:${typeof p.title === "string" ? p.title : typeof p.artifact_type === "string" ? p.artifact_type : "产物"}`,
|
||||
meta: p,
|
||||
};
|
||||
case "action.waiting_approval":
|
||||
case "action.approved":
|
||||
case "action.rejected": {
|
||||
const phase = ev.event_type.split(".")[1];
|
||||
const labelMap: Record<string, string> = {
|
||||
waiting_approval: "等待审批",
|
||||
approved: "已批准",
|
||||
rejected: "已拒绝",
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
kind: "approval",
|
||||
eventType: ev.event_type,
|
||||
title: `写工具「${typeof p.tool_name === "string" ? p.tool_name : "未知"}」${labelMap[phase] ?? phase}`,
|
||||
body: typeof p.summary === "string" ? p.summary : undefined,
|
||||
meta: p,
|
||||
};
|
||||
}
|
||||
case "workflow.checkpointed":
|
||||
return {
|
||||
...base,
|
||||
kind: "checkpoint",
|
||||
eventType: ev.event_type,
|
||||
title: "工作流已检查点",
|
||||
body: typeof p.reason === "string" ? p.reason : undefined,
|
||||
meta: p,
|
||||
};
|
||||
default:
|
||||
// 兜底:未知事件类型仍展示,避免遗漏。
|
||||
return {
|
||||
...base,
|
||||
kind: "run",
|
||||
eventType: ev.event_type,
|
||||
title: ev.event_type,
|
||||
meta: p,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 时间线条目的人类可读时间(HH:MM:SS)。null/无效返回空串。 */
|
||||
export function shortTime(iso: string | null): string {
|
||||
if (!iso) return "";
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 把 MAF 事实账本 + 末轮结论转成 FlowGraphPanel 可渲染的 FlowGraphData。
|
||||
*
|
||||
* 两个数据源,优先级合并:
|
||||
* 1. `final_result.content` 里的 ```flow-json``` 块(次源,结论流程图)—— 若 final_result
|
||||
* 含 flow-json,用现成的 extractFlowGraph 解析,直接作为「结论流程图」返回(它已是
|
||||
* 模型精心编排的层级图,比纯事实更有结构)。
|
||||
* 2. `confirmed_facts`(主源,事实证据图)—— 每条已确认事实 → task 节点;其
|
||||
* evidence_refs 连到对应证据节点(evidence → role / tool_results → deliverable);
|
||||
* 证据根用 start。
|
||||
*
|
||||
* 设计:**纯函数**,输入 checkpoint(宽松类型),输出 FlowGraphData | null。
|
||||
* null 表示无任何可绘制数据,由组件显示空态。
|
||||
*/
|
||||
|
||||
import {
|
||||
extractFlowGraph,
|
||||
type FlowGraphData,
|
||||
type FlowGraphEdge,
|
||||
type FlowGraphNode,
|
||||
} from "@/roundtable-planning/lib/flow-graph";
|
||||
|
||||
import type { MafCheckpoint, MafFactLedger } from "../api/types";
|
||||
|
||||
/** 截断长文本用于节点 label(detail 保留全文)。 */
|
||||
function truncate(text: string, max = 28): string {
|
||||
const t = text.trim().replace(/\s+/g, " ");
|
||||
return t.length <= max ? t : `${t.slice(0, max)}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试从 final_result.content 解析 flow-json 结论图。
|
||||
* 有则优先返回(模型编排的结论图比纯事实更有结构)。
|
||||
*/
|
||||
export function extractConclusionFlow(checkpoint: MafCheckpoint | null): FlowGraphData | null {
|
||||
const content = checkpoint?.final_result?.content;
|
||||
if (!content || typeof content !== "string") return null;
|
||||
const graph = extractFlowGraph(content);
|
||||
return graph && graph.nodes.length > 0 ? graph : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 confirmed_facts + evidence + tool_results 编织成事实证据图。
|
||||
*
|
||||
* 布局:
|
||||
* start(根) ──► evidence/tool(role/deliverable) ──► fact(task)
|
||||
*
|
||||
* 每个事实节点 group 标 confirmed_by(席位),detail 是全文 + evidence_refs。
|
||||
*/
|
||||
export function buildFactFlowGraph(checkpoint: MafCheckpoint | null): FlowGraphData | null {
|
||||
const ledger: MafFactLedger | undefined = checkpoint?.fact_ledger;
|
||||
const confirmed = ledger?.confirmed_facts ?? [];
|
||||
if (confirmed.length === 0) return null;
|
||||
|
||||
const nodes: FlowGraphNode[] = [];
|
||||
const edges: FlowGraphEdge[] = [];
|
||||
const nodeIds = new Set<string>();
|
||||
|
||||
const addNode = (n: FlowGraphNode) => {
|
||||
if (!nodeIds.has(n.id)) {
|
||||
nodeIds.add(n.id);
|
||||
nodes.push(n);
|
||||
}
|
||||
};
|
||||
const addEdge = (from: string, to: string, label?: string) => {
|
||||
if (from === to) return;
|
||||
edges.push({ from, to, label });
|
||||
};
|
||||
|
||||
// 证据索引:ref → 来源类型,便于给节点配 type。
|
||||
const evidenceByText = new Map<string, "evidence" | "tool">();
|
||||
for (const e of ledger?.evidence ?? []) {
|
||||
const ref = typeof e.ref === "string" ? e.ref : typeof e.text === "string" ? e.text : "";
|
||||
if (ref) evidenceByText.set(ref, "evidence");
|
||||
}
|
||||
for (const tr of ledger?.tool_results ?? []) {
|
||||
if (tr.status !== "completed") continue; // 只有成功的工具算证据
|
||||
const name = tr.tool_name ?? "";
|
||||
const turn = tr.turn_index;
|
||||
const ref =
|
||||
name && turn != null ? `tool:${name}:${turn}` : name ? `tool:${name}` : "";
|
||||
if (ref) evidenceByText.set(ref, "tool");
|
||||
}
|
||||
|
||||
// 根节点
|
||||
const ROOT_ID = "root";
|
||||
addNode({ id: ROOT_ID, label: "已确认事实", type: "start" });
|
||||
|
||||
for (const fact of confirmed) {
|
||||
const factId = `fact:${fact.candidate_id}`;
|
||||
addNode({
|
||||
id: factId,
|
||||
label: truncate(fact.text),
|
||||
type: "task",
|
||||
detail: fact.text,
|
||||
group: fact.confirmed_by ? `确认方:${fact.confirmed_by}` : "已确认",
|
||||
});
|
||||
|
||||
if ((fact.evidence_refs ?? []).length === 0) {
|
||||
// 无证据引用的事实:直接连根(理论上 Phase-3 校验后不应出现,兜底)。
|
||||
addEdge(ROOT_ID, factId);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const ref of fact.evidence_refs) {
|
||||
const refStr = String(ref);
|
||||
const evKind = evidenceByText.get(refStr) ?? "evidence";
|
||||
const evId = `ev:${refStr}`;
|
||||
// 证据节点(role=外部证据 / deliverable=工具产出)
|
||||
addNode({
|
||||
id: evId,
|
||||
label: truncate(refStr.replace(/^tool:/, ""), 24),
|
||||
type: evKind === "tool" ? "deliverable" : "role",
|
||||
detail: refStr,
|
||||
group: evKind === "tool" ? "工具证据" : "外部证据",
|
||||
});
|
||||
addEdge(ROOT_ID, evId);
|
||||
addEdge(evId, factId);
|
||||
}
|
||||
}
|
||||
|
||||
// 边去重(同 from→to 只留一条)
|
||||
const seen = new Set<string>();
|
||||
const dedupEdges = edges.filter((e) => {
|
||||
const k = `${e.from}->${e.to}`;
|
||||
if (seen.has(k)) return false;
|
||||
seen.add(k);
|
||||
return true;
|
||||
});
|
||||
|
||||
return { title: "事实证据图", nodes, edges: dedupEdges };
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并两源:结论 flow-json 优先;否则用事实证据图;都没有 → null(空态)。
|
||||
*/
|
||||
export function buildMafFlowGraph(checkpoint: MafCheckpoint | null): FlowGraphData | null {
|
||||
return extractConclusionFlow(checkpoint) ?? buildFactFlowGraph(checkpoint);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* MAF 单 run 工作区(路由 runs/:runId)。
|
||||
*
|
||||
* 三栏布局(桌面)/ 纵向堆叠(窄屏):
|
||||
* 左:事件时间线(MafEventTimeline)—— run live 时 cursor 轮询事件
|
||||
* 中:席位状态(MafSeatStatusPanel)+ 事实/结论流程图(MafFactFlowPanel)
|
||||
* 右:审批卡片(MafApprovalCard)—— 仅 pending 审批时显示
|
||||
*
|
||||
* 顶部:MafRunHeader(状态徽标 + 操作)。整体由 MafHealthGate 守卫。
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, Loader2, ShieldQuestion } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
||||
|
||||
import { useMafActions, useMafApprovals } from "../api/approvals";
|
||||
import { runIsLive, type MafAction } from "../api/types";
|
||||
import { useMafRunWithEvents } from "../hooks/use-maf-events";
|
||||
import { foldEventsToTimeline } from "../lib/event-timeline";
|
||||
import { MafApprovalCard } from "../components/MafApprovalCard";
|
||||
import { MafEventTimeline } from "../components/MafEventTimeline";
|
||||
import { MafFactFlowPanel } from "../components/MafFactFlowPanel";
|
||||
import { MafHealthGate } from "../components/MafHealthGate";
|
||||
import { MafRunHeader } from "../components/MafRunHeader";
|
||||
import { MafSeatStatusPanel } from "../components/MafSeatStatusPanel";
|
||||
|
||||
export default function MafRunDetailPage() {
|
||||
const { runId } = useParams<{ runId: string }>();
|
||||
return (
|
||||
<MafHealthGate>
|
||||
{runId ? <RunBody runId={runId} /> : <div className="p-6 text-slate-400">缺少 runId</div>}
|
||||
</MafHealthGate>
|
||||
);
|
||||
}
|
||||
|
||||
function RunBody({ runId }: { runId: string }) {
|
||||
const { run, isLoadingRun, runError, events } = useMafRunWithEvents(runId);
|
||||
const headerRef = useDesensitizeSubtree<HTMLDivElement>();
|
||||
|
||||
const live = runIsLive(run?.status);
|
||||
const approvalsQuery = useMafApprovals(runId, live);
|
||||
const actionsQuery = useMafActions(runId, live);
|
||||
const pendingApprovals = (approvalsQuery.data?.approvals ?? []).filter(
|
||||
(a) => a.status === "pending",
|
||||
);
|
||||
const actionsById = useMemo(() => {
|
||||
const m = new Map<string, MafAction>();
|
||||
for (const a of actionsQuery.data?.actions ?? []) m.set(a.id, a);
|
||||
return m;
|
||||
}, [actionsQuery.data]);
|
||||
|
||||
const timeline = useMemo(() => foldEventsToTimeline(events), [events]);
|
||||
|
||||
if (isLoadingRun) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-slate-400">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" /> 加载会商…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (runError || !run) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-slate-500">
|
||||
<p>加载失败:{(runError as Error)?.message ?? "run 不存在或无权访问"}</p>
|
||||
<Link to="/page/maf">
|
||||
<Button variant="outline" size="sm">
|
||||
<ArrowLeft className="mr-1 h-3.5 w-3.5" /> 返回会话列表
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div ref={headerRef}>
|
||||
<MafRunHeader run={run} />
|
||||
</div>
|
||||
|
||||
<div className="grid flex-1 min-h-0 grid-cols-1 gap-2 overflow-hidden p-2 lg:grid-cols-[1fr_360px_320px]">
|
||||
{/* 左:时间线 */}
|
||||
<section className="flex min-h-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white">
|
||||
<MafEventTimeline entries={timeline} streaming={live} />
|
||||
</section>
|
||||
|
||||
{/* 中:席位 + 流程图 */}
|
||||
<section className="flex min-h-0 flex-col gap-2 overflow-y-auto">
|
||||
<MafSeatStatusPanel
|
||||
checkpoint={run.maf_checkpoint}
|
||||
currentRound={typeof run.maf_checkpoint?.current_round === "number"
|
||||
? run.maf_checkpoint.current_round
|
||||
: undefined}
|
||||
/>
|
||||
<MafFactFlowPanel checkpoint={run.maf_checkpoint} streaming={live} />
|
||||
|
||||
{/* final_result 展示 */}
|
||||
{run.maf_checkpoint?.final_result?.content && (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-3">
|
||||
<h3 className="mb-1.5 text-sm font-semibold text-slate-700">最终结论</h3>
|
||||
<div className="prose prose-sm max-h-72 max-w-none overflow-auto text-slate-700 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
{/* final_result.content 可能含 flow-json 块;这里只展示文字部分(流程图已在上方画布) */}
|
||||
<pre className="whitespace-pre-wrap break-words font-sans text-xs">
|
||||
{stripFlowJson(run.maf_checkpoint.final_result.content)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 右:审批 */}
|
||||
<section className="flex min-h-0 flex-col gap-2 overflow-y-auto">
|
||||
<div className="flex items-center gap-1.5 px-1 text-xs font-medium uppercase tracking-wide text-slate-400">
|
||||
<ShieldQuestion className="h-3.5 w-3.5" />
|
||||
写工具审批
|
||||
{pendingApprovals.length > 0 && (
|
||||
<span className="rounded-full bg-red-100 px-1.5 text-[10px] font-bold text-red-600">
|
||||
{pendingApprovals.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{pendingApprovals.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-slate-200 bg-white/50 p-4 text-center text-xs text-slate-400">
|
||||
{live ? "暂无待审批的写操作" : "本次会商无待审批项"}
|
||||
</div>
|
||||
) : (
|
||||
pendingApprovals.map((approval) => (
|
||||
<MafApprovalCard
|
||||
key={approval.id}
|
||||
approval={approval}
|
||||
action={approval.action_id ? actionsById.get(approval.action_id) : undefined}
|
||||
runId={runId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 把 final_result.content 里的 ```flow-json 块替换成占位提示(流程图已在画布展示)。 */
|
||||
function stripFlowJson(text: string): string {
|
||||
const FENCE = "```flow-json";
|
||||
const start = text.lastIndexOf(FENCE);
|
||||
if (start < 0) return text;
|
||||
const after = text.slice(start + FENCE.length);
|
||||
const close = after.indexOf("```");
|
||||
const tail = close >= 0 ? after.slice(close + 3) : "";
|
||||
return `${text.slice(0, start).trimEnd()}\n\n[结构化流程数据已在上方「事实/结论流程图」画布展示]${tail}`;
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
/**
|
||||
* MAF 会商会话列表页(路由 index)。
|
||||
*
|
||||
* 左:会话列表(按创建时间倒序);右:选中会话的 run 列表 + 「启动会商」。
|
||||
* 点击某条 run → 跳 `/page/maf/runs/:runId`。
|
||||
*
|
||||
* P0-2:新建弹窗包含 task_text(必填)、模式、经理智能体、席位智能体多选、
|
||||
* 可选高级项(模型/推理强度/最大轮数/写入策略)。智能体列表复用 @/core/agents。
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Loader2, MessageSquarePlus, Play, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useAgents } from "@/core/agents";
|
||||
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useMafSessionActions, useMafSessions, type CreateSessionBody, type ParticipantInput } from "../api/sessions";
|
||||
import { useMafHealth, useMafRuns, useMafRunActions } from "../api/runs";
|
||||
import { runIsLive, type MafSessionStatus } from "../api/types";
|
||||
import { MafHealthGate } from "../components/MafHealthGate";
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
draft: "草稿",
|
||||
running: "会商中",
|
||||
waiting_approval: "等待审批",
|
||||
waiting_input: "等待输入",
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
cancelled: "已取消",
|
||||
};
|
||||
|
||||
function statusTone(status: string): string {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "text-blue-600";
|
||||
case "waiting_approval":
|
||||
case "waiting_input":
|
||||
return "text-amber-600";
|
||||
case "completed":
|
||||
return "text-emerald-600";
|
||||
case "failed":
|
||||
case "cancelled":
|
||||
return "text-red-500";
|
||||
default:
|
||||
return "text-slate-500";
|
||||
}
|
||||
}
|
||||
|
||||
export default function MafSessionsPage() {
|
||||
return (
|
||||
<MafHealthGate>
|
||||
<SessionsBody />
|
||||
</MafHealthGate>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionsBody() {
|
||||
const sessionsQuery = useMafSessions({ limit: 100 });
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const headerRef = useDesensitizeSubtree<HTMLDivElement>();
|
||||
|
||||
const sessions = sessionsQuery.data?.sessions ?? [];
|
||||
const active = selectedId
|
||||
? sessions.find((s) => s.id === selectedId)
|
||||
: sessions[0];
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
{/* 左:会话列表 */}
|
||||
<aside className="flex w-72 shrink-0 flex-col border-r border-slate-200 bg-white">
|
||||
<div ref={headerRef} className="flex items-center justify-between border-b border-slate-100 px-3 py-2.5">
|
||||
<h2 className="text-sm font-semibold text-slate-700">会话</h2>
|
||||
<Button size="sm" variant="outline" className="h-7 gap-1" onClick={() => setCreateOpen(true)}>
|
||||
<MessageSquarePlus className="h-3.5 w-3.5" />
|
||||
新建
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sessionsQuery.isLoading && (
|
||||
<div className="flex items-center justify-center py-8 text-slate-400">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> 加载中…
|
||||
</div>
|
||||
)}
|
||||
{sessionsQuery.isError && (
|
||||
<div className="p-3 text-xs text-red-500">
|
||||
加载失败:{(sessionsQuery.error as Error)?.message ?? "未知错误"}
|
||||
</div>
|
||||
)}
|
||||
{!sessionsQuery.isLoading && sessions.length === 0 && (
|
||||
<div className="p-4 text-center text-xs text-slate-400">
|
||||
暂无会话,点击「新建」开始
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul>
|
||||
{sessions.map((s) => (
|
||||
<li key={s.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedId(s.id)}
|
||||
className={cn(
|
||||
"w-full border-b border-slate-50 px-3 py-2.5 text-left transition hover:bg-slate-50",
|
||||
active?.id === s.id && "bg-blue-50/60",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-slate-700">
|
||||
{s.title || "未命名会话"}
|
||||
</span>
|
||||
<span className={cn("text-[10px]", statusTone(s.status))}>
|
||||
{STATUS_LABEL[s.status] ?? s.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[10px] text-slate-400">
|
||||
<Badge variant="outline" className="text-[9px]">{s.mode}</Badge>
|
||||
<span className="truncate">{s.task_text || "无任务说明"}</span>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 右:选中会话的 run 列表 */}
|
||||
<main className="flex flex-1 flex-col overflow-hidden bg-slate-50/40">
|
||||
{active ? (
|
||||
<SessionDetail sessionId={active.id} title={active.title} mode={active.mode} status={active.status} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-slate-400">
|
||||
选择左侧会话查看详情,或新建一个
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<CreateSessionDialog open={createOpen} onOpenChange={setCreateOpen} onCreated={(id) => setSelectedId(id)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionDetail({
|
||||
sessionId,
|
||||
title,
|
||||
mode,
|
||||
status,
|
||||
}: {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
mode: string;
|
||||
status: MafSessionStatus;
|
||||
}) {
|
||||
const runsQuery = useMafRuns(sessionId);
|
||||
const actions = useMafRunActions();
|
||||
const navigate = useNavigate();
|
||||
const runs = runsQuery.data?.runs ?? [];
|
||||
|
||||
// 存在运行中 run 时禁用「启动会商」
|
||||
const liveRun = runs.find((r) => runIsLive(r.status));
|
||||
|
||||
const onStart = () => {
|
||||
actions.start.mutate(
|
||||
{ sessionId },
|
||||
{
|
||||
onSuccess: (run) => navigate(`/page/maf/runs/${run.id}`),
|
||||
onError: (e: Error) => toast.error(`启动失败:${e.message}`),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="flex items-center justify-between border-b border-slate-200 bg-white px-4 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-base font-semibold text-slate-800">{title || "未命名会话"}</h1>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-xs text-slate-400">
|
||||
<Badge variant="outline" className="text-[10px]">{mode}</Badge>
|
||||
<span className={statusTone(status)}>{STATUS_LABEL[status] ?? status}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{liveRun && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/page/maf/runs/${liveRun.id}`)}
|
||||
>
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin text-blue-500" />
|
||||
已有会商进行中
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={onStart} disabled={actions.start.isPending || !!liveRun}>
|
||||
{actions.start.isPending ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Play className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
启动会商
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-slate-400">历史 run</h3>
|
||||
{runsQuery.isLoading && (
|
||||
<div className="flex items-center py-6 text-slate-400">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> 加载中…
|
||||
</div>
|
||||
)}
|
||||
{runs.length === 0 && !runsQuery.isLoading && (
|
||||
<p className="py-6 text-center text-xs text-slate-400">暂无会商记录,点击「启动会商」开始</p>
|
||||
)}
|
||||
<ul className="space-y-2">
|
||||
{runs.map((run) => (
|
||||
<li key={run.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/page/maf/runs/${run.id}`)}
|
||||
className="flex w-full items-center gap-3 rounded-lg border border-slate-200 bg-white px-3 py-2.5 text-left transition hover:border-blue-300 hover:shadow-sm"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-slate-400">{run.id.slice(0, 12)}…</span>
|
||||
{runIsLive(run.status) && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-blue-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-slate-400">
|
||||
{run.started_at ? new Date(run.started_at).toLocaleString() : "未开始"}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px]">{run.status}</Badge>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateSessionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
onCreated: (id: string) => void;
|
||||
}) {
|
||||
const health = useMafHealth();
|
||||
const sessionActions = useMafSessionActions();
|
||||
const { agents, isLoading: agentsLoading, error: agentsError } = useAgents();
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [taskText, setTaskText] = useState("");
|
||||
const [mode, setMode] = useState("structured");
|
||||
const [managerAgentId, setManagerAgentId] = useState("");
|
||||
const [seatAgentIds, setSeatAgentIds] = useState<string[]>([]);
|
||||
// 高级项
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [modelName, setModelName] = useState("");
|
||||
const [reasoningEffort, setReasoningEffort] = useState("");
|
||||
const [maxRounds, setMaxRounds] = useState("");
|
||||
const [writePolicy, setWritePolicy] = useState("");
|
||||
|
||||
// 默认 mode 跟随后端 default_mode(在 effect 中设置,避免 render 内 setState)
|
||||
useEffect(() => {
|
||||
if (health.data?.default_mode) {
|
||||
setMode(health.data.default_mode);
|
||||
}
|
||||
}, [health.data?.default_mode]);
|
||||
|
||||
// P0:切换经理时,自动从已选席位中移除(后端参与者唯一约束:同一 agent 不能同时是 manager 和 seat)
|
||||
useEffect(() => {
|
||||
if (managerAgentId) {
|
||||
setSeatAgentIds((prev) => prev.filter((id) => id !== managerAgentId));
|
||||
}
|
||||
}, [managerAgentId]);
|
||||
|
||||
const toggleSeat = (agentId: string) => {
|
||||
// 经理不能同时作为席位
|
||||
if (agentId === managerAgentId) return;
|
||||
setSeatAgentIds((prev) =>
|
||||
prev.includes(agentId)
|
||||
? prev.filter((id) => id !== agentId)
|
||||
: [...prev, agentId],
|
||||
);
|
||||
};
|
||||
|
||||
const onSubmit = () => {
|
||||
// 前端校验
|
||||
if (!taskText.trim()) {
|
||||
toast.error("请填写任务说明");
|
||||
return;
|
||||
}
|
||||
if (!managerAgentId) {
|
||||
toast.error("请选择经理智能体");
|
||||
return;
|
||||
}
|
||||
if (seatAgentIds.length === 0) {
|
||||
toast.error("请至少选择一个席位智能体");
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建 participants
|
||||
const participants: ParticipantInput[] = [];
|
||||
// 经理
|
||||
const managerAgent = agents.find((a) => a.id === managerAgentId);
|
||||
participants.push({
|
||||
agent_id: managerAgentId,
|
||||
display_name: managerAgent?.name ?? "",
|
||||
role: "manager",
|
||||
sort_order: 0,
|
||||
});
|
||||
// 席位
|
||||
seatAgentIds.forEach((agentId, idx) => {
|
||||
const agent = agents.find((a) => a.id === agentId);
|
||||
participants.push({
|
||||
agent_id: agentId,
|
||||
display_name: agent?.name ?? "",
|
||||
role: "seat",
|
||||
sort_order: idx + 1,
|
||||
});
|
||||
});
|
||||
|
||||
const body: CreateSessionBody = {
|
||||
title: title.trim(),
|
||||
task_text: taskText.trim(),
|
||||
mode: mode as "structured" | "group_chat",
|
||||
manager_agent_id: managerAgentId,
|
||||
participants,
|
||||
};
|
||||
// 高级项(仅非空时传)
|
||||
if (modelName.trim()) body.model_name = modelName.trim();
|
||||
if (reasoningEffort.trim()) body.reasoning_effort = reasoningEffort.trim();
|
||||
if (maxRounds.trim()) {
|
||||
const rounds = Number(maxRounds);
|
||||
if (!Number.isInteger(rounds) || rounds < 1 || rounds > 64) {
|
||||
toast.error("最大轮数必须为 1–64 的整数");
|
||||
return;
|
||||
}
|
||||
body.max_rounds = rounds;
|
||||
}
|
||||
if (writePolicy) body.write_policy = writePolicy as CreateSessionBody["write_policy"];
|
||||
|
||||
sessionActions.create.mutate(
|
||||
body,
|
||||
{
|
||||
onSuccess: (s) => {
|
||||
toast.success("会话已创建");
|
||||
// 重置表单
|
||||
setTitle("");
|
||||
setTaskText("");
|
||||
setManagerAgentId("");
|
||||
setSeatAgentIds([]);
|
||||
setModelName("");
|
||||
setReasoningEffort("");
|
||||
setMaxRounds("");
|
||||
setWritePolicy("");
|
||||
setShowAdvanced(false);
|
||||
onOpenChange(false);
|
||||
onCreated(s.id);
|
||||
},
|
||||
onError: (e: Error) => toast.error(`创建失败:${e.message}`),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建 MAF 会商会话</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
{/* 标题 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-slate-600">会商标题</label>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="例如:某任务多视角研讨" />
|
||||
</div>
|
||||
|
||||
{/* 任务说明(必填) */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-slate-600">
|
||||
任务说明 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Textarea
|
||||
value={taskText}
|
||||
onChange={(e) => setTaskText(e.target.value)}
|
||||
placeholder="描述本次会商要解决的问题、背景和目标(必填)"
|
||||
className="min-h-24"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 模式 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-slate-600">会商模式</label>
|
||||
<select
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-slate-200 bg-white px-2 text-sm"
|
||||
>
|
||||
<option value="structured">structured(规划→专家→评审→总结)</option>
|
||||
<option value="group_chat">group_chat(协调员动态点名)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 经理智能体 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-slate-600">
|
||||
经理智能体 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
{agentsLoading ? (
|
||||
<div className="flex h-9 items-center px-2 text-xs text-slate-400">
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> 加载智能体…
|
||||
</div>
|
||||
) : agentsError ? (
|
||||
<div className="flex h-9 items-center px-2 text-xs text-red-500">
|
||||
加载失败:{(agentsError as Error)?.message ?? "未知错误"}
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={managerAgentId}
|
||||
onChange={(e) => setManagerAgentId(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-slate-200 bg-white px-2 text-sm"
|
||||
>
|
||||
<option value="">请选择…</option>
|
||||
{agents.map((a) => (
|
||||
<option key={a.id} value={a.id}>{a.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 席位智能体多选 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-slate-600">
|
||||
席位智能体(至少一个) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="max-h-40 overflow-y-auto rounded-md border border-slate-200 p-2">
|
||||
{agentsLoading && (
|
||||
<div className="flex items-center justify-center py-2 text-xs text-slate-400">
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> 加载中…
|
||||
</div>
|
||||
)}
|
||||
{agentsError && (
|
||||
<div className="py-2 text-center text-xs text-red-500">
|
||||
加载失败:{(agentsError as Error)?.message ?? "未知错误"}
|
||||
</div>
|
||||
)}
|
||||
{!agentsLoading && !agentsError && agents.length === 0 && (
|
||||
<div className="py-2 text-center text-xs text-slate-400">暂无可用智能体</div>
|
||||
)}
|
||||
{!agentsLoading && !agentsError && agents.map((a) => (
|
||||
<label key={a.id} className="flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 hover:bg-slate-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={seatAgentIds.includes(a.id)}
|
||||
onChange={() => toggleSeat(a.id)}
|
||||
disabled={a.id === managerAgentId}
|
||||
className="h-3.5 w-3.5 rounded border-slate-300"
|
||||
/>
|
||||
<span className="text-sm text-slate-700">{a.name}</span>
|
||||
{a.id === managerAgentId && (
|
||||
<span className="text-[10px] text-slate-400">(已选为经理)</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{seatAgentIds.length > 0 && (
|
||||
<div className="mt-1 text-[10px] text-slate-400">
|
||||
已选 {seatAgentIds.length} 个席位
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 高级项折叠 */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
{showAdvanced ? "收起高级选项" : "展开高级选项"}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="mt-2 space-y-2 rounded-md border border-slate-100 bg-slate-50/50 p-2">
|
||||
<div>
|
||||
<label className="mb-0.5 block text-[11px] text-slate-500">模型名称</label>
|
||||
<Input value={modelName} onChange={(e) => setModelName(e.target.value)} placeholder="留空使用默认" className="h-8 text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-0.5 block text-[11px] text-slate-500">推理强度</label>
|
||||
<Input value={reasoningEffort} onChange={(e) => setReasoningEffort(e.target.value)} placeholder="low / medium / high" className="h-8 text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-0.5 block text-[11px] text-slate-500">最大轮数</label>
|
||||
<Input value={maxRounds} onChange={(e) => setMaxRounds(e.target.value)} placeholder="留空使用默认" type="number" min={1} max={64} className="h-8 text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-0.5 block text-[11px] text-slate-500">写入策略</label>
|
||||
<select
|
||||
value={writePolicy}
|
||||
onChange={(e) => setWritePolicy(e.target.value)}
|
||||
className="h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-xs"
|
||||
>
|
||||
<option value="">默认</option>
|
||||
<option value="auto">auto(自动执行)</option>
|
||||
<option value="confirm_high_risk">confirm_high_risk(高风险确认)</option>
|
||||
<option value="confirm_all_writes">confirm_all_writes(全部确认)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={onSubmit} disabled={sessionActions.create.isPending}>
|
||||
{sessionActions.create.isPending && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
|
||||
创建
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// 删除会话按钮(预留导出)。
|
||||
export function DeleteSessionButton({ sessionId }: { sessionId: string }) {
|
||||
const actions = useMafSessionActions();
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-red-500 hover:bg-red-50 hover:text-red-600"
|
||||
onClick={() => {
|
||||
actions.remove.mutate(sessionId, {
|
||||
onSuccess: () => toast.success("已删除"),
|
||||
onError: (e: Error) => toast.error(`删除失败:${e.message}`),
|
||||
});
|
||||
}}
|
||||
disabled={actions.remove.isPending}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
删除
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* MAF 多智能体会商路由树(/page/maf/*)。
|
||||
*
|
||||
* 仿 RoundtableRoutes:顶层包一层满高容器,内部 <Routes>:
|
||||
* index → 会话列表
|
||||
* runs/:runId → 单 run 工作区(时间线/席位/流程图/审批)
|
||||
* * → 重定向到 index
|
||||
*/
|
||||
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
|
||||
import MafRunDetailPage from "@/maf-roundtable/pages/MafRunDetailPage";
|
||||
import MafSessionsPage from "@/maf-roundtable/pages/MafSessionsPage";
|
||||
|
||||
export default function MafRoutes() {
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<Routes>
|
||||
<Route index element={<MafSessionsPage />} />
|
||||
<Route path="runs/:runId" element={<MafRunDetailPage />} />
|
||||
<Route path="*" element={<Navigate to="/page/maf" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import Header from "@/strategy-components/Header";
|
||||
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
|
||||
import RoundtableRoutes from "./RoundtableRoutes";
|
||||
import NotebookRoutes from "./NotebookRoutes";
|
||||
import MafRoutes from "./MafRoutes";
|
||||
import { RoundtableJobNotifier } from "@/roundtable-planning/components/RoundtableJobNotifier";
|
||||
|
||||
function PageLayout() {
|
||||
@@ -166,6 +167,7 @@ function PageLayout() {
|
||||
<Route path="canvas/*" element={<OpenCanvasRoutes />} />
|
||||
<Route path="roundtable/*" element={<RoundtableRoutes />} />
|
||||
<Route path="notebook/*" element={<NotebookRoutes />} />
|
||||
<Route path="maf/*" element={<MafRoutes />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,9 @@ import sys
|
||||
import threading
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from time import perf_counter
|
||||
from datetime import datetime
|
||||
from time import perf_counter
|
||||
from typing import Any
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI
|
||||
@@ -958,8 +959,63 @@ def _warn_if_multi_worker() -> None:
|
||||
pass
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
async def _scan_maf_approval_expiry(app: Any) -> list[str]:
|
||||
"""Expire due MAF approvals once, then resume affected workflows.
|
||||
|
||||
The store owns the conditional state transition and critical audit event.
|
||||
Only the worker that wins that CAS receives an affected run, so repeated
|
||||
scans cannot resume the same expiry twice.
|
||||
"""
|
||||
|
||||
service = getattr(app.state, "maf_roundtable_service", None)
|
||||
if service is None or not getattr(service, "enabled", False):
|
||||
return []
|
||||
|
||||
expired = await service.store.expire_due_approvals()
|
||||
run_ids = list(dict.fromkeys(
|
||||
str(item["run_id"])
|
||||
for item in expired
|
||||
if item.get("run_id")
|
||||
))
|
||||
if not run_ids:
|
||||
return []
|
||||
|
||||
from app.maf_roundtable.dispatcher import get_dispatcher
|
||||
|
||||
dispatcher = get_dispatcher(app)
|
||||
resumed: list[str] = []
|
||||
for run_id in run_ids:
|
||||
run = await service.store.runs.get_run(run_id)
|
||||
if run is None:
|
||||
continue
|
||||
session = await service.store.sessions.get_session(run["session_id"], user_id=None)
|
||||
if session is None:
|
||||
continue
|
||||
# Dispatcher.resume is a no-op for a terminal/non-waiting run; keeping
|
||||
# this call centralized preserves the restart and run-level CAS path.
|
||||
await dispatcher.resume(run_id, user_id=str(session.get("user_id") or "default"))
|
||||
resumed.append(run_id)
|
||||
return resumed
|
||||
|
||||
|
||||
async def _maf_approval_scanner_loop(app: Any) -> None:
|
||||
"""Continuously fail closed expired MAF approvals until shutdown."""
|
||||
|
||||
service = getattr(app.state, "maf_roundtable_service", None)
|
||||
config = getattr(service, "config", None)
|
||||
interval = max(1, int(getattr(config, "approval_scan_interval_seconds", 10) or 10))
|
||||
while True:
|
||||
try:
|
||||
await _scan_maf_approval_expiry(app)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("MAF approval expiry scan failed (non-fatal)")
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Application lifespan handler."""
|
||||
|
||||
# ?????? worker ?? ?? ?? AI ?? / ? agent ???????
|
||||
@@ -1123,12 +1179,27 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
# Concurrency (system-pressure) sampler — lightweight, singleton-locked,
|
||||
# runtime-toggleable via system_settings.concurrency_monitor.enabled.
|
||||
try:
|
||||
app.state.concurrency_sampler_task = asyncio.create_task(_concurrency_sampler_loop(app))
|
||||
except Exception:
|
||||
logger.exception("Concurrency sampler failed to start (non-fatal)")
|
||||
|
||||
# Start IM channel service if any channels are configured
|
||||
try:
|
||||
app.state.concurrency_sampler_task = asyncio.create_task(_concurrency_sampler_loop(app))
|
||||
except Exception:
|
||||
logger.exception("Concurrency sampler failed to start (non-fatal)")
|
||||
|
||||
# MAF-only approval expiry scanner. Legacy DeerFlow, old roundtable
|
||||
# and AI-writing paths never enter this loop.
|
||||
try:
|
||||
maf_service = getattr(app.state, "maf_roundtable_service", None)
|
||||
if maf_service is not None and getattr(maf_service, "enabled", False):
|
||||
app.state.maf_approval_scanner_task = asyncio.create_task(
|
||||
_maf_approval_scanner_loop(app)
|
||||
)
|
||||
logger.info(
|
||||
"MAF approval expiry scanner started (interval=%ss)",
|
||||
getattr(maf_service.config, "approval_scan_interval_seconds", 10),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("MAF approval expiry scanner failed to start (non-fatal)")
|
||||
|
||||
# Start IM channel service if any channels are configured
|
||||
try:
|
||||
from app.channels.service import start_channel_service
|
||||
|
||||
@@ -1187,16 +1258,26 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
logger.exception("Leaderboard snapshot scheduler shutdown error")
|
||||
|
||||
concurrency_sampler_task = getattr(app.state, "concurrency_sampler_task", None)
|
||||
if concurrency_sampler_task is not None:
|
||||
concurrency_sampler_task.cancel()
|
||||
try:
|
||||
await concurrency_sampler_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("Concurrency sampler shutdown error")
|
||||
|
||||
# ?? 7 ????AI ?? graph ???? checkpointer / in-flight task?
|
||||
if concurrency_sampler_task is not None:
|
||||
concurrency_sampler_task.cancel()
|
||||
try:
|
||||
await concurrency_sampler_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("Concurrency sampler shutdown error")
|
||||
|
||||
maf_approval_scanner_task = getattr(app.state, "maf_approval_scanner_task", None)
|
||||
if maf_approval_scanner_task is not None:
|
||||
maf_approval_scanner_task.cancel()
|
||||
try:
|
||||
await maf_approval_scanner_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("MAF approval expiry scanner shutdown error")
|
||||
|
||||
# ?? 7 ????AI ?? graph ???? checkpointer / in-flight task?
|
||||
# thread ??? LangGraph Server ? checkpointer ???lifespan ???
|
||||
# ``langgraph_runtime()`` ? contextmanager ???????????????
|
||||
|
||||
|
||||
@@ -112,18 +112,25 @@ 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 app.maf_roundtable.service import get_maf_write_guard_readiness
|
||||
from deerflow.config import get_app_config
|
||||
|
||||
cfg = get_app_config().maf_roundtable
|
||||
app_config = get_app_config()
|
||||
cfg = app_config.maf_roundtable
|
||||
svc = _get_service(request)
|
||||
store_available = svc is not None and svc.store is not None
|
||||
readiness = get_maf_write_guard_readiness(app_config=app_config)
|
||||
return HealthResponse(
|
||||
enabled=cfg.enabled,
|
||||
enabled=bool(cfg.enabled and svc is not None and svc.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,
|
||||
write_guard_enabled=readiness["enabled"],
|
||||
write_guard_ready=readiness["ready"],
|
||||
action_secret_configured=readiness["secret_configured"],
|
||||
write_guard_status=readiness["status"],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -359,6 +359,50 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
# These fields activate or authorize MAF WriteGuard behavior. They may only
|
||||
# cross the Gateway boundary on the typed, server-created request from
|
||||
# ``DeerFlowRuntimeClient``. In particular, accepting them from a public
|
||||
# ``body.config`` would let a caller forge an active MAF run or a post-approval
|
||||
# passthrough.
|
||||
_MAF_PRIVATE_CONTEXT_KEYS: frozenset[str] = frozenset(
|
||||
{
|
||||
"maf_run_id",
|
||||
"maf_session_id",
|
||||
"maf_user_id",
|
||||
"turn_index",
|
||||
"write_policy",
|
||||
"capability_snapshot",
|
||||
"maf_approved_tool",
|
||||
"maf_approved_action_id",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_internal_maf_request(request: Any) -> bool:
|
||||
state = getattr(request, "state", None)
|
||||
return bool(getattr(state, "maf_internal", False))
|
||||
|
||||
|
||||
def _merge_internal_maf_context(config: dict[str, Any], context: Mapping[str, Any], request: Any) -> None:
|
||||
"""Forward server-generated MAF context, never public request fields."""
|
||||
if not _is_internal_maf_request(request):
|
||||
for container_name in ("context", "configurable"):
|
||||
container = config.get(container_name)
|
||||
if isinstance(container, dict):
|
||||
for key in _MAF_PRIVATE_CONTEXT_KEYS:
|
||||
container.pop(key, None)
|
||||
return
|
||||
|
||||
configurable = config.setdefault("configurable", {})
|
||||
runtime_context = config.setdefault("context", {})
|
||||
for key in _MAF_PRIVATE_CONTEXT_KEYS:
|
||||
if key not in context:
|
||||
continue
|
||||
if isinstance(configurable, dict):
|
||||
configurable.setdefault(key, context[key])
|
||||
if isinstance(runtime_context, dict):
|
||||
runtime_context.setdefault(key, context[key])
|
||||
|
||||
|
||||
def merge_run_context_overrides(config: dict[str, Any], context: Mapping[str, Any] | None) -> None:
|
||||
"""Merge whitelisted keys from ``body.context`` into both ``config['configurable']``
|
||||
@@ -1022,6 +1066,7 @@ async def start_run(
|
||||
if value is not None and top_level_key not in body_context:
|
||||
body_context[top_level_key] = value
|
||||
merge_run_context_overrides(config, body_context or None)
|
||||
_merge_internal_maf_context(config, body_context, request)
|
||||
|
||||
stream_modes = normalize_stream_modes(body.stream_mode)
|
||||
|
||||
|
||||
@@ -54,8 +54,12 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from math import isfinite
|
||||
from typing import Any
|
||||
|
||||
from app.maf_roundtable.sanitize import sanitize, sanitize_payload
|
||||
@@ -140,6 +144,90 @@ def tool_needs_approval(
|
||||
return risk_val >= 3
|
||||
|
||||
|
||||
def derive_target_key(
|
||||
tool_name: str,
|
||||
raw_args: Any,
|
||||
capability_snapshot: dict[str, Any] | None,
|
||||
agent_id: str | None = None,
|
||||
) -> str:
|
||||
"""Return a service-derived, opaque target lease key for a write action.
|
||||
|
||||
The model and browser never get a target-key input. Only the frozen,
|
||||
server-built capability metadata chooses the parameter fields used to
|
||||
identify a business object. Only stable scalar values are accepted;
|
||||
structured or otherwise unstable values conservatively collapse to the
|
||||
unidentified target. The canonical value is protected with a
|
||||
domain-separated HMAC keyed by ``MAF_ACTION_SECRET_KEY`` before it is
|
||||
persisted, so a database reader cannot brute-force ordinary object IDs
|
||||
with a public hash.
|
||||
|
||||
An unclassified/missing identifier deliberately shares one conservative
|
||||
key across writes (rather than guessing that different requests are safe
|
||||
to parallelize). Such tools are already write/R3 by default and require
|
||||
normal approval under the default policy.
|
||||
"""
|
||||
metadata: dict[str, Any] | None = None
|
||||
snapshot_tools = (capability_snapshot or {}).get("tools")
|
||||
if isinstance(snapshot_tools, list):
|
||||
metadata = next(
|
||||
(item for item in snapshot_tools if isinstance(item, dict) and item.get("name") == tool_name),
|
||||
None,
|
||||
)
|
||||
if metadata is None and agent_id:
|
||||
for agent in (capability_snapshot or {}).get("agents") or []:
|
||||
if not isinstance(agent, dict) or agent.get("agent_id") != agent_id:
|
||||
continue
|
||||
for item in agent.get("tools") or []:
|
||||
if isinstance(item, dict) and item.get("name") == tool_name:
|
||||
metadata = item
|
||||
break
|
||||
if metadata is not None:
|
||||
break
|
||||
|
||||
fields = metadata.get("target_key_fields") if isinstance(metadata, dict) else None
|
||||
namespace = metadata.get("target_key_namespace") if isinstance(metadata, dict) else None
|
||||
if not isinstance(fields, list) or not fields or not isinstance(raw_args, dict):
|
||||
canonical = {"scope": "unidentified"}
|
||||
else:
|
||||
valid_fields = [
|
||||
field for field in fields
|
||||
if isinstance(field, str) and field != "target_key" and field in raw_args
|
||||
]
|
||||
if len(valid_fields) != len(fields) or any(
|
||||
not _is_stable_scalar(raw_args[field]) for field in valid_fields
|
||||
):
|
||||
canonical = {"scope": "unidentified"}
|
||||
else:
|
||||
canonical = {
|
||||
"scope": "identified",
|
||||
"tool": tool_name,
|
||||
"namespace": namespace if isinstance(namespace, str) and namespace else "default",
|
||||
"target": {field: raw_args[field] for field in sorted(valid_fields)},
|
||||
}
|
||||
|
||||
encoded = json.dumps(canonical, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
raw_secret = os.getenv("MAF_ACTION_SECRET_KEY", "")
|
||||
if not raw_secret.strip():
|
||||
raise MafActionSecretKeyError(
|
||||
"MAF_ACTION_SECRET_KEY is required before deriving a real-write target key"
|
||||
)
|
||||
domain = "maf-target-key:v1"
|
||||
digest = hmac.new(
|
||||
raw_secret.encode("utf-8"),
|
||||
domain.encode("ascii") + b"\x00" + encoded.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
return f"maf-target:v1:{digest}"
|
||||
|
||||
|
||||
def _is_stable_scalar(value: Any) -> bool:
|
||||
"""Whether a configured target field can safely identify one target."""
|
||||
|
||||
if value is None or isinstance(value, (str, bool, int)):
|
||||
return True
|
||||
return isinstance(value, float) and isfinite(value)
|
||||
|
||||
|
||||
# ── Encryption ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -230,6 +318,10 @@ class ActionCoordinator:
|
||||
self._store = service.store
|
||||
self._user_id = user_id
|
||||
|
||||
def _approval_expires_at(self) -> datetime:
|
||||
seconds = int(getattr(self._service.config, "approval_timeout_seconds", 3600) or 3600)
|
||||
return datetime.now(UTC) + timedelta(seconds=seconds)
|
||||
|
||||
# ── Post-turn: capture intercepted write tool calls ──────────────────
|
||||
|
||||
async def intercept_write_tool_calls(
|
||||
@@ -268,6 +360,7 @@ class ActionCoordinator:
|
||||
|
||||
_side_effect, risk_level = classify_tool(tool_name, capability_snapshot, agent_id)
|
||||
idempotency_key = f"{run_id}:{agent_id}:{turn_index}:{tool_call_id or tool_name}"
|
||||
target_key = derive_target_key(tool_name, raw_args, capability_snapshot, agent_id)
|
||||
|
||||
# Sanitize args for audit visibility (never expose raw args).
|
||||
sanitized_args = sanitize(raw_args)
|
||||
@@ -290,7 +383,7 @@ class ActionCoordinator:
|
||||
logical_action_id=tool_call_id or None,
|
||||
idempotency_key=idempotency_key,
|
||||
risk_level=risk_level,
|
||||
target_key=None,
|
||||
target_key=target_key,
|
||||
turn_index=turn_index,
|
||||
arguments_sanitized=sanitized_args,
|
||||
arguments_encrypted=encrypted_args,
|
||||
@@ -303,6 +396,7 @@ class ActionCoordinator:
|
||||
},
|
||||
audit_event_agent_id=agent_id,
|
||||
audit_event_turn_index=turn_index,
|
||||
approval_expires_at=self._approval_expires_at(),
|
||||
)
|
||||
|
||||
created.append(action)
|
||||
@@ -322,7 +416,10 @@ class ActionCoordinator:
|
||||
approved. This guarantees at-most-once execution under concurrent
|
||||
``resume()`` / retries / restart.
|
||||
"""
|
||||
return await self._store.actions.claim_for_execution(action_id)
|
||||
lease_seconds = int(getattr(self._service.config, "target_lease_seconds", 900) or 900)
|
||||
return await self._store.claim_action_for_execution(
|
||||
action_id, lease_seconds=lease_seconds
|
||||
)
|
||||
|
||||
async def record_result(
|
||||
self,
|
||||
@@ -344,7 +441,7 @@ class ActionCoordinator:
|
||||
is a no-op — it must NOT overwrite the real result.
|
||||
"""
|
||||
sanitized_result = sanitize_payload(result) if result else None
|
||||
await self._store.actions.record_terminal_result(
|
||||
await self._store.record_action_terminal_result(
|
||||
action_id,
|
||||
status=status,
|
||||
result=sanitized_result,
|
||||
|
||||
@@ -66,7 +66,7 @@ def _seat_tools_for_agent(
|
||||
session_model_name: str | None,
|
||||
session_reasoning_effort: str | None,
|
||||
requested_excluded: list[str],
|
||||
) -> tuple[list[str], list[str], list[dict[str, str]]]:
|
||||
) -> tuple[list[str], list[str], list[dict[str, Any]]]:
|
||||
"""Resolve allowed names, effective excluded, and tool metadata for one seat."""
|
||||
agent_config = load_agent_config(agent_id)
|
||||
tool_groups = getattr(agent_config, "tool_groups", None) if agent_config else None
|
||||
@@ -88,10 +88,19 @@ def _seat_tools_for_agent(
|
||||
effective_excluded = sorted(excluded_set & allowed_set)
|
||||
effective_allowed = sorted(allowed_set - set(effective_excluded))
|
||||
|
||||
tools_meta: list[dict[str, str]] = []
|
||||
tools_meta: list[dict[str, Any]] = []
|
||||
maf_tool_metadata = getattr(
|
||||
getattr(app_config, "maf_roundtable", None), "tool_metadata", None
|
||||
) or {}
|
||||
for name in effective_allowed:
|
||||
extra = _config_extra_for_tool(app_config, name)
|
||||
tools_meta.append(resolve_tool_metadata(name, config_extra=extra))
|
||||
tools_meta.append(
|
||||
resolve_tool_metadata(
|
||||
name,
|
||||
config_extra=extra,
|
||||
maf_metadata=maf_tool_metadata,
|
||||
)
|
||||
)
|
||||
|
||||
return effective_allowed, effective_excluded, tools_meta
|
||||
|
||||
@@ -108,7 +117,7 @@ async def build_trusted_capability_snapshot(
|
||||
) -> dict[str, Any]:
|
||||
"""Build the frozen snapshot stored on the session and used by WriteGuard."""
|
||||
agents_out: list[dict[str, Any]] = []
|
||||
flat_tools: dict[str, dict[str, str]] = {}
|
||||
flat_tools: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for p in participants:
|
||||
agent_id = str(p.get("agent_id") or "")
|
||||
|
||||
@@ -137,6 +137,16 @@ def make_action_rejected(agent_id: str, turn_index: int, *, action_id: str, appr
|
||||
return _make("action.rejected", agent_id, turn_index, {"action_id": action_id, "approval_id": approval_id, "decided_by": decided_by})
|
||||
|
||||
|
||||
def make_action_unknown(agent_id: str, turn_index: int, *, action_id: str, reason_code: str) -> dict[str, Any]:
|
||||
"""Record an inconclusive external outcome without leaking tool arguments."""
|
||||
return _make("action.unknown", agent_id, turn_index, {"action_id": action_id, "reason_code": reason_code})
|
||||
|
||||
|
||||
def make_action_waiting_target_lease(agent_id: str, turn_index: int, *, action_id: str, tool_name: str) -> dict[str, Any]:
|
||||
"""A second approved write is serialized behind an active target lease."""
|
||||
return _make("action.waiting_target_lease", agent_id, turn_index, {"action_id": action_id, "tool_name": tool_name})
|
||||
|
||||
|
||||
# ── workflow checkpoint events ─────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -78,6 +78,10 @@ class _InternalState:
|
||||
def __init__(self, principal: ExecutionPrincipal) -> None:
|
||||
self.user = _PrincipalUser(principal)
|
||||
self.auth = None
|
||||
# This marker is set only on the server-created request used by the
|
||||
# MAF dispatcher. Gateway code uses it to distinguish trusted MAF
|
||||
# execution context from user-supplied run configuration.
|
||||
self.maf_internal = True
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -193,8 +193,6 @@ class ActionResponse(BaseModel):
|
||||
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
|
||||
@@ -255,3 +253,7 @@ class HealthResponse(BaseModel):
|
||||
default_write_policy: str
|
||||
default_max_rounds: int
|
||||
admin_diagnostic_access: bool
|
||||
write_guard_enabled: bool
|
||||
write_guard_ready: bool
|
||||
action_secret_configured: bool
|
||||
write_guard_status: str
|
||||
@@ -79,6 +79,56 @@ class MafConfigurationError(RuntimeError):
|
||||
"""
|
||||
|
||||
|
||||
def get_maf_write_guard_readiness(*, app_config: Any | None = None) -> dict[str, Any]:
|
||||
"""Return non-sensitive WriteGuard readiness information for MAF health.
|
||||
|
||||
A missing secret intentionally does not disable read-only MAF work:
|
||||
WriteGuard fails closed before any external write can execute. ``ready``
|
||||
is therefore the signal that the complete approval + encryption path is
|
||||
usable, while ``enabled`` describes whether the middleware is mounted.
|
||||
"""
|
||||
import os
|
||||
|
||||
from deerflow.config.write_guard_config import get_write_guard_config
|
||||
from deerflow.reflection import resolve_variable
|
||||
|
||||
wg = getattr(app_config, "write_guard", None) if app_config is not None else None
|
||||
if wg is None:
|
||||
wg = get_write_guard_config()
|
||||
|
||||
provider = getattr(wg, "provider", None)
|
||||
provider_configured = bool(
|
||||
provider
|
||||
and getattr(provider, "policy_use", "")
|
||||
and getattr(provider, "interceptor_use", "")
|
||||
)
|
||||
bridge_resolvable = False
|
||||
if provider_configured:
|
||||
try:
|
||||
resolve_variable(provider.policy_use)
|
||||
resolve_variable(provider.interceptor_use)
|
||||
bridge_resolvable = True
|
||||
except Exception: # noqa: BLE001 - health must remain safe and non-fatal
|
||||
bridge_resolvable = False
|
||||
|
||||
secret_configured = bool((os.getenv("MAF_ACTION_SECRET_KEY") or "").strip())
|
||||
if not getattr(wg, "enabled", False):
|
||||
status = "disabled"
|
||||
elif not provider_configured or not bridge_resolvable:
|
||||
status = "provider_unavailable"
|
||||
elif not secret_configured:
|
||||
status = "missing_secret"
|
||||
else:
|
||||
status = "ready"
|
||||
|
||||
return {
|
||||
"enabled": bool(getattr(wg, "enabled", False)),
|
||||
"ready": status == "ready",
|
||||
"secret_configured": secret_configured,
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
def validate_maf_prerequisites() -> None:
|
||||
"""Fail loudly if MAF is enabled without its full safety stack in place.
|
||||
|
||||
@@ -86,14 +136,13 @@ def validate_maf_prerequisites() -> None:
|
||||
1. ``write_guard.enabled`` is true (the approval guard is mounted);
|
||||
2. ``write_guard.provider`` is complete (``policy_use`` + ``interceptor_use``);
|
||||
3. the bridge factories actually resolve (the app-layer guard is importable);
|
||||
4. ``MAF_ACTION_SECRET_KEY`` is set (action params are encrypted with it).
|
||||
4. secret readiness is reported separately; a missing secret must not
|
||||
disable read-only MAF work, while writes fail closed before execution.
|
||||
|
||||
Raises ``MafConfigurationError`` on the first unmet condition. Callers on
|
||||
the startup path treat that as "force the feature off"; direct unit-test
|
||||
construction of :class:`MafRoundtableService` does NOT call this.
|
||||
"""
|
||||
import os
|
||||
|
||||
from deerflow.config.write_guard_config import get_write_guard_config
|
||||
from deerflow.reflection import resolve_variable
|
||||
|
||||
@@ -116,10 +165,6 @@ def validate_maf_prerequisites() -> None:
|
||||
raise MafConfigurationError(
|
||||
f"write_guard bridge factories are not resolvable: {exc}"
|
||||
) from exc
|
||||
if not (os.getenv("MAF_ACTION_SECRET_KEY") or "").strip():
|
||||
raise MafConfigurationError(
|
||||
"maf_roundtable.enabled=true requires the MAF_ACTION_SECRET_KEY env var"
|
||||
)
|
||||
|
||||
|
||||
# Session statuses that may be edited (config changes).
|
||||
@@ -150,24 +195,10 @@ class MafRoundtableService:
|
||||
# defaults when no config is injected (keeps unit tests lightweight).
|
||||
self._config = config or MafRoundtableConfig()
|
||||
|
||||
# Startup validation: if the MAF feature is enabled, the
|
||||
# ``MAF_ACTION_SECRET_KEY`` env var MUST be set — external-write
|
||||
# action params are encrypted with it and there is no default key.
|
||||
# A missing key forces the feature off (write paths then return 503)
|
||||
# rather than running with a publicly-known key. This never aborts
|
||||
# Gateway startup (the feature is experimental / opt-in).
|
||||
if enabled:
|
||||
import os
|
||||
|
||||
key = os.getenv("MAF_ACTION_SECRET_KEY", "")
|
||||
if not key or not key.strip():
|
||||
logger.error(
|
||||
"[maf-roundtable] MAF_ACTION_SECRET_KEY is not set — "
|
||||
"disabling the feature (write paths will return 503). "
|
||||
"Set a 32+ char random string before enabling "
|
||||
"maf_roundtable.enabled."
|
||||
)
|
||||
enabled = False
|
||||
# A missing MAF_ACTION_SECRET_KEY does not disable read-only MAF
|
||||
# operation. The action coordinator raises before creating or
|
||||
# executing an external-write action, so writes fail closed until the
|
||||
# deployment injects the secret through the environment.
|
||||
self._enabled = enabled
|
||||
|
||||
@property
|
||||
@@ -539,10 +570,17 @@ class MafRoundtableService:
|
||||
async def list_actions(
|
||||
self, *, run_id: str, user_id: str | None, is_admin: bool = False
|
||||
) -> list[dict[str, Any]]:
|
||||
from deerflow.persistence.maf_roundtable.actions import action_to_public_dict
|
||||
|
||||
run = await self.get_run(run_id=run_id, user_id=user_id, is_admin=is_admin)
|
||||
return await self._store.actions.list_actions(run_id=run["id"])
|
||||
return [
|
||||
action_to_public_dict(action)
|
||||
for action in await self._store.actions.list_actions(run_id=run["id"])
|
||||
]
|
||||
|
||||
async def get_action(self, *, action_id: str, user_id: str | None, is_admin: bool = False) -> dict[str, Any]:
|
||||
from deerflow.persistence.maf_roundtable.actions import action_to_public_dict
|
||||
|
||||
action = await self._store.actions.get_action(action_id)
|
||||
if action is None:
|
||||
raise MafNotFoundError("action", action_id)
|
||||
@@ -550,7 +588,7 @@ class MafRoundtableService:
|
||||
session = await self._store.sessions.get_session(action["session_id"], user_id=scope)
|
||||
if session is None:
|
||||
raise MafNotFoundError("action", action_id)
|
||||
return action
|
||||
return action_to_public_dict(action)
|
||||
|
||||
# ── approvals ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -558,6 +596,8 @@ class MafRoundtableService:
|
||||
self, *, run_id: str, user_id: str | None, is_admin: bool = False
|
||||
) -> list[dict[str, Any]]:
|
||||
run = await self.get_run(run_id=run_id, user_id=user_id, is_admin=is_admin)
|
||||
# Reads are observational. Expiry is performed by the dedicated
|
||||
# scanner; decision/resume paths retain the fail-closed pre-CAS check.
|
||||
return await self._store.approvals.list_approvals(run_id=run["id"])
|
||||
|
||||
async def decide_approval(
|
||||
@@ -579,6 +619,11 @@ class MafRoundtableService:
|
||||
if is_admin and await self._store.sessions.get_session(approval["session_id"], user_id=None) is not None:
|
||||
raise MafForbiddenError("approval", approval_id)
|
||||
raise MafNotFoundError("approval", approval_id)
|
||||
# The timeout transition is server-owned and happens before the
|
||||
# decision CAS, so an expired approval can never be revived by a late
|
||||
# browser request.
|
||||
if approval.get("run_id"):
|
||||
await self._store.expire_due_approvals(run_id=approval["run_id"])
|
||||
try:
|
||||
updated = await self._store.approvals.decide(approval_id, decision=decision, decided_by=user_id)
|
||||
except ValueError as exc:
|
||||
|
||||
@@ -113,6 +113,15 @@ class ToolExecutor:
|
||||
)
|
||||
return {"status": "failed", "error": f"action state machine error: {exc}"}
|
||||
|
||||
if claim == "target_busy":
|
||||
logger.info("ToolExecutor: target lease is held for action %s", action_id)
|
||||
return {"status": "waiting_target_lease", "error": "target lease is held by another action"}
|
||||
if claim == "target_reconciliation_required":
|
||||
logger.warning("ToolExecutor: expired target lease requires reconciliation for %s", action_id)
|
||||
return {
|
||||
"status": "unknown",
|
||||
"error": "target lease expired during another action; reconciliation required",
|
||||
}
|
||||
if claim != "claimed":
|
||||
existing = await self._coordinator._store.actions.get_action(action_id) # noqa: SLF001
|
||||
if claim == "in_progress":
|
||||
@@ -128,9 +137,8 @@ class ToolExecutor:
|
||||
return {"status": claim, "action": existing}
|
||||
|
||||
if not approved_tool:
|
||||
await self._coordinator.record_result(
|
||||
action_id, status="failed",
|
||||
result={"error": "no approved tool name — cannot execute"},
|
||||
await self._record_result_or_log(
|
||||
action_id, status="failed", result={"error": "no approved tool name — cannot execute"}
|
||||
)
|
||||
return {"status": "failed", "error": "no approved tool name"}
|
||||
|
||||
@@ -147,11 +155,14 @@ class ToolExecutor:
|
||||
"ToolExecutor: action %s params unavailable (%s): %s",
|
||||
action_id, exc.reason, safe_msg,
|
||||
)
|
||||
await self._coordinator.record_result(
|
||||
persisted = await self._record_result_or_log(
|
||||
action_id, status="failed",
|
||||
result={"error": f"action secret unavailable: {exc.reason}"},
|
||||
)
|
||||
return {"status": "failed", "error": safe_msg}
|
||||
return {
|
||||
"status": "failed" if persisted else "unknown",
|
||||
"error": safe_msg if persisted else "action result persistence failed",
|
||||
}
|
||||
|
||||
# 3. Read the stored action record (authoritative turn_index + the
|
||||
# run/session/agent/tool the harness will verify against).
|
||||
@@ -191,34 +202,83 @@ class ToolExecutor:
|
||||
"ToolExecutor: direct tool call '%s' raised: %s",
|
||||
approved_tool, safe_msg,
|
||||
)
|
||||
await self._coordinator.record_result(
|
||||
action_id, status="failed", result={"error": safe_msg},
|
||||
# An exception can arrive after the remote request was accepted
|
||||
# (network disconnect / timeout), so fail closed as ``unknown``.
|
||||
persisted = await self._record_result_or_log(
|
||||
action_id, status="unknown", result={"error": "tool_transport_exception"}
|
||||
)
|
||||
return {"status": "failed", "error": safe_msg}
|
||||
return {
|
||||
"status": "unknown",
|
||||
"error": safe_msg if persisted else "action result persistence failed",
|
||||
}
|
||||
|
||||
# 5. Record terminal result (state-machine constrained: only
|
||||
# running → terminal; a lost race is a no-op).
|
||||
outcome_status = outcome.get("status")
|
||||
if outcome_status == "succeeded":
|
||||
await self._coordinator.record_result(
|
||||
action_id, status="succeeded",
|
||||
result={"tool_result": outcome.get("result")},
|
||||
tool_result = outcome.get("result")
|
||||
external_object_id = (
|
||||
str(tool_result.get("external_object_id"))
|
||||
if isinstance(tool_result, dict)
|
||||
and tool_result.get("external_object_id")
|
||||
else None
|
||||
)
|
||||
return {"status": "succeeded", "tool_result": outcome.get("result")}
|
||||
persisted = await self._record_result_or_log(
|
||||
action_id, status="succeeded",
|
||||
result={"tool_result": tool_result},
|
||||
external_object_id=external_object_id,
|
||||
)
|
||||
if not persisted:
|
||||
# The external side effect succeeded but its local terminal
|
||||
# record did not. Never retry; the retained running lease is
|
||||
# later reconciled to unknown by recovery.
|
||||
return {"status": "unknown", "error": "action result persistence failed"}
|
||||
return {"status": "succeeded", "tool_result": tool_result}
|
||||
elif outcome_status == "unknown":
|
||||
# Inconclusive external result (timeout / disconnect / gateway):
|
||||
# record ``unknown`` — never auto-retry, a human must reconcile.
|
||||
error = outcome.get("error", "inconclusive external result")
|
||||
await self._coordinator.record_result(
|
||||
persisted = await self._record_result_or_log(
|
||||
action_id, status="unknown", result={"error": error},
|
||||
)
|
||||
return {"status": "unknown", "error": error}
|
||||
return {"status": "unknown", "error": error if persisted else "action result persistence failed"}
|
||||
else:
|
||||
error = outcome.get("error", "tool execution failed")
|
||||
await self._coordinator.record_result(
|
||||
persisted = await self._record_result_or_log(
|
||||
action_id, status="failed", result={"error": error},
|
||||
)
|
||||
return {"status": "failed", "error": error}
|
||||
return {
|
||||
"status": "failed" if persisted else "unknown",
|
||||
"error": error if persisted else "action result persistence failed",
|
||||
}
|
||||
|
||||
async def _record_result_or_log(
|
||||
self,
|
||||
action_id: str,
|
||||
*,
|
||||
status: str,
|
||||
result: dict[str, Any],
|
||||
external_object_id: str | None = None,
|
||||
) -> bool:
|
||||
"""Persist a terminal outcome without opening a retry path after I/O.
|
||||
|
||||
If this write fails after a real tool reports success, returning an
|
||||
``unknown`` outcome lets the parent run stop for reconciliation while
|
||||
the still-held lease prevents a replay of the external call.
|
||||
"""
|
||||
try:
|
||||
await self._coordinator.record_result(
|
||||
action_id,
|
||||
status=status,
|
||||
result=result,
|
||||
external_object_id=external_object_id,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception(
|
||||
"ToolExecutor: could not persist terminal result for action %s", action_id
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
async def record_unknown(self, action_id: str, *, reason: str) -> None:
|
||||
"""Record an ``unknown`` outcome (network timeout / inconclusive).
|
||||
@@ -230,6 +290,6 @@ class ToolExecutor:
|
||||
"ToolExecutor: action %s outcome unknown — %s (no auto-retry)",
|
||||
action_id, reason,
|
||||
)
|
||||
await self._coordinator.record_result(
|
||||
action_id, status="unknown", result={"reason": reason},
|
||||
await self._record_result_or_log(
|
||||
action_id, status="unknown", result={"reason": reason}
|
||||
)
|
||||
@@ -316,6 +316,10 @@ class WorkflowRunner:
|
||||
logger.warning("Cannot resume run %s: no pending_approvals in checkpoint", run_id)
|
||||
return run
|
||||
|
||||
# Expire due approvals before inspecting the checkpoint. This is a
|
||||
# fail-closed server transition; a late HTTP approval cannot revive it.
|
||||
await self._service.store.expire_due_approvals(run_id=run_id)
|
||||
|
||||
# Check if any approval is still pending — if so, don't resume yet.
|
||||
any_pending = False
|
||||
for pa in state.pending_approvals:
|
||||
@@ -365,14 +369,15 @@ class WorkflowRunner:
|
||||
from app.maf_roundtable.event_normalizer import make_action_rejected
|
||||
# Conditional transition (waiting_approval → rejected) — never
|
||||
# overwrite a terminal state via a generic update.
|
||||
await self._service.store.actions.mark_rejected(action_id)
|
||||
await self._adapter._emit_critical(run_id, make_action_rejected(
|
||||
pa.get("agent_id", ""),
|
||||
state.current_round,
|
||||
action_id=action_id,
|
||||
approval_id=approval_id,
|
||||
decided_by=approval.get("decided_by", ""),
|
||||
))
|
||||
changed = await self._service.store.actions.mark_rejected(action_id)
|
||||
if changed:
|
||||
await self._adapter._emit_critical(run_id, make_action_rejected(
|
||||
pa.get("agent_id", ""),
|
||||
state.current_round,
|
||||
action_id=action_id,
|
||||
approval_id=approval_id,
|
||||
decided_by=approval.get("decided_by", ""),
|
||||
))
|
||||
action_outcomes.append({"action_id": action_id, "status": "rejected"})
|
||||
# Phase-3 P1-3: move the ledger row out of pending into the
|
||||
# failed/unknown partition as rejected.
|
||||
@@ -478,6 +483,37 @@ class WorkflowRunner:
|
||||
# P0-3: determine parent run status from action outcomes.
|
||||
statuses = {o.get("status") for o in action_outcomes}
|
||||
|
||||
# A target held by another approved write remains pending for an
|
||||
# explicit later resume. It is never retried automatically.
|
||||
if "waiting_target_lease" in statuses:
|
||||
from app.maf_roundtable.event_normalizer import make_action_waiting_target_lease
|
||||
|
||||
state.status = "waiting_approval"
|
||||
for outcome in action_outcomes:
|
||||
if outcome.get("status") == "waiting_target_lease":
|
||||
await self._adapter._emit_critical(
|
||||
run_id,
|
||||
make_action_waiting_target_lease(
|
||||
next(
|
||||
(str(pa.get("agent_id") or "") for pa in state.pending_approvals if pa.get("action_id") == outcome.get("action_id")),
|
||||
"",
|
||||
),
|
||||
state.current_round,
|
||||
action_id=str(outcome.get("action_id") or ""),
|
||||
tool_name=str(outcome.get("tool_name") or ""),
|
||||
),
|
||||
)
|
||||
await self._service.store.runs.update_run(run_id, {
|
||||
"status": "waiting_approval",
|
||||
"maf_checkpoint": state.to_dict(),
|
||||
})
|
||||
await self._service.store.sessions.update_session(
|
||||
session_id,
|
||||
user_id=self._user_id,
|
||||
fields={"status": "waiting_approval", "fact_ledger": state.fact_ledger},
|
||||
)
|
||||
return await self._service.store.runs.get_run(run_id)
|
||||
|
||||
# While any action is still in_progress (another executor running it),
|
||||
# the parent run stays running — never write finished_at, never finalize.
|
||||
if "in_progress" in statuses:
|
||||
@@ -572,6 +608,44 @@ class WorkflowRunner:
|
||||
if session is None:
|
||||
return run
|
||||
|
||||
expired_actions = await self._service.store.recover_expired_action_leases(run_id=run_id)
|
||||
if expired_actions:
|
||||
from app.maf_roundtable.event_normalizer import make_action_unknown
|
||||
|
||||
expired_ids = {action["id"] for action in expired_actions}
|
||||
for action in expired_actions:
|
||||
record_action_transition(
|
||||
state.fact_ledger,
|
||||
action_id=action["id"],
|
||||
tool_name=action.get("tool_name", ""),
|
||||
status="unknown",
|
||||
agent_id=action.get("agent_id", ""),
|
||||
)
|
||||
await self._adapter._emit_critical(
|
||||
run_id,
|
||||
make_action_unknown(
|
||||
action.get("agent_id", ""),
|
||||
int(action.get("turn_index") or state.current_round),
|
||||
action_id=action["id"],
|
||||
reason_code="target_lease_expired_during_execution",
|
||||
),
|
||||
)
|
||||
state.pending_approvals = [
|
||||
item for item in state.pending_approvals
|
||||
if item.get("action_id") not in expired_ids
|
||||
]
|
||||
state.status = "waiting_input"
|
||||
await self._service.store.runs.update_run(run_id, {
|
||||
"status": "waiting_input",
|
||||
"maf_checkpoint": state.to_dict(),
|
||||
})
|
||||
await self._service.store.sessions.update_session(
|
||||
state.session_id,
|
||||
user_id=self._user_id,
|
||||
fields={"status": "waiting_input", "fact_ledger": state.fact_ledger},
|
||||
)
|
||||
return await self._service.store.runs.get_run(run_id)
|
||||
|
||||
# Ensure threads still bound.
|
||||
participants = await self._service.store.participants.list_participants(state.session_id)
|
||||
thread_map = await self._adapter.ensure_threads(state.session_id, run_id, participants)
|
||||
|
||||
@@ -558,13 +558,23 @@ task_deeplink:
|
||||
# - 前端入口隐藏
|
||||
# 详见 frontend-web/docs/microsoft-agent-framework-多智能体会商-实施计划.md
|
||||
maf_roundtable:
|
||||
enabled: false # 部署级特性开关;首期仅管理员/白名单可见
|
||||
enabled: true # 启用独立 MAF 会商页面;不影响旧 roundtable 功能
|
||||
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 会商用于诊断(仅读,不含写)
|
||||
seat_timeout_seconds: 600 # 结构化流程单席位超时(秒)
|
||||
approval_scan_interval_seconds: 10 # 审批主动过期扫描间隔(秒)
|
||||
schema_repair_enabled: true # 席位 JSON 校验失败时允许一次定向修复
|
||||
# 必须使用 MCP 实际发现到的运行时 tool name;不要改成 server 名或前缀猜测。
|
||||
# 当前部署仅启用检索 MCP,因此这里只登记两个只读工具。
|
||||
tool_metadata:
|
||||
weknora-retrieval_weknora_list_knowledge_bases:
|
||||
side_effect: read
|
||||
risk_level: R0
|
||||
weknora-retrieval_weknora_search:
|
||||
side_effect: read
|
||||
risk_level: R0
|
||||
|
||||
# ── WriteGuard 中间件配置 ────────────────────────────────────────────────────
|
||||
# 与 maf_roundtable 联动:MAF 会商运行时,WriteGuard 中间件拦截外部写工具调用,
|
||||
@@ -573,7 +583,10 @@ maf_roundtable:
|
||||
# policy_use / interceptor_use 指向 app.maf_roundtable 的具体实现(通过
|
||||
# resolve_variable 解析),保证 harness 层不导入 app.*。
|
||||
write_guard:
|
||||
enabled: false
|
||||
# MAF-only WriteGuard is mounted in production. Legacy runs without
|
||||
# maf_run_id pass through unchanged; a missing MAF_ACTION_SECRET_KEY makes
|
||||
# external writes fail closed while read-only tools and skills remain usable.
|
||||
enabled: true
|
||||
provider:
|
||||
policy_use: app.maf_roundtable.write_guard_bridge:make_policy
|
||||
interceptor_use: app.maf_roundtable.write_guard_bridge:make_interceptor
|
||||
+88
-1
@@ -17,15 +17,74 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
# ── shared enums (config + API schemas) ───────────────────────────────────
|
||||
|
||||
MafMode = Literal["structured", "group_chat"]
|
||||
MafWritePolicy = Literal["auto", "confirm_high_risk", "confirm_all_writes"]
|
||||
MafParticipantRole = Literal["manager", "seat"]
|
||||
MafToolSideEffect = Literal["read", "skill", "write"]
|
||||
MafRiskLevel = Literal["R0", "R1", "R2", "R3", "R4"]
|
||||
|
||||
|
||||
class MafToolMetadata(BaseModel):
|
||||
"""Deployment-owned side-effect classification for one actual tool name.
|
||||
|
||||
This lives in the MAF configuration rather than in the browser request or
|
||||
model output. The key in ``MafRoundtableConfig.tool_metadata`` must be the
|
||||
exact runtime tool name (including the MCP server prefix when applicable).
|
||||
"""
|
||||
|
||||
side_effect: MafToolSideEffect = Field(
|
||||
description="Tool side effect class used by the MAF WriteGuard",
|
||||
)
|
||||
risk_level: MafRiskLevel = Field(
|
||||
description="External action risk level used by MAF approval policy",
|
||||
)
|
||||
target_key_fields: list[str] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"仅服务端配置的、用于派生目标租约键的顶层参数字段。"
|
||||
"参数值只参与稳定哈希,绝不直接写入 target_key。"
|
||||
),
|
||||
)
|
||||
target_key_namespace: str = Field(
|
||||
default="default",
|
||||
min_length=1,
|
||||
max_length=96,
|
||||
description="服务端目标键命名空间;用于隔离不同租户/业务域。",
|
||||
)
|
||||
|
||||
@field_validator("target_key_fields")
|
||||
@classmethod
|
||||
def _validate_target_key_fields(cls, fields: list[str]) -> list[str]:
|
||||
"""Only allow explicit top-level server-owned argument names.
|
||||
|
||||
In particular, a model/client supplied ``target_key`` is never a
|
||||
valid input. Nested paths are deliberately unsupported: a production
|
||||
adapter must register the exact stable business-id fields it owns.
|
||||
"""
|
||||
valid = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
|
||||
normalized: list[str] = []
|
||||
for field in fields:
|
||||
if not isinstance(field, str) or not valid.fullmatch(field):
|
||||
raise ValueError("target_key_fields must be simple top-level argument names")
|
||||
if field == "target_key":
|
||||
raise ValueError("target_key must not be supplied as a target key input")
|
||||
if field not in normalized:
|
||||
normalized.append(field)
|
||||
return normalized
|
||||
|
||||
@field_validator("target_key_namespace")
|
||||
@classmethod
|
||||
def _validate_target_key_namespace(cls, namespace: str) -> str:
|
||||
if not re.fullmatch(r"[A-Za-z0-9_.:-]{1,96}", namespace):
|
||||
raise ValueError("target_key_namespace contains unsupported characters")
|
||||
return namespace
|
||||
|
||||
|
||||
class MafRoundtableConfig(BaseModel):
|
||||
@@ -74,11 +133,39 @@ class MafRoundtableConfig(BaseModel):
|
||||
default=True,
|
||||
description="席位输出未通过 SeatTurnOutput 校验时是否允许一次定向格式修复",
|
||||
)
|
||||
# Server-trusted metadata keyed by the exact runtime tool name. Do not
|
||||
# infer this from MCP server names or prefixes: the actual discovered name
|
||||
# is what the capability snapshot and runtime WriteGuard see.
|
||||
tool_metadata: dict[str, MafToolMetadata] = Field(
|
||||
default_factory=dict,
|
||||
description="MAF 专用工具副作用/风险元数据,键必须是实际运行时 tool name",
|
||||
)
|
||||
approval_timeout_seconds: int = Field(
|
||||
default=3600,
|
||||
ge=60,
|
||||
le=604800,
|
||||
description="外部写入审批有效期(秒);到期后自动拒绝,绝不执行。",
|
||||
)
|
||||
approval_scan_interval_seconds: int = Field(
|
||||
default=10,
|
||||
ge=1,
|
||||
le=3600,
|
||||
description="MAF approval expiry scan interval in seconds",
|
||||
)
|
||||
target_lease_seconds: int = Field(
|
||||
default=900,
|
||||
ge=30,
|
||||
le=7200,
|
||||
description="外部业务目标写入租约有效期(秒);过期运行中的动作转 unknown。",
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MafMode",
|
||||
"MafParticipantRole",
|
||||
"MafRiskLevel",
|
||||
"MafRoundtableConfig",
|
||||
"MafToolMetadata",
|
||||
"MafToolSideEffect",
|
||||
"MafWritePolicy",
|
||||
]
|
||||
+6
-3
@@ -1,9 +1,9 @@
|
||||
"""Microsoft Agent Framework (MAF) roundtable experiment persistence.
|
||||
|
||||
Six independent tables — **no foreign keys to the legacy ``roundtable_*``
|
||||
Nine 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 /
|
||||
isolated: it has its own session / participant / run / event / action / lease /
|
||||
approval tables.
|
||||
|
||||
Boundary: this package lives in the harness layer (``deerflow.*``) and never
|
||||
@@ -18,7 +18,8 @@ Tables (see the implementation plan §8):
|
||||
- ``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
|
||||
- ``maf_roundtable_action_leases`` — active target-write leases
|
||||
- ``maf_roundtable_approvals`` — pending human-approval requests
|
||||
- ``maf_roundtable_seat_runs`` — MAF↔DeerFlow run mapping (survives restart)
|
||||
- ``maf_roundtable_action_secrets`` — encrypted actual execution params (server-only)
|
||||
"""
|
||||
@@ -32,6 +33,7 @@ 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 (
|
||||
MafRoundtableActionLeaseRow,
|
||||
MafRoundtableActionRow,
|
||||
MafRoundtableActionSecretRow,
|
||||
MafRoundtableApprovalRow,
|
||||
@@ -54,6 +56,7 @@ __all__ = [
|
||||
"MafEventRepository",
|
||||
"MafParticipantRepository",
|
||||
"MafRoundtableActionRow",
|
||||
"MafRoundtableActionLeaseRow",
|
||||
"MafRoundtableActionSecretRow",
|
||||
"MafRoundtableApprovalRow",
|
||||
"MafRoundtableEventRow",
|
||||
|
||||
+14
@@ -10,6 +10,7 @@ store does not re-redact.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
@@ -50,6 +51,19 @@ def action_to_dict(row: MafRoundtableActionRow) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def action_to_public_dict(action: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Return the allow-listed action representation exposed by MAF HTTP APIs.
|
||||
|
||||
Target lease keys and idempotency keys are persistence/coordinator
|
||||
details. They must never cross the public boundary.
|
||||
"""
|
||||
|
||||
public = dict(action)
|
||||
public.pop("target_key", None)
|
||||
public.pop("idempotency_key", None)
|
||||
return public
|
||||
|
||||
|
||||
# Back-compat alias for internal callers that used the old private name.
|
||||
_to_dict = action_to_dict
|
||||
|
||||
|
||||
+42
-5
@@ -1,9 +1,9 @@
|
||||
"""ORM models for the MAF (Microsoft Agent Framework) roundtable experiment.
|
||||
|
||||
Six independent tables — **no foreign keys to the legacy ``roundtable_*``
|
||||
Nine 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
|
||||
own session / participant / run / event / action / lease / approval tables, keyed by
|
||||
its own surrogate ids, so the legacy multi-agent roundtable keeps its current
|
||||
behavior, schema, and recovery semantics untouched.
|
||||
|
||||
@@ -14,6 +14,7 @@ Tables (see the implementation plan §8):
|
||||
- ``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_action_leases`` — active target-write leases (one per target key)
|
||||
- ``maf_roundtable_approvals`` — pending human-approval requests
|
||||
|
||||
User isolation: ``maf_roundtable_sessions.user_id`` is the owner; all child
|
||||
@@ -263,7 +264,42 @@ class MafRoundtableActionRow(Base):
|
||||
)
|
||||
|
||||
|
||||
# ── 6. approvals ──────────────────────────────────────────────────────────
|
||||
# ── 6. target leases ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class MafRoundtableActionLeaseRow(Base):
|
||||
"""One active, short-lived lease for a server-derived external target key.
|
||||
|
||||
The key is opaque (a stable SHA-256 digest) and is never sourced from a
|
||||
browser/model field. ``target_key`` is the primary key, which makes the
|
||||
database — not an in-process mutex — arbitrate concurrent writes across
|
||||
workers and restarts. A lease belongs to exactly one action and is removed
|
||||
with a known terminal result; an ``unknown`` result deliberately retains a
|
||||
manual-reconciliation barrier.
|
||||
"""
|
||||
|
||||
__tablename__ = "maf_roundtable_action_leases"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("action_id", name="uq_maf_action_leases_action"),
|
||||
)
|
||||
|
||||
target_key: Mapped[str] = mapped_column(String(255), primary_key=True)
|
||||
action_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("maf_roundtable_actions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
lease_expires_at: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(), nullable=False, 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
|
||||
)
|
||||
|
||||
|
||||
# ── 7. approvals ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class MafRoundtableApprovalRow(Base):
|
||||
@@ -297,7 +333,7 @@ class MafRoundtableApprovalRow(Base):
|
||||
)
|
||||
|
||||
|
||||
# ── 7. seat runs (Phase 2: MAF↔DeerFlow run mapping) ─────────────────────
|
||||
# ── 8. seat runs (Phase 2: MAF↔DeerFlow run mapping) ─────────────────────
|
||||
|
||||
|
||||
class MafRoundtableSeatRunRow(Base):
|
||||
@@ -370,7 +406,7 @@ class MafRoundtableSeatRunRow(Base):
|
||||
)
|
||||
|
||||
|
||||
# ── 8. action secrets (encrypted actual execution params, server-only) ────
|
||||
# ── 9. action secrets (encrypted actual execution params, server-only) ────
|
||||
|
||||
|
||||
class MafRoundtableActionSecretRow(Base):
|
||||
@@ -403,6 +439,7 @@ class MafRoundtableActionSecretRow(Base):
|
||||
|
||||
__all__ = [
|
||||
"MafRoundtableActionRow",
|
||||
"MafRoundtableActionLeaseRow",
|
||||
"MafRoundtableActionSecretRow",
|
||||
"MafRoundtableApprovalRow",
|
||||
"MafRoundtableEventRow",
|
||||
|
||||
+310
-5
@@ -14,19 +14,24 @@ another's transactions.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.maf_roundtable.action_secrets import MafActionSecretRepository
|
||||
from deerflow.persistence.maf_roundtable.actions import MafActionRepository, action_to_dict
|
||||
from deerflow.persistence.maf_roundtable.actions import (
|
||||
MafActionRepository,
|
||||
MafActionStateMachineError,
|
||||
action_to_dict,
|
||||
)
|
||||
from deerflow.persistence.maf_roundtable.approvals import MafApprovalRepository
|
||||
from deerflow.persistence.maf_roundtable.events import MafEventRepository
|
||||
from deerflow.persistence.maf_roundtable.model import (
|
||||
MafRoundtableActionLeaseRow,
|
||||
MafRoundtableActionRow,
|
||||
MafRoundtableActionSecretRow,
|
||||
MafRoundtableApprovalRow,
|
||||
@@ -315,6 +320,7 @@ class MafRoundtableStore:
|
||||
approval_payload: dict[str, Any] | None,
|
||||
audit_event_agent_id: str,
|
||||
audit_event_turn_index: int,
|
||||
approval_expires_at: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create an action + secret + approval + audit event in ONE transaction.
|
||||
|
||||
@@ -432,6 +438,7 @@ class MafRoundtableStore:
|
||||
status="pending",
|
||||
summary=approval_summary,
|
||||
payload_redacted_json=approval_payload,
|
||||
expires_at=approval_expires_at,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
))
|
||||
@@ -482,10 +489,308 @@ class MafRoundtableStore:
|
||||
return result
|
||||
|
||||
raise RuntimeError(
|
||||
f"create_pending_action_bundle failed after {_MAX_BUNDLE_RETRIES} attempts "
|
||||
f"for idempotency_key={idempotency_key}"
|
||||
f"create_pending_action_bundle failed after {_MAX_BUNDLE_RETRIES} attempts"
|
||||
) from last_error
|
||||
|
||||
async def claim_action_for_execution(self, action_id: str, *, lease_seconds: int) -> str:
|
||||
"""Atomically claim an approved action and its external-target lease.
|
||||
|
||||
The action CAS and the target lease live in one transaction. A second
|
||||
approved action for the same target receives ``target_busy`` and is not
|
||||
executed. An expired lease owned by a still-running action is treated
|
||||
as an ambiguous external outcome: the old action becomes ``unknown``
|
||||
and the caller receives ``target_reconciliation_required`` rather than
|
||||
being allowed to write over it.
|
||||
"""
|
||||
# Pre-Phase-5 actions have no server-derived target key. Preserve the
|
||||
# original conditional UPDATE CAS for them; replacing it with a
|
||||
# read-then-write transaction would weaken their existing cross-worker
|
||||
# at-most-once guarantee on SQLite (where FOR UPDATE is a no-op).
|
||||
existing = await self.actions.get_action(action_id)
|
||||
if existing is None:
|
||||
raise MafActionStateMachineError(f"action {action_id} not found during claim")
|
||||
if not existing.get("target_key"):
|
||||
return await self.actions.claim_for_execution(action_id)
|
||||
|
||||
now = _now()
|
||||
expiry = now + timedelta(seconds=max(1, lease_seconds))
|
||||
last_error: Exception | None = None
|
||||
|
||||
for _attempt in range(4):
|
||||
try:
|
||||
async with self._sf() as db:
|
||||
async with db.begin():
|
||||
action = (
|
||||
await db.execute(
|
||||
select(MafRoundtableActionRow)
|
||||
.where(MafRoundtableActionRow.id == action_id)
|
||||
.with_for_update()
|
||||
)
|
||||
).scalars().first()
|
||||
if action is None:
|
||||
raise MafActionStateMachineError(
|
||||
f"action {action_id} not found during claim"
|
||||
)
|
||||
if action.status == "running":
|
||||
return "in_progress"
|
||||
if action.status in ("succeeded", "failed", "unknown", "rejected"):
|
||||
return str(action.status)
|
||||
if action.status != "approved":
|
||||
raise MafActionStateMachineError(
|
||||
f"action {action_id} cannot be claimed from state '{action.status}'"
|
||||
)
|
||||
|
||||
# Legacy rows can be executed at most once with the old
|
||||
# action CAS. All new actions receive a target key from
|
||||
# ActionCoordinator; never manufacture one from client
|
||||
# input here.
|
||||
if action.target_key:
|
||||
lease = (
|
||||
await db.execute(
|
||||
select(MafRoundtableActionLeaseRow)
|
||||
.where(MafRoundtableActionLeaseRow.target_key == action.target_key)
|
||||
.with_for_update()
|
||||
)
|
||||
).scalars().first()
|
||||
if lease is not None and lease.action_id != action.id:
|
||||
owner = (
|
||||
await db.execute(
|
||||
select(MafRoundtableActionRow)
|
||||
.where(MafRoundtableActionRow.id == lease.action_id)
|
||||
.with_for_update()
|
||||
)
|
||||
).scalars().first()
|
||||
if owner is not None and owner.status == "unknown":
|
||||
# Unknown means the remote side effect may
|
||||
# have happened. Keep the lease as a
|
||||
# durable manual-reconciliation barrier;
|
||||
# another write to the same object must not
|
||||
# silently leapfrog it.
|
||||
return "target_reconciliation_required"
|
||||
if lease.lease_expires_at > now:
|
||||
return "target_busy"
|
||||
if owner is not None and owner.status == "running":
|
||||
owner.status = "unknown"
|
||||
owner.result_redacted_json = {
|
||||
"error_code": "target_lease_expired_during_execution"
|
||||
}
|
||||
owner.finished_at = now
|
||||
owner.updated_at = now
|
||||
# Retain the lease as an explicit manual
|
||||
# reconciliation barrier. It is released
|
||||
# only by a future audited reconciliation
|
||||
# flow, never by automatic replay.
|
||||
lease.lease_expires_at = now
|
||||
lease.updated_at = now
|
||||
return "target_reconciliation_required"
|
||||
await db.delete(lease)
|
||||
elif lease is not None and lease.action_id == action.id:
|
||||
# This can only occur after a failed local
|
||||
# terminal-result write. Do not treat it as a
|
||||
# new execution opportunity.
|
||||
return "in_progress"
|
||||
|
||||
db.add(MafRoundtableActionLeaseRow(
|
||||
target_key=action.target_key,
|
||||
action_id=action.id,
|
||||
lease_expires_at=expiry,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
))
|
||||
|
||||
action.status = "running"
|
||||
action.attempt_count = int(action.attempt_count or 0) + 1
|
||||
action.started_at = now
|
||||
action.updated_at = now
|
||||
await db.flush()
|
||||
return "claimed"
|
||||
except IntegrityError as exc:
|
||||
# The target-key primary key is the cross-worker arbitration
|
||||
# point. On a collision retry and observe the winning lease.
|
||||
last_error = exc
|
||||
continue
|
||||
|
||||
raise RuntimeError(f"could not claim action lease for {action_id}") from last_error
|
||||
|
||||
async def record_action_terminal_result(
|
||||
self,
|
||||
action_id: str,
|
||||
*,
|
||||
status: str,
|
||||
result: dict[str, Any] | None = None,
|
||||
external_object_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Persist a terminal result and release a known target lease atomically.
|
||||
|
||||
``unknown`` is intentionally different: its target lease remains as a
|
||||
manual-reconciliation barrier because the remote side effect may have
|
||||
completed even though the local process cannot prove it.
|
||||
"""
|
||||
if status not in ("succeeded", "failed", "unknown"):
|
||||
raise ValueError(f"invalid action result status: {status}")
|
||||
now = _now()
|
||||
async with self._sf() as db:
|
||||
async with db.begin():
|
||||
action = (
|
||||
await db.execute(
|
||||
select(MafRoundtableActionRow)
|
||||
.where(MafRoundtableActionRow.id == action_id)
|
||||
.with_for_update()
|
||||
)
|
||||
).scalars().first()
|
||||
if action is None or action.status != "running":
|
||||
return None
|
||||
action.status = status
|
||||
action.result_redacted_json = result
|
||||
action.external_object_id = external_object_id
|
||||
action.finished_at = now
|
||||
action.updated_at = now
|
||||
if status != "unknown":
|
||||
# A known success or known failure has a deterministic
|
||||
# outcome, so the next approved action may acquire the
|
||||
# target. ``unknown`` deliberately retains the barrier.
|
||||
await db.execute(
|
||||
delete(MafRoundtableActionLeaseRow).where(
|
||||
MafRoundtableActionLeaseRow.action_id == action_id
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return action_to_dict(action)
|
||||
|
||||
async def expire_due_approvals(
|
||||
self, *, run_id: str | None = None, now: datetime | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Expire due approvals and reject unexecuted actions with audit events.
|
||||
|
||||
Expiry is deliberately a fail-closed transition: only an action still
|
||||
waiting for approval becomes rejected; a later/running action is never
|
||||
overwritten. The approval, action and critical event are committed in
|
||||
one transaction so polling sees a coherent terminal decision.
|
||||
"""
|
||||
current = now or _now()
|
||||
last_error: Exception | None = None
|
||||
for _attempt in range(4):
|
||||
try:
|
||||
async with self._sf() as db:
|
||||
async with db.begin():
|
||||
stmt = select(MafRoundtableApprovalRow).where(
|
||||
MafRoundtableApprovalRow.status == "pending",
|
||||
MafRoundtableApprovalRow.expires_at.is_not(None),
|
||||
MafRoundtableApprovalRow.expires_at <= current,
|
||||
)
|
||||
if run_id is not None:
|
||||
stmt = stmt.where(MafRoundtableApprovalRow.run_id == run_id)
|
||||
approvals = (await db.execute(stmt.with_for_update())).scalars().all()
|
||||
if not approvals:
|
||||
return []
|
||||
|
||||
max_seq_by_run: dict[str, int] = {}
|
||||
expired: list[dict[str, Any]] = []
|
||||
for approval in approvals:
|
||||
approval.status = "expired"
|
||||
approval.decided_by = "system:approval_timeout"
|
||||
approval.decided_at = current
|
||||
approval.updated_at = current
|
||||
action = None
|
||||
if approval.action_id:
|
||||
action = (
|
||||
await db.execute(
|
||||
select(MafRoundtableActionRow)
|
||||
.where(MafRoundtableActionRow.id == approval.action_id)
|
||||
.with_for_update()
|
||||
)
|
||||
).scalars().first()
|
||||
if action is not None and action.status == "waiting_approval":
|
||||
action.status = "rejected"
|
||||
action.finished_at = current
|
||||
action.updated_at = current
|
||||
|
||||
if approval.run_id and action is not None:
|
||||
seq = max_seq_by_run.get(approval.run_id)
|
||||
if seq is None:
|
||||
seq = int((
|
||||
await db.execute(
|
||||
select(func.max(MafRoundtableEventRow.seq)).where(
|
||||
MafRoundtableEventRow.run_id == approval.run_id
|
||||
)
|
||||
)
|
||||
).scalar_one() or 0)
|
||||
seq += 1
|
||||
max_seq_by_run[approval.run_id] = seq
|
||||
db.add(MafRoundtableEventRow(
|
||||
id=uuid4().hex,
|
||||
session_id=approval.session_id,
|
||||
run_id=approval.run_id,
|
||||
seq=seq,
|
||||
event_type="action.rejected",
|
||||
agent_id=approval.requested_by_agent_id,
|
||||
turn_index=action.turn_index,
|
||||
source_event_id=f"approval-expired:{approval.id}",
|
||||
payload_json={
|
||||
"action_id": action.id,
|
||||
"approval_id": approval.id,
|
||||
"reason": "approval_expired",
|
||||
},
|
||||
created_at=current,
|
||||
))
|
||||
expired.append({
|
||||
"id": approval.id,
|
||||
"run_id": approval.run_id,
|
||||
"action_id": approval.action_id,
|
||||
})
|
||||
await db.flush()
|
||||
return expired
|
||||
except IntegrityError as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
raise RuntimeError("could not persist expired MAF approvals") from last_error
|
||||
|
||||
async def recover_expired_action_leases(
|
||||
self, *, run_id: str, now: datetime | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Mark stale in-flight writes ``unknown`` during restart recovery.
|
||||
|
||||
No tool is re-run. A lease that survived a process death cannot prove
|
||||
whether the remote side effect completed, so the only safe recovery is
|
||||
an explicit human reconciliation path.
|
||||
"""
|
||||
current = now or _now()
|
||||
async with self._sf() as db:
|
||||
async with db.begin():
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(MafRoundtableActionLeaseRow, MafRoundtableActionRow)
|
||||
.join(
|
||||
MafRoundtableActionRow,
|
||||
MafRoundtableActionLeaseRow.action_id == MafRoundtableActionRow.id,
|
||||
)
|
||||
.where(
|
||||
MafRoundtableActionRow.run_id == run_id,
|
||||
MafRoundtableActionLeaseRow.lease_expires_at <= current,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
).all()
|
||||
recovered: list[dict[str, Any]] = []
|
||||
for lease, action in rows:
|
||||
if action.status == "running":
|
||||
action.status = "unknown"
|
||||
action.result_redacted_json = {
|
||||
"error_code": "target_lease_expired_during_execution"
|
||||
}
|
||||
action.finished_at = current
|
||||
action.updated_at = current
|
||||
recovered.append(action_to_dict(action))
|
||||
# Keep a durable block for manual reconciliation;
|
||||
# automatically deleting it would allow a later write
|
||||
# to overtake an ambiguous external side effect.
|
||||
lease.lease_expires_at = current
|
||||
lease.updated_at = current
|
||||
elif action.status != "unknown":
|
||||
await db.delete(lease)
|
||||
await db.flush()
|
||||
return recovered
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MafActionRepository",
|
||||
|
||||
+10
@@ -37,6 +37,7 @@ passing all secret-derived params and the stored action record as plain data.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, TypedDict
|
||||
@@ -477,6 +478,15 @@ def _interpret_tool_node_result(result: Any, tool_name: str) -> dict[str, Any]:
|
||||
if _is_inconclusive_text(text):
|
||||
return {"status": "unknown", "error": _ERROR_CODE_INCONCLUSIVE}
|
||||
return {"status": "failed", "error": _ERROR_CODE_FAILED}
|
||||
# LangChain may serialize a structured tool return into a JSON string
|
||||
# inside ToolMessage.content. Recover only the safe, allow-listed
|
||||
# fields so an external object id can be audited without exposing
|
||||
# arbitrary tool output.
|
||||
if isinstance(content, str):
|
||||
try:
|
||||
content = json.loads(content)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return {"status": "succeeded", "result": _safe_success_payload(content)}
|
||||
|
||||
return {"status": "succeeded", "result": {"executed": True}}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
"""Test-only real external-write tool for MAF approval E2E tests.
|
||||
|
||||
This module is deliberately not registered in ``config.yaml``. Tests add the
|
||||
tool to an isolated ``AppConfig`` and set ``MAF_E2E_OUTBOX_DIR`` to a temporary
|
||||
directory. It is a real LangChain ``StructuredTool`` and therefore exercises
|
||||
the same LangGraph ``ToolNode`` path as a configured production tool without
|
||||
touching a business system.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from langchain.tools import tool
|
||||
|
||||
_OPERATION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,95}$")
|
||||
_TEST_BLOCK_STARTED: threading.Event | None = None
|
||||
_TEST_BLOCK_RELEASE: threading.Event | None = None
|
||||
|
||||
|
||||
def configure_test_block(
|
||||
*, started: threading.Event | None = None, release: threading.Event | None = None
|
||||
) -> None:
|
||||
"""Install a test-only barrier for lease contention tests.
|
||||
|
||||
The hook is inert unless a test explicitly supplies both events. It keeps
|
||||
the write inside the real synchronous ToolNode call while allowing the
|
||||
second MAF run to observe the first target lease as ``running``.
|
||||
"""
|
||||
|
||||
global _TEST_BLOCK_STARTED, _TEST_BLOCK_RELEASE
|
||||
_TEST_BLOCK_STARTED = started
|
||||
_TEST_BLOCK_RELEASE = release
|
||||
|
||||
|
||||
def clear_test_block() -> None:
|
||||
configure_test_block()
|
||||
|
||||
|
||||
@tool("maf_test_outbox_write", parse_docstring=True)
|
||||
def maf_test_outbox_write(operation_id: str, value: str) -> dict[str, object]:
|
||||
"""Append one controlled write record to the test outbox.
|
||||
|
||||
Args:
|
||||
operation_id: Stable idempotency identifier for the test action.
|
||||
value: Test payload written only to the temporary outbox.
|
||||
"""
|
||||
outbox_dir = (os.getenv("MAF_E2E_OUTBOX_DIR") or "").strip()
|
||||
if not outbox_dir:
|
||||
raise RuntimeError("MAF_E2E_OUTBOX_DIR is not configured")
|
||||
if not _OPERATION_ID.fullmatch(operation_id):
|
||||
raise ValueError("invalid test outbox operation id")
|
||||
|
||||
if _TEST_BLOCK_STARTED is not None and _TEST_BLOCK_RELEASE is not None:
|
||||
_TEST_BLOCK_STARTED.set()
|
||||
_TEST_BLOCK_RELEASE.wait()
|
||||
|
||||
directory = Path(outbox_dir).resolve()
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
record_path = directory / f"{operation_id}.jsonl"
|
||||
with record_path.open("a", encoding="utf-8", newline="") as record_file:
|
||||
record_file.write(
|
||||
json.dumps({"operation_id": operation_id, "value": value}, ensure_ascii=False)
|
||||
+ "\n"
|
||||
)
|
||||
return {
|
||||
"executed": True,
|
||||
"external_object_id": operation_id,
|
||||
"safe_summary": "test outbox write completed",
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["clear_test_block", "configure_test_block", "maf_test_outbox_write"]
|
||||
@@ -11,6 +11,7 @@ under ``confirm_high_risk``).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
_DEFAULT_SIDE_EFFECT = "write"
|
||||
@@ -42,15 +43,52 @@ def resolve_tool_metadata(
|
||||
tool_name: str,
|
||||
*,
|
||||
config_extra: dict[str, Any] | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Return ``{name, tool_name, side_effect, risk_level}`` for one tool."""
|
||||
maf_metadata: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return trusted metadata for one tool.
|
||||
|
||||
Precedence is intentionally server-only and explicit:
|
||||
|
||||
1. MAF deployment metadata keyed by the exact runtime tool name;
|
||||
2. existing server ``ToolConfig`` extras;
|
||||
3. the curated built-in allow-list;
|
||||
4. conservative ``write`` / ``R3`` fallback.
|
||||
|
||||
``maf_metadata`` is passed by the application while building a frozen
|
||||
capability snapshot. It is never accepted from a session request or a
|
||||
model message.
|
||||
"""
|
||||
side = _DEFAULT_SIDE_EFFECT
|
||||
risk = _DEFAULT_RISK_LEVEL
|
||||
if config_extra:
|
||||
if config_extra.get("side_effect"):
|
||||
side = str(config_extra["side_effect"])
|
||||
if config_extra.get("risk_level"):
|
||||
risk = str(config_extra["risk_level"])
|
||||
target_key_fields: list[str] = []
|
||||
target_key_namespace = "default"
|
||||
registered = maf_metadata.get(tool_name) if maf_metadata else None
|
||||
if registered is not None:
|
||||
candidate_side = registered.get("side_effect") if isinstance(registered, Mapping) else getattr(registered, "side_effect", None)
|
||||
candidate_risk = registered.get("risk_level") if isinstance(registered, Mapping) else getattr(registered, "risk_level", None)
|
||||
if candidate_side in {"read", "skill", "write"}:
|
||||
side = str(candidate_side)
|
||||
if candidate_risk in {"R0", "R1", "R2", "R3", "R4"}:
|
||||
risk = str(candidate_risk)
|
||||
candidate_fields = registered.get("target_key_fields") if isinstance(registered, Mapping) else getattr(registered, "target_key_fields", None)
|
||||
candidate_namespace = registered.get("target_key_namespace") if isinstance(registered, Mapping) else getattr(registered, "target_key_namespace", None)
|
||||
if isinstance(candidate_fields, list) and all(isinstance(item, str) for item in candidate_fields):
|
||||
target_key_fields = list(candidate_fields)
|
||||
if isinstance(candidate_namespace, str) and candidate_namespace:
|
||||
target_key_namespace = candidate_namespace
|
||||
elif config_extra:
|
||||
candidate_side = config_extra.get("side_effect")
|
||||
candidate_risk = config_extra.get("risk_level")
|
||||
if candidate_side in {"read", "skill", "write"}:
|
||||
side = str(candidate_side)
|
||||
if candidate_risk in {"R0", "R1", "R2", "R3", "R4"}:
|
||||
risk = str(candidate_risk)
|
||||
candidate_fields = config_extra.get("target_key_fields")
|
||||
candidate_namespace = config_extra.get("target_key_namespace")
|
||||
if isinstance(candidate_fields, list) and all(isinstance(item, str) for item in candidate_fields):
|
||||
target_key_fields = list(candidate_fields)
|
||||
if isinstance(candidate_namespace, str) and candidate_namespace:
|
||||
target_key_namespace = candidate_namespace
|
||||
elif tool_name in _BUILTIN_METADATA:
|
||||
side, risk = _BUILTIN_METADATA[tool_name]
|
||||
return {
|
||||
@@ -58,4 +96,6 @@ def resolve_tool_metadata(
|
||||
"tool_name": tool_name,
|
||||
"side_effect": side,
|
||||
"risk_level": risk,
|
||||
"target_key_fields": target_key_fields,
|
||||
"target_key_namespace": target_key_namespace,
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Phase 5 migration for MAF external-write target leases.
|
||||
|
||||
Creates the additive ``maf_roundtable_action_leases`` table used to serialize
|
||||
approved writes to the same server-derived external business target. Existing
|
||||
action rows are intentionally not backfilled: old rows have no trusted target
|
||||
key and retain the legacy action-level at-most-once behavior. New writes
|
||||
receive an opaque target key from ``ActionCoordinator``.
|
||||
|
||||
Run this before deploying code that enables MAF external writes:
|
||||
|
||||
PYTHONPATH=. uv run python scripts/migrate_maf_roundtable_phase5.py --dry-run
|
||||
PYTHONPATH=. uv run python scripts/migrate_maf_roundtable_phase5.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
logger = logging.getLogger("migrate_maf_roundtable_phase5")
|
||||
|
||||
TABLE = "maf_roundtable_action_leases"
|
||||
ACTION_INDEX = "ix_maf_action_leases_action_id"
|
||||
EXPIRY_INDEX = "ix_maf_action_leases_lease_expires_at"
|
||||
|
||||
|
||||
def _dialect_name(conn_or_dialect: Any) -> str:
|
||||
return str(getattr(getattr(conn_or_dialect, "dialect", conn_or_dialect), "name", "sqlite")).lower()
|
||||
|
||||
|
||||
def _timestamp_sql(conn_or_dialect: Any) -> str:
|
||||
"""Return the timestamp type accepted by each supported database."""
|
||||
|
||||
dialect = _dialect_name(conn_or_dialect)
|
||||
if dialect == "postgresql":
|
||||
return "TIMESTAMP WITH TIME ZONE"
|
||||
if dialect in {"mysql", "mariadb"}:
|
||||
return "DATETIME(6)"
|
||||
return "DATETIME"
|
||||
|
||||
|
||||
def _create_lease_table_sql(conn_or_dialect: Any) -> str:
|
||||
timestamp_type = _timestamp_sql(conn_or_dialect)
|
||||
return f"""
|
||||
CREATE TABLE {TABLE} (
|
||||
target_key VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||
action_id VARCHAR(64) NOT NULL,
|
||||
lease_expires_at {timestamp_type} NOT NULL,
|
||||
created_at {timestamp_type} NOT NULL,
|
||||
updated_at {timestamp_type} NOT NULL,
|
||||
CONSTRAINT uq_maf_action_leases_action UNIQUE (action_id),
|
||||
FOREIGN KEY (action_id) REFERENCES maf_roundtable_actions (id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _add_missing_columns(conn: Any, *, inspector: Any, execute: Any) -> None:
|
||||
"""Complete a partially created table without rewriting existing data.
|
||||
|
||||
The migration is additive. A missing primary key or action foreign-key
|
||||
column cannot be safely added to a populated table, so fail loudly rather
|
||||
than creating a lease table that the arbiter could misinterpret.
|
||||
"""
|
||||
|
||||
columns = {column["name"] for column in inspector.get_columns(TABLE)}
|
||||
required = {"target_key", "action_id", "lease_expires_at", "created_at", "updated_at"}
|
||||
missing = required - columns
|
||||
if not missing:
|
||||
return
|
||||
if {"target_key", "action_id"} & missing:
|
||||
raise RuntimeError(
|
||||
f"{TABLE} is incomplete ({sorted(missing)}); recreate it with the deployment migration"
|
||||
)
|
||||
timestamp_type = _timestamp_sql(conn)
|
||||
for column in ("lease_expires_at", "created_at", "updated_at"):
|
||||
if column in missing:
|
||||
execute(f"ALTER TABLE {TABLE} ADD COLUMN {column} {timestamp_type}")
|
||||
|
||||
|
||||
async def _migrate(dry_run: bool) -> int:
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
|
||||
|
||||
await init_engine_from_config(get_app_config().database)
|
||||
sf = get_session_factory()
|
||||
if sf is None:
|
||||
logger.error("Database backend is 'memory' — nothing to migrate.")
|
||||
return 0
|
||||
engine = sf.kw["bind"]
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(_migrate_sync, dry_run=dry_run)
|
||||
await close_engine()
|
||||
return 0
|
||||
|
||||
|
||||
def _migrate_sync(conn: Any, *, dry_run: bool) -> None:
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
inspector = inspect(conn)
|
||||
|
||||
def execute(sql: str) -> None:
|
||||
if dry_run:
|
||||
logger.info("[dry-run] would execute: %s", " ".join(sql.split()))
|
||||
return
|
||||
conn.execute(text(sql))
|
||||
|
||||
if TABLE not in inspector.get_table_names():
|
||||
logger.info("Creating %s", TABLE)
|
||||
execute(_create_lease_table_sql(conn))
|
||||
if dry_run:
|
||||
execute(f"CREATE INDEX {ACTION_INDEX} ON {TABLE} (action_id)")
|
||||
execute(f"CREATE INDEX {EXPIRY_INDEX} ON {TABLE} (lease_expires_at)")
|
||||
return
|
||||
inspector = inspect(conn)
|
||||
else:
|
||||
logger.info("Table %s already exists", TABLE)
|
||||
_add_missing_columns(conn, inspector=inspector, execute=execute)
|
||||
if not dry_run:
|
||||
inspector = inspect(conn)
|
||||
|
||||
indexes = {index.get("name") for index in inspector.get_indexes(TABLE)}
|
||||
if ACTION_INDEX not in indexes:
|
||||
execute(f"CREATE INDEX {ACTION_INDEX} ON {TABLE} (action_id)")
|
||||
if EXPIRY_INDEX not in indexes:
|
||||
execute(f"CREATE INDEX {EXPIRY_INDEX} ON {TABLE} (lease_expires_at)")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Create MAF external-write target lease table")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Preview only; do not write")
|
||||
return asyncio.run(_migrate(parser.parse_args().dry_run))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,396 @@
|
||||
"""Phase-5 target lease, timeout and ambiguous-result safety tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from maf_test_helpers import make_maf_service
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.gateway.app import _scan_maf_approval_expiry
|
||||
from app.maf_roundtable.action_coordinator import (
|
||||
ActionCoordinator,
|
||||
derive_target_key,
|
||||
encrypt_params,
|
||||
)
|
||||
from app.maf_roundtable.service import MafConflictError
|
||||
from app.maf_roundtable.tool_executor import ToolExecutor
|
||||
from app.maf_roundtable.workflow_executors import RoundtableState
|
||||
from app.maf_roundtable.workflow_runner import WorkflowRunner
|
||||
from deerflow.config.maf_roundtable_config import MafRoundtableConfig
|
||||
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||
from deerflow.persistence.maf_roundtable.model import (
|
||||
Base,
|
||||
MafRoundtableActionLeaseRow,
|
||||
MafRoundtableApprovalRow,
|
||||
)
|
||||
|
||||
TEST_TOOL = "controlled_write"
|
||||
|
||||
|
||||
def _snapshot() -> dict:
|
||||
tool = {
|
||||
"name": TEST_TOOL,
|
||||
"side_effect": "write",
|
||||
"risk_level": "R3",
|
||||
"target_key_fields": ["tenant_id", "object_id"],
|
||||
"target_key_namespace": "lease-test",
|
||||
}
|
||||
return {
|
||||
"tools": [tool],
|
||||
"agents": [{"agent_id": "seat-1", "tools": [tool]}],
|
||||
}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def store(tmp_path):
|
||||
# A file-backed SQLite database gives concurrent sessions the same
|
||||
# transaction/constraint semantics used by deployed SQLite installations.
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'maf_action_leases.db'}", echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
sf = async_sessionmaker(engine, expire_on_commit=False)
|
||||
result = make_maf_roundtable_store(sf)
|
||||
assert result is not None
|
||||
yield result
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def svc(store):
|
||||
return make_maf_service(
|
||||
store,
|
||||
enabled=True,
|
||||
config=MafRoundtableConfig(
|
||||
enabled=True,
|
||||
approval_timeout_seconds=60,
|
||||
target_lease_seconds=30,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _run(store, svc) -> tuple[dict, dict]:
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
return session, run
|
||||
|
||||
|
||||
async def _approved_action(
|
||||
store, session: dict, run: dict, *, key: str, target_key: str, agent_id: str = "seat-1"
|
||||
) -> dict:
|
||||
return await store.actions.create_action({
|
||||
"session_id": session["id"],
|
||||
"run_id": run["id"],
|
||||
"agent_id": agent_id,
|
||||
"tool_name": TEST_TOOL,
|
||||
"idempotency_key": key,
|
||||
"target_key": target_key,
|
||||
"risk_level": "R3",
|
||||
"status": "approved",
|
||||
})
|
||||
|
||||
|
||||
def test_target_key_is_server_derived_and_opaque(monkeypatch) -> None:
|
||||
snapshot = _snapshot()
|
||||
original = {
|
||||
"tenant_id": "tenant-a",
|
||||
"object_id": "task-7",
|
||||
"target_key": "browser-forged-value",
|
||||
"secret": "do-not-persist",
|
||||
}
|
||||
same_target_different_noise = {
|
||||
"tenant_id": "tenant-a",
|
||||
"object_id": "task-7",
|
||||
"target_key": "another-forged-value",
|
||||
"secret": "changed",
|
||||
}
|
||||
target = derive_target_key(TEST_TOOL, original, snapshot, "seat-1")
|
||||
assert target == derive_target_key(TEST_TOOL, same_target_different_noise, snapshot, "seat-1")
|
||||
assert target.startswith("maf-target:v1:")
|
||||
assert "tenant-a" not in target
|
||||
assert "task-7" not in target
|
||||
assert "browser-forged" not in target
|
||||
|
||||
nested = derive_target_key(
|
||||
TEST_TOOL,
|
||||
{"tenant_id": "tenant-a", "object_id": {"id": "task-7"}},
|
||||
snapshot,
|
||||
"seat-1",
|
||||
)
|
||||
nested_other = derive_target_key(
|
||||
TEST_TOOL,
|
||||
{"tenant_id": "tenant-a", "object_id": ["task-7"]},
|
||||
snapshot,
|
||||
"seat-1",
|
||||
)
|
||||
assert nested == nested_other
|
||||
|
||||
monkeypatch.setenv("MAF_ACTION_SECRET_KEY", "another-test-domain-secret")
|
||||
assert target != derive_target_key(TEST_TOOL, original, snapshot, "seat-1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_agents_same_target_are_serialized(store, svc):
|
||||
"""The DB lease, not a process lock, blocks the second approved write."""
|
||||
session, run = await _run(store, svc)
|
||||
target_key = derive_target_key(
|
||||
TEST_TOOL, {"tenant_id": "tenant-a", "object_id": "task-7"}, _snapshot(), "seat-1"
|
||||
)
|
||||
first = await _approved_action(store, session, run, key="lease-first", target_key=target_key)
|
||||
second = await _approved_action(
|
||||
store, session, run, key="lease-second", target_key=target_key, agent_id="seat-2"
|
||||
)
|
||||
|
||||
first_claim, second_claim = await asyncio.gather(
|
||||
store.claim_action_for_execution(first["id"], lease_seconds=30),
|
||||
store.claim_action_for_execution(second["id"], lease_seconds=30),
|
||||
)
|
||||
assert sorted([first_claim, second_claim]) == ["claimed", "target_busy"]
|
||||
winner = first if first_claim == "claimed" else second
|
||||
waiting = second if winner["id"] == first["id"] else first
|
||||
assert (await store.actions.get_action(waiting["id"]))["status"] == "approved"
|
||||
|
||||
assert await store.record_action_terminal_result(
|
||||
winner["id"], status="succeeded", result={"safe": True}
|
||||
)
|
||||
assert await store.claim_action_for_execution(waiting["id"], lease_seconds=30) == "claimed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restart_recovers_expired_lease_as_unknown_without_replay(store, svc):
|
||||
session, run = await _run(store, svc)
|
||||
target_key = derive_target_key(
|
||||
TEST_TOOL, {"tenant_id": "tenant-a", "object_id": "task-7"}, _snapshot(), "seat-1"
|
||||
)
|
||||
action = await _approved_action(store, session, run, key="lease-restart", target_key=target_key)
|
||||
assert await store.claim_action_for_execution(action["id"], lease_seconds=1) == "claimed"
|
||||
|
||||
recovered = await store.recover_expired_action_leases(
|
||||
run_id=run["id"], now=datetime.now(UTC) + timedelta(seconds=5)
|
||||
)
|
||||
assert [item["id"] for item in recovered] == [action["id"]]
|
||||
stored = await store.actions.get_action(action["id"])
|
||||
assert stored["status"] == "unknown"
|
||||
assert stored["result"] == {"error_code": "target_lease_expired_during_execution"}
|
||||
assert await store.claim_action_for_execution(action["id"], lease_seconds=30) == "unknown"
|
||||
blocked = await _approved_action(
|
||||
store,
|
||||
session,
|
||||
run,
|
||||
key="lease-restart-blocked",
|
||||
target_key=target_key,
|
||||
agent_id="seat-2",
|
||||
)
|
||||
assert await store.claim_action_for_execution(blocked["id"], lease_seconds=30) == "target_reconciliation_required"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approval_timeout_rejects_action_and_writes_audit_event(monkeypatch, store, svc):
|
||||
monkeypatch.setenv("MAF_ACTION_SECRET_KEY", "test-only-lease-secret")
|
||||
session, run = await _run(store, svc)
|
||||
coordinator = ActionCoordinator(svc, user_id="u1")
|
||||
actions = await coordinator.intercept_write_tool_calls(
|
||||
run_id=run["id"],
|
||||
session_id=session["id"],
|
||||
agent_id="seat-1",
|
||||
turn_index=1,
|
||||
tool_calls=[{
|
||||
"name": TEST_TOOL,
|
||||
"id": "timeout-call",
|
||||
"args": {"tenant_id": "tenant-a", "object_id": "task-7"},
|
||||
}],
|
||||
capability_snapshot=_snapshot(),
|
||||
write_policy="confirm_all_writes",
|
||||
)
|
||||
approval_id = actions[0]["approval_id"]
|
||||
|
||||
expired = await store.expire_due_approvals(
|
||||
run_id=run["id"], now=datetime.now(UTC) + timedelta(minutes=2)
|
||||
)
|
||||
assert expired == [{"id": approval_id, "run_id": run["id"], "action_id": actions[0]["id"]}]
|
||||
assert (await store.approvals.get_approval(approval_id))["status"] == "expired"
|
||||
assert (await store.actions.get_action(actions[0]["id"]))["status"] == "rejected"
|
||||
with pytest.raises(MafConflictError):
|
||||
await svc.decide_approval(approval_id=approval_id, decision="approved", user_id="u1")
|
||||
events = await store.events.list_events(run["id"])
|
||||
assert any(
|
||||
event["event_type"] == "action.rejected"
|
||||
and event["payload"].get("reason") == "approval_expired"
|
||||
for event in events
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_external_success_with_local_result_failure_never_replays(monkeypatch, store, svc):
|
||||
"""After external success, an unavailable DB result write becomes unknown."""
|
||||
monkeypatch.setenv("MAF_ACTION_SECRET_KEY", "test-only-lease-secret")
|
||||
session, run = await _run(store, svc)
|
||||
target_key = derive_target_key(
|
||||
TEST_TOOL, {"tenant_id": "tenant-a", "object_id": "task-7"}, _snapshot(), "seat-1"
|
||||
)
|
||||
action = await _approved_action(store, session, run, key="persist-failure", target_key=target_key)
|
||||
await store.action_secrets.create(
|
||||
action_id=action["id"], arguments_encrypted=encrypt_params({"tenant_id": "tenant-a", "object_id": "task-7"})
|
||||
)
|
||||
|
||||
calls = 0
|
||||
|
||||
class Client:
|
||||
async def execute_tool_directly(self, **_kwargs):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return {"status": "succeeded", "result": {"executed": True}}
|
||||
|
||||
adapter = SimpleNamespace(_client=Client())
|
||||
coordinator = ActionCoordinator(svc, user_id="u1")
|
||||
|
||||
async def fail_terminal_persistence(*_args, **_kwargs):
|
||||
raise RuntimeError("simulated local persistence outage")
|
||||
|
||||
monkeypatch.setattr(coordinator, "record_result", fail_terminal_persistence)
|
||||
executor = ToolExecutor(adapter=adapter, action_coordinator=coordinator, user_id="u1")
|
||||
first = await executor.execute(
|
||||
action_id=action["id"],
|
||||
session_id=session["id"],
|
||||
run_id=run["id"],
|
||||
agent_id="seat-1",
|
||||
thread_id="thread-1",
|
||||
approved_tool=TEST_TOOL,
|
||||
capability_snapshot=_snapshot(),
|
||||
write_policy="confirm_all_writes",
|
||||
)
|
||||
second = await executor.execute(
|
||||
action_id=action["id"],
|
||||
session_id=session["id"],
|
||||
run_id=run["id"],
|
||||
agent_id="seat-1",
|
||||
thread_id="thread-1",
|
||||
approved_tool=TEST_TOOL,
|
||||
capability_snapshot=_snapshot(),
|
||||
write_policy="confirm_all_writes",
|
||||
)
|
||||
|
||||
assert first["status"] == "unknown"
|
||||
assert second["status"] == "in_progress"
|
||||
assert calls == 1
|
||||
recovered = await store.recover_expired_action_leases(
|
||||
run_id=run["id"], now=datetime.now(UTC) + timedelta(minutes=20)
|
||||
)
|
||||
assert [item["id"] for item in recovered] == [action["id"]]
|
||||
assert (await store.actions.get_action(action["id"]))["status"] == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_recovery_stops_on_expired_lease_without_tool_replay(store, svc):
|
||||
"""The restart entry point converts a stale running lease to waiting_input."""
|
||||
session, run = await _run(store, svc)
|
||||
target_key = derive_target_key(
|
||||
TEST_TOOL, {"tenant_id": "tenant-a", "object_id": "task-7"}, _snapshot(), "seat-1"
|
||||
)
|
||||
action = await _approved_action(store, session, run, key="runner-recovery", target_key=target_key)
|
||||
assert await store.claim_action_for_execution(action["id"], lease_seconds=30) == "claimed"
|
||||
async with store.session_factory() as db:
|
||||
await db.execute(
|
||||
update(MafRoundtableActionLeaseRow)
|
||||
.where(MafRoundtableActionLeaseRow.action_id == action["id"])
|
||||
.values(lease_expires_at=datetime.now(UTC) - timedelta(seconds=1))
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
state = RoundtableState(
|
||||
session_id=session["id"],
|
||||
run_id=run["id"],
|
||||
task_text="test",
|
||||
mode="structured",
|
||||
model_name=None,
|
||||
reasoning_effort=None,
|
||||
max_rounds=1,
|
||||
write_policy="confirm_all_writes",
|
||||
manager_agent_id=None,
|
||||
seat_agent_ids=[],
|
||||
thread_map={},
|
||||
capability_snapshot=_snapshot(),
|
||||
)
|
||||
state.current_round = 1
|
||||
state.status = "running"
|
||||
state.pending_approvals = [{
|
||||
"action_id": action["id"],
|
||||
"approval_id": "already-approved",
|
||||
"agent_id": "seat-1",
|
||||
"tool_name": TEST_TOOL,
|
||||
}]
|
||||
await store.runs.update_run(run["id"], {"status": "running", "maf_checkpoint": state.to_dict()})
|
||||
await store.sessions.update_session(session["id"], user_id="u1", fields={"status": "running"})
|
||||
|
||||
runner = WorkflowRunner(SimpleNamespace(), service=svc, user_id="u1")
|
||||
recovered = await runner.recover(run["id"])
|
||||
assert recovered is not None and recovered["status"] == "waiting_input"
|
||||
stored = await store.actions.get_action(action["id"])
|
||||
assert stored["status"] == "unknown"
|
||||
assert (await store.sessions.get_session(session["id"], user_id="u1"))["status"] == "waiting_input"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approval_expiry_scan_is_repeatable_and_get_is_observational(monkeypatch, store, svc):
|
||||
"""A scan expires once; ordinary approval GETs never perform that mutation."""
|
||||
|
||||
session, run = await _run(store, svc)
|
||||
coordinator = ActionCoordinator(svc, user_id="u1")
|
||||
action = (
|
||||
await coordinator.intercept_write_tool_calls(
|
||||
run_id=run["id"],
|
||||
session_id=session["id"],
|
||||
agent_id="seat-1",
|
||||
turn_index=1,
|
||||
tool_calls=[{"name": TEST_TOOL, "id": "expiry-scan", "args": {
|
||||
"tenant_id": "tenant-a", "object_id": "task-7", "value": "x"
|
||||
}}],
|
||||
capability_snapshot=_snapshot(),
|
||||
write_policy="confirm_all_writes",
|
||||
)
|
||||
)[0]
|
||||
|
||||
async with store.session_factory() as db:
|
||||
await db.execute(
|
||||
update(MafRoundtableApprovalRow)
|
||||
.where(MafRoundtableApprovalRow.id == action["approval_id"])
|
||||
.values(expires_at=datetime.now(UTC) - timedelta(seconds=1))
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# The normal read path must not mutate an expired pending row.
|
||||
listed = await svc.list_approvals(run_id=run["id"], user_id="u1")
|
||||
assert listed[0]["status"] == "pending"
|
||||
|
||||
class FakeDispatcher:
|
||||
def __init__(self):
|
||||
self.calls: list[tuple[str, str]] = []
|
||||
|
||||
async def resume(self, run_id: str, *, user_id: str):
|
||||
self.calls.append((run_id, user_id))
|
||||
return await store.runs.get_run(run_id)
|
||||
|
||||
fake = FakeDispatcher()
|
||||
monkeypatch.setattr(
|
||||
"app.maf_roundtable.dispatcher.get_dispatcher",
|
||||
lambda _app: fake,
|
||||
)
|
||||
app = SimpleNamespace(
|
||||
state=SimpleNamespace(maf_roundtable_service=svc),
|
||||
)
|
||||
|
||||
concurrent = await asyncio.gather(
|
||||
_scan_maf_approval_expiry(app),
|
||||
_scan_maf_approval_expiry(app),
|
||||
)
|
||||
repeat_after_restart = await _scan_maf_approval_expiry(app)
|
||||
assert sorted(concurrent, key=len) == [[], [run["id"]]]
|
||||
assert repeat_after_restart == []
|
||||
assert fake.calls == [(run["id"], "u1")]
|
||||
assert (await store.approvals.get_approval(action["approval_id"]))["status"] == "expired"
|
||||
assert (await store.actions.get_action(action["id"]))["status"] == "rejected"
|
||||
@@ -50,7 +50,11 @@ CHANNEL_VALUES: dict[str, Any] = {
|
||||
def app_config():
|
||||
from deerflow.config import get_app_config
|
||||
|
||||
return get_app_config()
|
||||
# The production config mounts WriteGuard. These low-level executor tests
|
||||
# that do not register a MAF service intentionally exercise the harness
|
||||
# path without the app-layer validator; the dedicated enabled tests below
|
||||
# provide the real bridge/service wiring.
|
||||
return get_app_config().model_copy(update={"write_guard": WriteGuardConfig(enabled=False)})
|
||||
|
||||
|
||||
def _snapshot(action_tool: str) -> dict[str, Any]:
|
||||
|
||||
@@ -86,3 +86,33 @@ async def test_inaccessible_agent_rejected():
|
||||
app_config=app_config,
|
||||
agent_store=_DenyStore(),
|
||||
)
|
||||
|
||||
|
||||
def test_maf_tool_metadata_has_highest_server_priority():
|
||||
metadata = resolve_tool_metadata(
|
||||
"weknora-retrieval_weknora_search",
|
||||
config_extra={"side_effect": "write", "risk_level": "R4"},
|
||||
maf_metadata={"weknora-retrieval_weknora_search": {"side_effect": "read", "risk_level": "R0"}},
|
||||
)
|
||||
|
||||
assert metadata["side_effect"] == "read"
|
||||
assert metadata["risk_level"] == "R0"
|
||||
|
||||
|
||||
def test_deployed_weknora_names_are_explicitly_read_only():
|
||||
metadata = get_app_config().maf_roundtable.tool_metadata
|
||||
|
||||
assert metadata["weknora-retrieval_weknora_list_knowledge_bases"].side_effect == "read"
|
||||
assert metadata["weknora-retrieval_weknora_list_knowledge_bases"].risk_level == "R0"
|
||||
assert metadata["weknora-retrieval_weknora_search"].side_effect == "read"
|
||||
assert metadata["weknora-retrieval_weknora_search"].risk_level == "R0"
|
||||
|
||||
|
||||
def test_unknown_tool_metadata_remains_conservative():
|
||||
metadata = resolve_tool_metadata(
|
||||
"unregistered_external_tool",
|
||||
config_extra={"side_effect": "invalid", "risk_level": "R99"},
|
||||
)
|
||||
|
||||
assert metadata["side_effect"] == "write"
|
||||
assert metadata["risk_level"] == "R3"
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Acceptance coverage for the additive Phase-5 target-lease migration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from deerflow.persistence.maf_roundtable.model import Base
|
||||
|
||||
|
||||
def _migration_module():
|
||||
script = Path(__file__).resolve().parent.parent / "scripts" / "migrate_maf_roundtable_phase5.py"
|
||||
spec = importlib.util.spec_from_file_location("migrate_maf_roundtable_phase5", script)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_phase5_lease_migration_is_additive_and_idempotent():
|
||||
module = _migration_module()
|
||||
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
await conn.execute(text("DROP TABLE maf_roundtable_action_leases"))
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(module._migrate_sync, dry_run=False)
|
||||
|
||||
def check(sync_conn):
|
||||
inspector = inspect(sync_conn)
|
||||
assert "maf_roundtable_action_leases" in inspector.get_table_names()
|
||||
columns = {column["name"] for column in inspector.get_columns("maf_roundtable_action_leases")}
|
||||
assert {"target_key", "action_id", "lease_expires_at"} <= columns
|
||||
indexes = {index["name"] for index in inspector.get_indexes("maf_roundtable_action_leases")}
|
||||
assert {"ix_maf_action_leases_action_id", "ix_maf_action_leases_lease_expires_at"} <= indexes
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(check)
|
||||
await conn.execute(text(f"DROP INDEX {module.ACTION_INDEX}"))
|
||||
await conn.execute(text(f"DROP INDEX {module.EXPIRY_INDEX}"))
|
||||
await conn.run_sync(module._migrate_sync, dry_run=False)
|
||||
await conn.run_sync(check)
|
||||
await conn.run_sync(module._migrate_sync, dry_run=True)
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_phase5_migration_dry_run_is_safe_when_table_is_absent():
|
||||
module = _migration_module()
|
||||
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(module._migrate_sync, dry_run=True)
|
||||
tables = await conn.run_sync(lambda sync_conn: inspect(sync_conn).get_table_names())
|
||||
assert "maf_roundtable_action_leases" not in tables
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def test_phase5_ddl_uses_sqlite_datetime():
|
||||
module = _migration_module()
|
||||
ddl = module._create_lease_table_sql(SimpleDialect("sqlite"))
|
||||
assert "lease_expires_at DATETIME NOT NULL" in ddl
|
||||
assert "created_at DATETIME NOT NULL" in ddl
|
||||
|
||||
|
||||
def test_phase5_ddl_uses_postgresql_timestamp_with_timezone():
|
||||
module = _migration_module()
|
||||
ddl = module._create_lease_table_sql(SimpleDialect("postgresql"))
|
||||
assert "lease_expires_at TIMESTAMP WITH TIME ZONE NOT NULL" in ddl
|
||||
assert "created_at TIMESTAMP WITH TIME ZONE NOT NULL" in ddl
|
||||
assert "DATETIME" not in ddl
|
||||
|
||||
|
||||
def test_phase5_ddl_uses_mysql_microsecond_datetime():
|
||||
module = _migration_module()
|
||||
ddl = module._create_lease_table_sql(SimpleDialect("mysql"))
|
||||
assert "lease_expires_at DATETIME(6) NOT NULL" in ddl
|
||||
assert "created_at DATETIME(6) NOT NULL" in ddl
|
||||
|
||||
|
||||
class SimpleDialect:
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Entry-level MAF Phase-5 approval and target-lease acceptance tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, Request
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from langchain_core.language_models.chat_models import BaseChatModel
|
||||
from langchain_core.messages import AIMessage
|
||||
from langchain_core.outputs import ChatGeneration, ChatResult
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
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.dispatcher import get_dispatcher
|
||||
from app.maf_roundtable.write_guard_bridge import register_write_guard_service
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.config.maf_roundtable_config import MafRoundtableConfig, MafToolMetadata
|
||||
from deerflow.config.tool_config import ToolConfig
|
||||
from deerflow.config.write_guard_config import WriteGuardConfig, WriteGuardProviderConfig
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||
from deerflow.runtime import RunManager
|
||||
from deerflow.runtime.events.store.memory import MemoryRunEventStore
|
||||
from deerflow.runtime.stream_bridge.memory import MemoryStreamBridge
|
||||
|
||||
TEST_TOOL = "maf_test_outbox_write"
|
||||
|
||||
|
||||
class _User:
|
||||
def __init__(self, user_id: str) -> None:
|
||||
self.id = user_id
|
||||
self.system_role = "user"
|
||||
|
||||
|
||||
class _ThreadStore:
|
||||
def __init__(self) -> None:
|
||||
self.rows: dict[str, dict] = {}
|
||||
|
||||
async def create(self, thread_id, assistant_id=None, metadata=None):
|
||||
self.rows[thread_id] = {
|
||||
"thread_id": thread_id,
|
||||
"assistant_id": assistant_id,
|
||||
"metadata": metadata or {},
|
||||
"status": "idle",
|
||||
}
|
||||
|
||||
async def get(self, thread_id):
|
||||
return self.rows.get(thread_id)
|
||||
|
||||
async def update_status(self, thread_id, status):
|
||||
self.rows.setdefault(thread_id, {})["status"] = status
|
||||
|
||||
async def update_assistant_id(self, thread_id, assistant_id):
|
||||
self.rows.setdefault(thread_id, {})["assistant_id"] = assistant_id
|
||||
|
||||
async def update_metadata(self, thread_id, metadata):
|
||||
self.rows.setdefault(thread_id, {}).setdefault("metadata", {}).update(metadata)
|
||||
|
||||
async def update_display_name(self, thread_id, display_name):
|
||||
self.rows.setdefault(thread_id, {})["display_name"] = display_name
|
||||
|
||||
|
||||
class _DeterministicModel(BaseChatModel):
|
||||
"""Deterministic model boundary: propose one write, then finish."""
|
||||
|
||||
calls: int = 0
|
||||
|
||||
@property
|
||||
def _llm_type(self) -> str:
|
||||
return "maf-phase5-deterministic"
|
||||
|
||||
def bind_tools(self, tools, **kwargs): # noqa: ARG002
|
||||
return self
|
||||
|
||||
def _generate(self, messages, stop=None, run_manager=None, **kwargs): # noqa: ARG002
|
||||
if self.calls == 0:
|
||||
message = AIMessage(
|
||||
content="",
|
||||
tool_calls=[{
|
||||
"name": TEST_TOOL,
|
||||
"args": {"operation_id": "phase5-target", "value": "controlled"},
|
||||
"id": "phase5-tool-call",
|
||||
"type": "tool_call",
|
||||
}],
|
||||
)
|
||||
else:
|
||||
message = AIMessage(content="deterministic completion")
|
||||
self.calls += 1
|
||||
return ChatResult(generations=[ChatGeneration(message=message)])
|
||||
|
||||
|
||||
def _app_config():
|
||||
config = get_app_config().model_copy(deep=True)
|
||||
config.tools = [
|
||||
*config.tools,
|
||||
ToolConfig(
|
||||
name=TEST_TOOL,
|
||||
group="maf-e2e",
|
||||
use="deerflow.tools.builtins.maf_test_outbox:maf_test_outbox_write",
|
||||
),
|
||||
]
|
||||
config.write_guard = WriteGuardConfig(
|
||||
enabled=True,
|
||||
provider=WriteGuardProviderConfig(
|
||||
policy_use="app.maf_roundtable.write_guard_bridge:make_policy",
|
||||
interceptor_use="app.maf_roundtable.write_guard_bridge:make_interceptor",
|
||||
),
|
||||
)
|
||||
config.maf_roundtable = MafRoundtableConfig(
|
||||
enabled=True,
|
||||
default_write_policy="confirm_all_writes",
|
||||
approval_timeout_seconds=300,
|
||||
approval_scan_interval_seconds=1,
|
||||
tool_metadata={
|
||||
TEST_TOOL: MafToolMetadata(
|
||||
side_effect="write",
|
||||
risk_level="R2",
|
||||
target_key_fields=["operation_id"],
|
||||
target_key_namespace="phase5-e2e",
|
||||
)
|
||||
},
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
class _VisibleAgentStore:
|
||||
async def get_visible(self, agent_id: str, user_id: str): # noqa: ARG002
|
||||
return {"id": agent_id, "user_id": user_id}
|
||||
|
||||
async def ensure_builtin(self, record): # noqa: ARG002
|
||||
return None
|
||||
|
||||
|
||||
def _install_test_app(app: FastAPI, service, config) -> None:
|
||||
app.state.config = config
|
||||
app.state.maf_roundtable_service = service
|
||||
app.state.maf_auto_dispatch = True
|
||||
app.state.checkpointer = InMemorySaver()
|
||||
app.state.thread_store = _ThreadStore()
|
||||
app.state.run_manager = RunManager()
|
||||
app.state.stream_bridge = MemoryStreamBridge()
|
||||
app.state.run_event_store = MemoryRunEventStore()
|
||||
app.state.store = None
|
||||
app.state.agent_store = _VisibleAgentStore()
|
||||
register_write_guard_service(service)
|
||||
app.include_router(maf_router)
|
||||
|
||||
|
||||
async def _wait_dispatch(dispatcher, run_id: str) -> None:
|
||||
task = dispatcher._tasks.get(run_id) # noqa: SLF001 - entry test waits on the real task
|
||||
assert task is not None
|
||||
await task
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def deterministic_agent(monkeypatch, tmp_path):
|
||||
agent_config = SimpleNamespace(
|
||||
tool_groups=["maf-e2e"],
|
||||
id="seat-1",
|
||||
description="MAF E2E test seat",
|
||||
model=None,
|
||||
skills=[],
|
||||
subagent_enabled=False,
|
||||
name="MAF E2E seat",
|
||||
)
|
||||
paths = (
|
||||
"deerflow.config.agents_config.load_agent_config",
|
||||
"app.maf_roundtable.capability_snapshot.load_agent_config",
|
||||
"deerflow.agents.lead_agent.agent.load_agent_config",
|
||||
)
|
||||
for path in paths:
|
||||
monkeypatch.setattr(path, lambda _agent_id, cfg=agent_config: cfg)
|
||||
monkeypatch.setattr("deerflow.agents.lead_agent.agent.load_agent_soul", lambda _agent_id: "")
|
||||
monkeypatch.setattr(
|
||||
"deerflow.agents.lead_agent.agent.create_chat_model",
|
||||
lambda **_kwargs: _DeterministicModel(),
|
||||
)
|
||||
config = _app_config()
|
||||
config.title.enabled = False
|
||||
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
|
||||
monkeypatch.setenv("MAF_ACTION_SECRET_KEY", "phase5-entry-test-secret")
|
||||
monkeypatch.setenv("MAF_E2E_OUTBOX_DIR", str(tmp_path))
|
||||
return config
|
||||
|
||||
|
||||
async def _make_app(tmp_path, config):
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'maf_entry.db'}")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
sf = async_sessionmaker(engine, expire_on_commit=False)
|
||||
store = make_maf_roundtable_store(sf)
|
||||
from app.maf_roundtable.service import MafRoundtableService
|
||||
|
||||
service = MafRoundtableService(
|
||||
store,
|
||||
enabled=True,
|
||||
admin_diagnostic_access=False,
|
||||
config=config.maf_roundtable,
|
||||
agent_store=_VisibleAgentStore(),
|
||||
app_config=config,
|
||||
)
|
||||
app = FastAPI()
|
||||
_install_test_app(app, service, config)
|
||||
|
||||
@app.middleware("http")
|
||||
async def test_auth(request: Request, call_next):
|
||||
request.state.user = _User("u1")
|
||||
return await call_next(request)
|
||||
|
||||
return app, service, engine
|
||||
|
||||
|
||||
async def _create_waiting_run(client: AsyncClient, dispatcher, *, task_text: str, service=None) -> tuple[str, dict]:
|
||||
session_response = await client.post(
|
||||
"/api/maf-roundtable/sessions",
|
||||
json={
|
||||
"task_text": task_text,
|
||||
"write_policy": "confirm_all_writes",
|
||||
"participants": [{"agent_id": "seat-1", "role": "seat"}],
|
||||
},
|
||||
)
|
||||
assert session_response.status_code == 200, session_response.text
|
||||
session = session_response.json()
|
||||
run_response = await client.post(
|
||||
f"/api/maf-roundtable/sessions/{session['id']}/runs",
|
||||
json={},
|
||||
)
|
||||
assert run_response.status_code == 200, run_response.text
|
||||
run = run_response.json()
|
||||
await _wait_dispatch(dispatcher, run["id"])
|
||||
run_state = (await client.get(f"/api/maf-roundtable/runs/{run['id']}")).json()
|
||||
if run_state["status"] != "waiting_approval" and service is not None:
|
||||
actions = await service.store.actions.list_actions(run_id=run["id"])
|
||||
raise AssertionError(f"unexpected run state: {run_state}; actions={actions}")
|
||||
assert run_state["status"] == "waiting_approval"
|
||||
return session["id"], run
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_entry_http_approval_reaches_real_toolnode_once(tmp_path, deterministic_agent):
|
||||
app, service, engine = await _make_app(tmp_path, deterministic_agent)
|
||||
dispatcher = get_dispatcher(app)
|
||||
try:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
session_id, run = await _create_waiting_run(client, dispatcher, task_text="entry approval", service=service)
|
||||
approvals = (await client.get(f"/api/maf-roundtable/runs/{run['id']}/approvals")).json()["approvals"]
|
||||
assert len(approvals) == 1
|
||||
approval_response = await client.post(
|
||||
f"/api/maf-roundtable/approvals/{approvals[0]['id']}/decision",
|
||||
json={"decision": "approved"},
|
||||
)
|
||||
assert approval_response.status_code == 200, approval_response.text
|
||||
action = (await client.get(f"/api/maf-roundtable/runs/{run['id']}/actions")).json()["actions"][0]
|
||||
assert action["status"] == "succeeded"
|
||||
outbox = tmp_path / "phase5-target.jsonl"
|
||||
assert outbox.exists()
|
||||
assert outbox.read_text(encoding="utf-8").count("\n") == 1
|
||||
assert (await client.get(f"/api/maf-roundtable/sessions/{session_id}")).json()["status"] == "completed"
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_entry_http_same_target_waits_for_explicit_resume(tmp_path, deterministic_agent):
|
||||
app, service, engine = await _make_app(tmp_path, deterministic_agent)
|
||||
dispatcher = get_dispatcher(app)
|
||||
from deerflow.tools.builtins import maf_test_outbox
|
||||
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
maf_test_outbox.configure_test_block(started=started, release=release)
|
||||
try:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
_session_one, run_one = await _create_waiting_run(client, dispatcher, task_text="lease one", service=service)
|
||||
approval_one = (await client.get(f"/api/maf-roundtable/runs/{run_one['id']}/approvals")).json()["approvals"][0]
|
||||
first_decision = asyncio.create_task(client.post(
|
||||
f"/api/maf-roundtable/approvals/{approval_one['id']}/decision",
|
||||
json={"decision": "approved"},
|
||||
))
|
||||
assert await asyncio.to_thread(started.wait, 10)
|
||||
|
||||
_session_two, run_two = await _create_waiting_run(client, dispatcher, task_text="lease two", service=service)
|
||||
approval_two = (await client.get(f"/api/maf-roundtable/runs/{run_two['id']}/approvals")).json()["approvals"][0]
|
||||
second_decision = await client.post(
|
||||
f"/api/maf-roundtable/approvals/{approval_two['id']}/decision",
|
||||
json={"decision": "approved"},
|
||||
)
|
||||
assert second_decision.status_code == 200
|
||||
assert (await client.get(f"/api/maf-roundtable/runs/{run_two['id']}")).json()["status"] == "waiting_approval"
|
||||
second_action = (await client.get(f"/api/maf-roundtable/runs/{run_two['id']}/actions")).json()["actions"][0]
|
||||
assert second_action["status"] == "approved"
|
||||
|
||||
release.set()
|
||||
assert (await first_decision).status_code == 200
|
||||
assert (await client.get(f"/api/maf-roundtable/runs/{run_one['id']}")).json()["status"] == "completed"
|
||||
|
||||
resumed = await client.post(f"/api/maf-roundtable/runs/{run_two['id']}/resume")
|
||||
assert resumed.status_code == 200, resumed.text
|
||||
assert (await client.get(f"/api/maf-roundtable/runs/{run_two['id']}")).json()["status"] == "completed"
|
||||
outbox = tmp_path / "phase5-target.jsonl"
|
||||
assert outbox.read_text(encoding="utf-8").count("\n") == 2
|
||||
finally:
|
||||
release.set()
|
||||
maf_test_outbox.clear_test_block()
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,238 @@
|
||||
"""MAF approval-to-real-tool E2E coverage using the test outbox tool.
|
||||
|
||||
The tool is a real LangChain StructuredTool and is loaded through the normal
|
||||
``get_available_tools`` + LangGraph ``ToolNode`` path. It is intentionally
|
||||
only added to this test's AppConfig; production ``config.yaml`` does not
|
||||
register it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from maf_test_helpers import make_maf_service
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.maf_roundtable.action_coordinator import ActionCoordinator
|
||||
from app.maf_roundtable.tool_executor import ToolExecutor
|
||||
from app.maf_roundtable.write_guard_bridge import (
|
||||
MafWriteGuardInterceptor,
|
||||
MafWriteGuardPolicy,
|
||||
register_write_guard_service,
|
||||
)
|
||||
from deerflow.agents.middlewares.write_guard_middleware import WriteGuardMiddleware
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.config.tool_config import ToolConfig
|
||||
from deerflow.config.write_guard_config import WriteGuardConfig, WriteGuardProviderConfig
|
||||
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||
from deerflow.persistence.maf_roundtable.model import Base
|
||||
|
||||
TEST_TOOL = "maf_test_outbox_write"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def store():
|
||||
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
sf = async_sessionmaker(engine, expire_on_commit=False)
|
||||
result = make_maf_roundtable_store(sf)
|
||||
assert result is not None
|
||||
yield result
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def svc(store):
|
||||
result = make_maf_service(store, enabled=True, admin_diagnostic_access=True)
|
||||
register_write_guard_service(result)
|
||||
return result
|
||||
|
||||
|
||||
def _app_config():
|
||||
config = get_app_config().model_copy(deep=True)
|
||||
config.tools = [
|
||||
*config.tools,
|
||||
ToolConfig(
|
||||
name=TEST_TOOL,
|
||||
group="maf-e2e",
|
||||
use="deerflow.tools.builtins.maf_test_outbox:maf_test_outbox_write",
|
||||
),
|
||||
]
|
||||
config.write_guard = WriteGuardConfig(
|
||||
enabled=True,
|
||||
provider=WriteGuardProviderConfig(
|
||||
policy_use="app.maf_roundtable.write_guard_bridge:make_policy",
|
||||
interceptor_use="app.maf_roundtable.write_guard_bridge:make_interceptor",
|
||||
),
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def _snapshot():
|
||||
tool = {"name": TEST_TOOL, "side_effect": "write", "risk_level": "R2"}
|
||||
return {
|
||||
"tools": [tool],
|
||||
"agents": [{
|
||||
"agent_id": "seat-1",
|
||||
"allowed_tool_names": [TEST_TOOL],
|
||||
"excluded_tools": [],
|
||||
"tools": [tool],
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
async def _make_pending_action(svc, store):
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
coordinator = ActionCoordinator(svc, user_id="u1")
|
||||
actions = await coordinator.intercept_write_tool_calls(
|
||||
run_id=run["id"],
|
||||
session_id=session["id"],
|
||||
agent_id="seat-1",
|
||||
turn_index=1,
|
||||
tool_calls=[{
|
||||
"name": TEST_TOOL,
|
||||
"id": "call-outbox-1",
|
||||
"args": {"operation_id": "op-1", "value": "controlled-value"},
|
||||
}],
|
||||
capability_snapshot=_snapshot(),
|
||||
write_policy="confirm_all_writes",
|
||||
)
|
||||
assert len(actions) == 1
|
||||
return session, run, coordinator, actions[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_outbox_requires_approval_and_executes_once(monkeypatch, tmp_path, svc, store):
|
||||
"""Pending action has no side effect; approved ToolNode call writes once."""
|
||||
monkeypatch.setenv("MAF_E2E_OUTBOX_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(
|
||||
"deerflow.config.agents_config.load_agent_config",
|
||||
lambda _agent_id: SimpleNamespace(tool_groups=["maf-e2e"], model=None),
|
||||
)
|
||||
|
||||
session, run, coordinator, action = await _make_pending_action(svc, store)
|
||||
outbox_file = tmp_path / "op-1.jsonl"
|
||||
assert not outbox_file.exists(), "approval interception must not execute the tool"
|
||||
|
||||
assert await store.actions.mark_approved(action["id"]) is True
|
||||
|
||||
class Adapter:
|
||||
class Client:
|
||||
async def execute_tool_directly(self, **kwargs):
|
||||
from deerflow.tools.approved_tool_executor import execute_approved_tool
|
||||
|
||||
return await execute_approved_tool(
|
||||
app_config=_app_config(),
|
||||
checkpointer=None,
|
||||
store=None,
|
||||
thread_id=kwargs["thread_id"],
|
||||
user_id="u1",
|
||||
session_id=kwargs["principal"].session_id,
|
||||
run_id=kwargs["principal"].run_id,
|
||||
agent_id=kwargs["agent_id"],
|
||||
turn_index=kwargs["turn_index"],
|
||||
model_name=None,
|
||||
excluded_tools=[],
|
||||
capability_snapshot=kwargs["capability_snapshot"],
|
||||
write_policy=kwargs["write_policy"],
|
||||
tool_name=kwargs["tool_name"],
|
||||
params=kwargs["params"],
|
||||
approved_action_id=kwargs["approved_action_id"],
|
||||
action_record=kwargs["action_record"],
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self._client = self.Client()
|
||||
|
||||
executor = ToolExecutor(
|
||||
adapter=Adapter(),
|
||||
action_coordinator=coordinator,
|
||||
user_id="u1",
|
||||
)
|
||||
first = await executor.execute(
|
||||
action_id=action["id"],
|
||||
session_id=session["id"],
|
||||
run_id=run["id"],
|
||||
agent_id="seat-1",
|
||||
thread_id="thread-1",
|
||||
approved_tool=TEST_TOOL,
|
||||
capability_snapshot=_snapshot(),
|
||||
write_policy="confirm_all_writes",
|
||||
)
|
||||
second = await executor.execute(
|
||||
action_id=action["id"],
|
||||
session_id=session["id"],
|
||||
run_id=run["id"],
|
||||
agent_id="seat-1",
|
||||
thread_id="thread-1",
|
||||
approved_tool=TEST_TOOL,
|
||||
capability_snapshot=_snapshot(),
|
||||
write_policy="confirm_all_writes",
|
||||
)
|
||||
|
||||
assert first["status"] == "succeeded"
|
||||
assert second["status"] == "succeeded"
|
||||
assert outbox_file.read_text(encoding="utf-8").count("\n") == 1
|
||||
stored = await store.actions.get_action(action["id"])
|
||||
assert stored["status"] == "succeeded"
|
||||
assert stored["result"]["tool_result"]["external_object_id"] == "op-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejected_action_never_reaches_real_tool(monkeypatch, tmp_path, svc, store):
|
||||
monkeypatch.setenv("MAF_E2E_OUTBOX_DIR", str(tmp_path))
|
||||
_session, _run, _coordinator, action = await _make_pending_action(svc, store)
|
||||
|
||||
assert await store.actions.mark_rejected(action["id"]) is True
|
||||
assert not list(tmp_path.iterdir())
|
||||
assert (await store.actions.get_action(action["id"]))["status"] == "rejected"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_secret_fails_closed_before_action_creation(monkeypatch, tmp_path, svc, store):
|
||||
monkeypatch.delenv("MAF_ACTION_SECRET_KEY", raising=False)
|
||||
monkeypatch.setenv("MAF_E2E_OUTBOX_DIR", str(tmp_path))
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
|
||||
middleware = WriteGuardMiddleware(
|
||||
policy=MafWriteGuardPolicy(),
|
||||
interceptor=MafWriteGuardInterceptor(),
|
||||
)
|
||||
called = 0
|
||||
|
||||
context = {
|
||||
"maf_run_id": run["id"],
|
||||
"maf_session_id": session["id"],
|
||||
"maf_user_id": "u1",
|
||||
"agent_id": "seat-1",
|
||||
"turn_index": 1,
|
||||
"write_policy": "confirm_all_writes",
|
||||
"capability_snapshot": _snapshot(),
|
||||
}
|
||||
request = SimpleNamespace(
|
||||
tool_call={
|
||||
"name": TEST_TOOL,
|
||||
"id": "call-missing-key",
|
||||
"args": {"operation_id": "op-missing-key", "value": "must-not-run"},
|
||||
},
|
||||
runtime=SimpleNamespace(context=context),
|
||||
)
|
||||
|
||||
async def handler(_request):
|
||||
nonlocal called
|
||||
called += 1
|
||||
return {"executed": True}
|
||||
|
||||
result = await middleware.awrap_tool_call(request, handler)
|
||||
|
||||
envelope = json.loads(result.content)
|
||||
assert envelope["status"] == "intercept_failed"
|
||||
assert called == 0
|
||||
assert not list(tmp_path.iterdir())
|
||||
assert await store.actions.list_actions(run_id=run["id"]) == []
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Real HTTP approval → dispatcher → runner → ToolNode/outbox acceptance test."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, Request
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from maf_test_helpers import make_maf_service
|
||||
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.action_coordinator import ActionCoordinator
|
||||
from app.maf_roundtable.dispatcher import get_dispatcher
|
||||
from app.maf_roundtable.runtime_client import DeerFlowRuntimeClient
|
||||
from app.maf_roundtable.workflow_executors import RoundtableState
|
||||
from app.maf_roundtable.write_guard_bridge import register_write_guard_service
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.config.maf_roundtable_config import MafRoundtableConfig
|
||||
from deerflow.config.tool_config import ToolConfig
|
||||
from deerflow.config.write_guard_config import WriteGuardConfig, WriteGuardProviderConfig
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||
from deerflow.tools.approved_tool_executor import execute_approved_tool
|
||||
|
||||
TEST_TOOL = "maf_test_outbox_write"
|
||||
|
||||
|
||||
class _User:
|
||||
def __init__(self, user_id: str) -> None:
|
||||
self.id = user_id
|
||||
self.system_role = "user"
|
||||
|
||||
|
||||
def _snapshot() -> dict:
|
||||
tool = {
|
||||
"name": TEST_TOOL,
|
||||
"side_effect": "write",
|
||||
"risk_level": "R2",
|
||||
"target_key_fields": ["operation_id"],
|
||||
"target_key_namespace": "maf-e2e",
|
||||
}
|
||||
return {
|
||||
"tools": [tool],
|
||||
"agents": [{
|
||||
"agent_id": "seat-1",
|
||||
"allowed_tool_names": [TEST_TOOL],
|
||||
"excluded_tools": [],
|
||||
"tools": [tool],
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def _app_config():
|
||||
config = get_app_config().model_copy(deep=True)
|
||||
config.tools = [
|
||||
*config.tools,
|
||||
ToolConfig(
|
||||
name=TEST_TOOL,
|
||||
group="maf-e2e",
|
||||
use="deerflow.tools.builtins.maf_test_outbox:maf_test_outbox_write",
|
||||
),
|
||||
]
|
||||
config.write_guard = WriteGuardConfig(
|
||||
enabled=True,
|
||||
provider=WriteGuardProviderConfig(
|
||||
policy_use="app.maf_roundtable.write_guard_bridge:make_policy",
|
||||
interceptor_use="app.maf_roundtable.write_guard_bridge:make_interceptor",
|
||||
),
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_approval_dispatches_real_tool_once(monkeypatch, tmp_path):
|
||||
"""A real route decision reaches the ToolNode exactly once, even on replay."""
|
||||
monkeypatch.setenv("MAF_ACTION_SECRET_KEY", "test-only-http-e2e-secret")
|
||||
monkeypatch.setenv("MAF_E2E_OUTBOX_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(
|
||||
"deerflow.config.agents_config.load_agent_config",
|
||||
lambda _agent_id: SimpleNamespace(tool_groups=["maf-e2e"], model=None),
|
||||
)
|
||||
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'maf_http_e2e.db'}")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
sf = async_sessionmaker(engine, expire_on_commit=False)
|
||||
store = make_maf_roundtable_store(sf)
|
||||
assert store is not None
|
||||
svc = make_maf_service(
|
||||
store,
|
||||
enabled=True,
|
||||
config=MafRoundtableConfig(enabled=True, approval_timeout_seconds=300),
|
||||
)
|
||||
register_write_guard_service(svc)
|
||||
|
||||
app = FastAPI()
|
||||
app.state.maf_roundtable_service = svc
|
||||
app.include_router(maf_router)
|
||||
|
||||
@app.middleware("http")
|
||||
async def test_auth(request: Request, call_next):
|
||||
request.state.user = _User("u1")
|
||||
return await call_next(request)
|
||||
|
||||
async def execute_directly(_client, **kwargs):
|
||||
return await execute_approved_tool(
|
||||
app_config=_app_config(),
|
||||
checkpointer=None,
|
||||
store=None,
|
||||
thread_id=kwargs["thread_id"],
|
||||
user_id=kwargs["principal"].user_id,
|
||||
session_id=kwargs["principal"].session_id,
|
||||
run_id=kwargs["principal"].run_id,
|
||||
agent_id=kwargs["agent_id"],
|
||||
turn_index=kwargs["turn_index"],
|
||||
model_name=None,
|
||||
excluded_tools=[],
|
||||
capability_snapshot=kwargs["capability_snapshot"],
|
||||
write_policy=kwargs["write_policy"],
|
||||
tool_name=kwargs["tool_name"],
|
||||
params=kwargs["params"],
|
||||
approved_action_id=kwargs["approved_action_id"],
|
||||
action_record=kwargs["action_record"],
|
||||
)
|
||||
|
||||
async def no_model_finalizer(_adapter, state):
|
||||
state.final_result = {"content": "http e2e final report"}
|
||||
|
||||
monkeypatch.setattr(DeerFlowRuntimeClient, "execute_tool_directly", execute_directly)
|
||||
monkeypatch.setattr("app.maf_roundtable.workflow_runner.generate_final_report", no_model_finalizer)
|
||||
|
||||
session = await svc.create_session(user_id="u1", body={"task_text": "HTTP E2E"})
|
||||
run = await svc.create_run(session_id=session["id"], user_id="u1")
|
||||
coordinator = ActionCoordinator(svc, user_id="u1")
|
||||
actions = await coordinator.intercept_write_tool_calls(
|
||||
run_id=run["id"],
|
||||
session_id=session["id"],
|
||||
agent_id="seat-1",
|
||||
turn_index=1,
|
||||
tool_calls=[{
|
||||
"name": TEST_TOOL,
|
||||
"id": "http-outbox-call",
|
||||
"args": {"operation_id": "http-op-1", "value": "controlled"},
|
||||
}],
|
||||
capability_snapshot=_snapshot(),
|
||||
write_policy="confirm_all_writes",
|
||||
)
|
||||
action = actions[0]
|
||||
state = RoundtableState(
|
||||
session_id=session["id"],
|
||||
run_id=run["id"],
|
||||
task_text="HTTP E2E",
|
||||
mode="structured",
|
||||
model_name=None,
|
||||
reasoning_effort=None,
|
||||
max_rounds=1,
|
||||
write_policy="confirm_all_writes",
|
||||
manager_agent_id=None,
|
||||
seat_agent_ids=["seat-1"],
|
||||
thread_map={"seat-1": "thread-1"},
|
||||
capability_snapshot=_snapshot(),
|
||||
)
|
||||
state.current_round = 1
|
||||
state.status = "waiting_approval"
|
||||
state.pending_approvals = [{
|
||||
"action_id": action["id"],
|
||||
"approval_id": action["approval_id"],
|
||||
"agent_id": "seat-1",
|
||||
"tool_name": TEST_TOOL,
|
||||
}]
|
||||
await store.runs.update_run(run["id"], {"status": "waiting_approval", "maf_checkpoint": state.to_dict()})
|
||||
await store.sessions.update_session(
|
||||
session["id"], user_id="u1", fields={"status": "waiting_approval"}
|
||||
)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
listed = await client.get(f"/api/maf-roundtable/runs/{run['id']}/actions")
|
||||
assert listed.status_code == 200
|
||||
listed_action = listed.json()["actions"][0]
|
||||
assert "target_key" not in listed_action
|
||||
assert "idempotency_key" not in listed_action
|
||||
|
||||
fetched = await client.get(f"/api/maf-roundtable/actions/{action['id']}")
|
||||
assert fetched.status_code == 200
|
||||
fetched_action = fetched.json()
|
||||
assert "target_key" not in fetched_action
|
||||
assert "idempotency_key" not in fetched_action
|
||||
|
||||
# The lease arbiter still needs the private key in persistence.
|
||||
stored_before_decision = await store.actions.get_action(action["id"])
|
||||
assert stored_before_decision["target_key"]
|
||||
assert stored_before_decision["idempotency_key"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/maf-roundtable/approvals/{action['approval_id']}/decision",
|
||||
json={"decision": "approved"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "approved"
|
||||
|
||||
duplicate = await client.post(
|
||||
f"/api/maf-roundtable/approvals/{action['approval_id']}/decision",
|
||||
json={"decision": "approved"},
|
||||
)
|
||||
assert duplicate.status_code == 409
|
||||
|
||||
outbox = tmp_path / "http-op-1.jsonl"
|
||||
assert outbox.read_text(encoding="utf-8").count("\n") == 1
|
||||
stored = await store.actions.get_action(action["id"])
|
||||
assert stored["status"] == "succeeded"
|
||||
assert stored["attempt_count"] == 1
|
||||
assert (await store.runs.get_run(run["id"]))["status"] == "completed"
|
||||
|
||||
# A process/retry replay after the terminal transition returns the stored
|
||||
# run and never reaches ToolNode again.
|
||||
replay = await get_dispatcher(app).resume(run["id"], user_id="u1")
|
||||
assert replay is not None and replay["status"] == "completed"
|
||||
assert outbox.read_text(encoding="utf-8").count("\n") == 1
|
||||
|
||||
await engine.dispose()
|
||||
@@ -1,7 +1,7 @@
|
||||
"""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
|
||||
another user's session / run / action / lease / approval, and the nine MAF tables have
|
||||
no foreign-key relationship to the legacy ``roundtable_*`` tables (the
|
||||
experiment is fully isolated —see implementation plan §2.3 "不改原有的工程定义).
|
||||
"""
|
||||
@@ -17,6 +17,7 @@ from app.maf_roundtable.service import MafNotFoundError
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||
from deerflow.persistence.maf_roundtable.model import (
|
||||
MafRoundtableActionLeaseRow,
|
||||
MafRoundtableActionRow,
|
||||
MafRoundtableActionSecretRow,
|
||||
MafRoundtableApprovalRow,
|
||||
@@ -149,7 +150,7 @@ async def test_admin_diagnostic_access_off_keeps_owner_scope(store):
|
||||
|
||||
@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
|
||||
"""The nine 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:
|
||||
@@ -162,6 +163,7 @@ async def test_maf_tables_isolated_from_legacy_roundtable(store):
|
||||
"maf_roundtable_runs",
|
||||
"maf_roundtable_events",
|
||||
"maf_roundtable_actions",
|
||||
"maf_roundtable_action_leases",
|
||||
"maf_roundtable_approvals",
|
||||
"maf_roundtable_seat_runs",
|
||||
"maf_roundtable_action_secrets",
|
||||
@@ -174,6 +176,7 @@ async def test_maf_tables_isolated_from_legacy_roundtable(store):
|
||||
MafRoundtableRunRow,
|
||||
MafRoundtableEventRow,
|
||||
MafRoundtableActionRow,
|
||||
MafRoundtableActionLeaseRow,
|
||||
MafRoundtableActionSecretRow,
|
||||
MafRoundtableApprovalRow,
|
||||
MafRoundtableSeatRunRow,
|
||||
|
||||
@@ -770,12 +770,9 @@ def test_validate_maf_prerequisites_passes_when_all_met(monkeypatch):
|
||||
reset_write_guard_config()
|
||||
|
||||
|
||||
def test_validate_maf_prerequisites_requires_secret_key(monkeypatch):
|
||||
"""validate_maf_prerequisites raises when MAF_ACTION_SECRET_KEY is missing."""
|
||||
from app.maf_roundtable.service import (
|
||||
MafConfigurationError,
|
||||
validate_maf_prerequisites,
|
||||
)
|
||||
def test_validate_maf_prerequisites_allows_read_only_without_secret(monkeypatch):
|
||||
"""A missing key leaves reads available; writes fail closed at interception."""
|
||||
from app.maf_roundtable.service import validate_maf_prerequisites
|
||||
from deerflow.config.write_guard_config import (
|
||||
load_write_guard_config_from_dict,
|
||||
reset_write_guard_config,
|
||||
@@ -790,8 +787,34 @@ def test_validate_maf_prerequisites_requires_secret_key(monkeypatch):
|
||||
"interceptor_use": "app.maf_roundtable.write_guard_bridge:make_interceptor",
|
||||
},
|
||||
})
|
||||
with pytest.raises(MafConfigurationError):
|
||||
validate_maf_prerequisites()
|
||||
validate_maf_prerequisites()
|
||||
finally:
|
||||
reset_write_guard_config()
|
||||
|
||||
|
||||
def test_write_guard_health_reports_missing_secret(monkeypatch):
|
||||
from app.maf_roundtable.service import get_maf_write_guard_readiness
|
||||
from deerflow.config.write_guard_config import (
|
||||
load_write_guard_config_from_dict,
|
||||
reset_write_guard_config,
|
||||
)
|
||||
|
||||
monkeypatch.delenv("MAF_ACTION_SECRET_KEY", raising=False)
|
||||
try:
|
||||
load_write_guard_config_from_dict({
|
||||
"enabled": True,
|
||||
"provider": {
|
||||
"policy_use": "app.maf_roundtable.write_guard_bridge:make_policy",
|
||||
"interceptor_use": "app.maf_roundtable.write_guard_bridge:make_interceptor",
|
||||
},
|
||||
})
|
||||
readiness = get_maf_write_guard_readiness()
|
||||
assert readiness == {
|
||||
"enabled": True,
|
||||
"ready": False,
|
||||
"secret_configured": False,
|
||||
"status": "missing_secret",
|
||||
}
|
||||
finally:
|
||||
reset_write_guard_config()
|
||||
|
||||
|
||||
Reference in new issue
Block a user