docs: 巡检简报+todo 回写(2026-06-15 第2轮)
巡检发现:
- 2196c77 workflow 整文件替换回退破坏(AiChat+Ideas 12项功能)
- B-260615-03 truncated 标志已落地
- AR-8-scroll scrollToBottom smooth 已补
- CR-260615-09 .ai-md 残余:4详情页各21处 scoped .ai-md
(全局 ai-md.css 75行已建,旧副本待清理但非阻塞)
This commit is contained in:
360
docs/02-架构设计/patch_file工具设计-2026-06-15.md
Normal file
360
docs/02-架构设计/patch_file工具设计-2026-06-15.md
Normal file
@@ -0,0 +1,360 @@
|
||||
# Patch File 工具设计
|
||||
|
||||
> 创建: 2026-06-15 | 状态: 设计定稿待实施 | 优先级: P0
|
||||
> 关联 todo: F-260615-06 [P0]
|
||||
|
||||
---
|
||||
|
||||
## 一、问题定义
|
||||
|
||||
### 1.1 现状缺口
|
||||
|
||||
DevFlow AI agent 有 `write_file`(全量覆盖写入)但**无局部编辑能力**。AI 需要修改文件中某几行时只能:
|
||||
|
||||
```
|
||||
read_file → AI 在 context 中拼出完整新内容 → write_file 全量覆盖
|
||||
```
|
||||
|
||||
**问题链**:
|
||||
1. **Token 浪费**:大文件(几百行)只改 3 行却要重发全部内容
|
||||
2. **事故风险**:write_file 全量覆盖已出事故(PROGRESS.md 762 行 → 248 字节,FR-S7 记录)
|
||||
3. **无审计粒度**:无法知道「改了哪里」,只有「整个文件被替换了」
|
||||
4. **并发不安全**:join_all 并行场景下多工具操作同一文件无保护
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
提供 `patch_file` 工具,让 AI 能做**精确的局部文本替换**,形成完整的文件操作闭环:
|
||||
|
||||
```
|
||||
read_file(读) → search_in_file(定位) → patch_file(改) → run_command(验证)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、API 设计
|
||||
|
||||
### 2.1 请求结构
|
||||
|
||||
```rust
|
||||
/// 局部文件更新请求
|
||||
struct PatchFileRequest {
|
||||
/// 目标文件路径(必填,走 validate_path 校验 + 黑名单)
|
||||
path: String,
|
||||
|
||||
/// 文件指纹(可选):用于检测外部修改
|
||||
/// 格式: "{unix_timestamp}_{size}" 如 "1718400000_12345"
|
||||
/// 由 read_file 返回的 file_hash 字段携带
|
||||
expected_hash: Option<String>,
|
||||
|
||||
/// 有序补丁列表(从文件末尾往前执行,避免行号偏移)
|
||||
patches: Vec<Patch>,
|
||||
}
|
||||
|
||||
/// 单个补丁
|
||||
struct Patch {
|
||||
/// 必填:要替换的旧文本(精确匹配 = 乐观锁)
|
||||
old_text: String,
|
||||
|
||||
/// 必填:替换后的新文本
|
||||
new_text: String,
|
||||
|
||||
/// 可选:行号辅助定位(快速跳转 + 去歧增强)
|
||||
/// 有值时优先跳到该行检查 old_text;不匹配则降级全文扫描
|
||||
line: Option<u32>,
|
||||
|
||||
/// 可选:old_text 之前的上下文锚(去歧——多匹配时精确锁定)
|
||||
before_text: Option<String>,
|
||||
|
||||
/// 可选:old_text 之后的上下文锚(去歧)
|
||||
after_text: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 响应结构
|
||||
|
||||
```rust
|
||||
struct PatchResult {
|
||||
success: bool,
|
||||
patches_applied: usize, // 成功替换的 patch 数
|
||||
total_matches: usize, // 每个 patch 的总命中数(含未替换的)
|
||||
lines_changed: i32, // 总行数变化(正=增加 负=减少)
|
||||
warnings: Vec<String>, // ["匹配到 3 处,仅替换第 1 处"]
|
||||
file_hash: String, // 操作后的新指纹(下次操作用)
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 read_file 扩展(返回指纹)
|
||||
|
||||
现有 read_file 返回值新增字段:
|
||||
|
||||
```rust
|
||||
struct ReadFileResult {
|
||||
path: String,
|
||||
content: String,
|
||||
size: u64,
|
||||
lines: u32,
|
||||
// 新增:
|
||||
modified: String, // ISO8601 或 unix timestamp
|
||||
file_hash: String, // mtime+size 指纹,如 "1718400000_12345"
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 LLM 描述(tool_registry 注册用)
|
||||
|
||||
```
|
||||
"局部更新文件内容。用于精确修改文件的特定部分(而非全量覆盖)。
|
||||
每个补丁指定 old_text(要替换的原文)和 new_text(新内容)。
|
||||
可选 line 辅助定位、before/after_text 上下文锚定消除歧义。
|
||||
属 Medium 风险操作(修改已有文件),需人工审批。
|
||||
注意:old_text 必须与文件内容完全匹配(含空格/缩进);若文件已被外部修改,
|
||||
请先重新 read_file 获取最新内容和 file_hash。"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、核心决策记录
|
||||
|
||||
### 决策 1:定位方式 — old_text 精确匹配为主,line 为辅
|
||||
|
||||
| 方案 | 示例 | 优点 | 缺点 |
|
||||
|------|------|------|------|
|
||||
| **A: old_text ✅ 选定** | 匹配 "fn main() {" 替换 | =隐式乐观锁;AI零认知负担(复制即用);内容变了自动冲突报错 | 改动大时 old_string 长 |
|
||||
| B: line 行号 | 替换第 42 行 | 短小精悍 | 文件改了行号偏移;AI需先search定位(多一轮IPC) |
|
||||
| C: 正则 regex | 匹配 `/return Err\(.*\)/` | 表达力强 | AI生成regex易出错;转义复杂 |
|
||||
|
||||
**折中**:old_text **必填**(主定位),line **可选**(辅助快速跳转+去歧),before/after_text **可选**(多匹配去歧)。
|
||||
|
||||
**理由**:
|
||||
- AI 做 edit 时天然持有「要改哪段」上下文,复制即用(最小认知路径)
|
||||
- old_text 天然防并发冲突(CAS 语义:Compare-And-Swap)
|
||||
- line 不单独使用(无内容校验=盲替换,并发不安全)
|
||||
|
||||
### 决策 2:多匹配处理 — 替换第 1 处 + warning
|
||||
|
||||
```
|
||||
文件中有 N(N>1) 处相同 old_text:
|
||||
→ 仅替换第 1 处
|
||||
→ 返回 warning: "⚠️ 匹配到 N 处,仅替换第 1 处"
|
||||
→ AI 收到 warning 后可加 before/after_text 缩小范围重试
|
||||
```
|
||||
|
||||
**不选**「全部替换」(太危险,可能批量错改)或「拒绝执行」(太严格,第 1 处往往就是目标)。
|
||||
|
||||
### 决策 3:并发安全 — 文件级 Mutex(不用队列)
|
||||
|
||||
#### 为什么不用队列
|
||||
|
||||
| 维度 | 通用文件锁队列 | DevFlow 实际需要 |
|
||||
|------|-------------|-----------------|
|
||||
| 范围 | 全局、所有会话、所有文件 | 仅 join_all 并发窗口 + 远期多会话 |
|
||||
| 粒度 | 每文件独立 FIFO 队列 | 文件级 Mutex 就够 |
|
||||
| 复杂度 | 高(调度/超时/死锁检测) | **极低(~15行)** |
|
||||
| 场景 | 多用户 / 分布式 | 单进程单用户桌面应用 |
|
||||
|
||||
#### 当前真实并发源
|
||||
|
||||
```
|
||||
唯一并行点: audit.rs:338 join_all — Low 风险工具并行执行
|
||||
典型场景: AI 同时 list_projects + read_file + (未来) patch_file 同一文件
|
||||
```
|
||||
|
||||
#### 实现
|
||||
|
||||
```rust
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
/// 全局文件锁表:每个路径一把互斥锁
|
||||
static FILE_LOCKS: Lazy<Mutex<HashMap<PathBuf, ()>>> =
|
||||
Lazy::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
// handler 内使用:
|
||||
let abs_path = validated_path.canonicalize()?;
|
||||
let _guard = FILE_LOCKS
|
||||
.lock()
|
||||
.entry(abs_path)
|
||||
.or_insert_with(|| ());
|
||||
// guard drop 时自动释放
|
||||
```
|
||||
|
||||
**效果**:同一文件读写串行化,不同文件仍并行。Mutex 释放后后续操作继续。
|
||||
|
||||
### 决策 4:外部脏写防御 — expected_hash 指纹校验
|
||||
|
||||
#### 指纹选型
|
||||
|
||||
| 方案 | 精度 | 开销 | 适用 |
|
||||
|------|------|------|------|
|
||||
| **mtime + size ✅ 选定** | 秒级 | ~μs(一次 metadata 调用) | 本地桌面应用 |
|
||||
| blake3/sha256 | 内容级 | 大文件 ms 级 | 需要密码学强度时 |
|
||||
| inode + mtime | Unix 语义 | ~μs | Windows inode 不同 |
|
||||
|
||||
选 **mtime + size**:DevFlow 是本地桌面应用,「外部修改」= 用户切 VS Code 改了几行再回来,时间差 >1s。实现最简单。
|
||||
|
||||
#### 流程
|
||||
|
||||
```
|
||||
read_file(path) → { content, file_hash: "1718400000_23456" }
|
||||
↓
|
||||
AI 基于内容决策 ↓
|
||||
patch_file({ path, expected_hash: "1718400000_23456", ... })
|
||||
↓
|
||||
后端:
|
||||
① current_meta = metadata(path)
|
||||
② current_hash = format!("{}_{}", modified.timestamp(), size)
|
||||
③ current_hash != expected_hash?
|
||||
→ Err("⚠️ 文件已被外部修改(hash 不匹配),请重新读取")
|
||||
含 current_hash 让前端可选自动重读
|
||||
④ hash 通过 → 执行 patch(L2 old_text 校验 + L3 .bak)
|
||||
```
|
||||
|
||||
### 决策 5:截断策略 — 软删除标记(非真删)
|
||||
|
||||
关联 UX-2025-09 编辑消息功能。patch_file 本身不涉及消息截断,但设计原则一致:
|
||||
|
||||
```
|
||||
messages 表: status 列
|
||||
active — 正常显示
|
||||
truncated — 被编辑截断(前端不展示,后端不进 context)
|
||||
保留历史可追溯,与 WF-A soft_delete 模式一致
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、三层防御架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ L1: 文件级 Mutex │
|
||||
│ 防时机冲突:同文件读写自动串行化 │
|
||||
│ 实现: HashMap<PathBuf, Mutex<()>> (~15行) │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────┐ │
|
||||
│ │ L2: old_text 精确匹配 │ │
|
||||
│ │ 防内容错配:= 乐观锁(CAS) │ │
|
||||
│ │ 内容变了 → 匹配不上 → 报错 │ │
|
||||
│ │ 实现: 内容扫描 (~20行) │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────────────────────┐ │ │
|
||||
│ │ │ L3: expected_hash 指纹校验 │ │ │
|
||||
│ │ │ 防版本漂移: 外部修改检测 │ │ │
|
||||
│ │ │ 实现: metadata() (~10行) │ │ │
|
||||
│ │ └─────────────────────────────────┘ │ │
|
||||
│ └───────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 底层兜底: FR-S7 .bak 备份(误操作可恢复) │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
各层职责独立、互补:
|
||||
|
||||
| 层 | 管 | 防什么 | 失效后果 |
|
||||
|----|-----|--------|---------|
|
||||
| L1 Mutex | 时机 | 两操作同时碰同一文件 | 数据丢失/半写 |
|
||||
| L2 old_text | 内容 | 操作基于过时内容 | 错改别处 |
|
||||
| L3 hash | 版本 | read→patch之间文件被外部改 | 基于错误版本操作 |
|
||||
| .bak | 恢复 | 以上全失效时的最后防线 | 可回滚 |
|
||||
|
||||
---
|
||||
|
||||
## 五、查找策略(line + old_text 组合)
|
||||
|
||||
```
|
||||
有 line 参数?
|
||||
├─ YES → 跳到该行,检查周围是否包含 old_text
|
||||
│ ├─ 匹配 → 替换(快速路径 ✅)
|
||||
│ └─ 不匹配(行已偏移) → 降级: 全文扫描 old_text
|
||||
└─ NO → 全文扫描 old_text
|
||||
|
||||
全文扫描结果:
|
||||
├─ 0 处匹配 → Err("未找到目标文本,文件可能已被修改")
|
||||
├─ 1 处匹配 → 替换 ✅
|
||||
└─ N 处匹配(N>1) → 替换第 1 处 + warning("匹配到 N 处...")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、边界情况处理
|
||||
|
||||
| 边界情况 | 行为 | 理由 |
|
||||
|---------|------|------|
|
||||
| 文件不存在 | Err("文件不存在") | 安全第一 |
|
||||
| 文件 >1MB | warn + 继续或拒绝(复用 FR-S2 上限) | 防性能问题 |
|
||||
| 二进制文件(含 \0) | Err("不支持二进制文件") | 文本操作不适用 |
|
||||
| old_text 为空串 | Err("old_text 不能为空") | 防全文件匹配 |
|
||||
| new_text == old_text | success + warning("无实际更改") | 不浪费 I/O |
|
||||
| patches 为空 | Err("patches 不能为空") | 无意义调用 |
|
||||
| 单次 patch 文件膨胀 >900% | warn(复用 FR-S7 逻辑) | 异常检测 |
|
||||
| 目标路径是 .bak/.tmp | is_noise_file 过滤拒绝(CR-03) | 不改临时文件 |
|
||||
| 路径含 ".." | validate_path 黑名单拦截 | 路径遍历防护 |
|
||||
| RiskLevel | **Medium**(修改已有文件) | 比 write_file 同级(都是改文件) |
|
||||
|
||||
---
|
||||
|
||||
## 七、性能分析
|
||||
|
||||
| 操作 | 开销 | 对比基准 |
|
||||
|------|------|---------|
|
||||
| Mutex 获取/释放 | ~100ns(非竞争)/ μs 级(等待) | 文件 I/O 是 ms 级,可忽略 |
|
||||
| 全文扫描 old_text | O(n), n=行数(<1MB) | <1ms |
|
||||
| hash 计算 | metadata() 一次系统调用 | ~μs 级 |
|
||||
| .bak 备份 | 文件大小一次 copy | SSD ~100MB/s |
|
||||
| **总开销** | | **<5ms(<< LLM 秒级延迟)** |
|
||||
|
||||
---
|
||||
|
||||
## 八、与现有架构兼容性
|
||||
|
||||
| 维度 | 兼容性 |
|
||||
|------|--------|
|
||||
| tool_registry 注册 | ✅ 完全复用 write_file 模式 |
|
||||
| RiskLevel 分流 | ✅ Medium → 审批(白名单收紧:纯读取外全审) |
|
||||
| audit 审计日志 | ✅ process_tool_calls 自动入库 |
|
||||
| validate_path | ✅ 复用黑名单 + canonicalize |
|
||||
| .bak 备份 | ✅ 复用 FR-S7 已有逻辑 |
|
||||
| ToolCard 前端渲染 | ✅ args 键值对自动适配 |
|
||||
| 数据变更联动 AR-11 | ✅ emit df-data-changed 触发刷新 |
|
||||
| 会话级授权 AE-04 | ✅ write_file 同类操作,可纳入 session_trust |
|
||||
|
||||
---
|
||||
|
||||
## 九、实施步骤
|
||||
|
||||
### 第一批(核心三件套,~50 行后端 + 前端零改动)
|
||||
|
||||
1. **`mod.rs` 或 `tool_registry.rs` 顶层**:加 `FILE_LOCKS` 静态 Mutex(~10 行)
|
||||
2. **`tool_registry.rs`**:注册 `patch_file` handler(~40 行)
|
||||
- 参数解析 + validate_path
|
||||
- expected_hash 校验(可选,第一批可先加框架)
|
||||
- 逐 patch 执行:扫描 old_text → 替换 → 记录结果
|
||||
- .bak 备份(复用 write_file 逻辑)
|
||||
- 截断输出(stdout/stderr 各 10KB)
|
||||
3. **测试**:AI 调用 `patch_file` 改一个已知文件,验证替换正确性
|
||||
|
||||
### 第二批(增强层,按需)
|
||||
|
||||
4. before/after_text 锁定逻辑(~15 行)
|
||||
5. expected_hash 指纹校验完整接入(~10 行)
|
||||
6. read_file 返回值扩展 file_hash 字段(~5 行)
|
||||
|
||||
### 第三批(远期)
|
||||
|
||||
7. replace_all 开关
|
||||
8. regex 支持(old_regex 字段)
|
||||
9. undo stack(会话内 patch 历史)
|
||||
|
||||
---
|
||||
|
||||
## 十、替代方案否决记录
|
||||
|
||||
| 方案 | 否决理由 |
|
||||
|------|---------|
|
||||
| sed/awk via run_command | B-37 stdout 空;修好后也是间接操作,无原子性/备份/审计 |
|
||||
| write_file 全量覆盖 | 已出事故(762→248字节);大文件 token 浪费;无 diff |
|
||||
| git apply (Git-based patch) | 强依赖 git 仓库;非 git 目录不可用 |
|
||||
| JSON Patch (RFC 6902) | 面向 JSON/结构化数据;不适合自由格式文本 |
|
||||
| AST-level (tree-sitter) | 过度工程;需每语言 parser;AI 代码未必能 parse |
|
||||
| 纯 line 号编辑 | 无内容校验=并发不安全;行号漂移易出错 |
|
||||
| 全局文件队列 | 单用户桌面不需要;Mutex 够用且简单 10x |
|
||||
Reference in New Issue
Block a user