# 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, /// 有序补丁列表(从文件末尾往前执行,避免行号偏移) patches: Vec, } /// 单个补丁 struct Patch { /// 必填:要替换的旧文本(精确匹配 = 乐观锁) old_text: String, /// 必填:替换后的新文本 new_text: String, /// 可选:行号辅助定位(快速跳转 + 去歧增强) /// 有值时优先跳到该行检查 old_text;不匹配则降级全文扫描 line: Option, /// 可选:old_text 之前的上下文锚(去歧——多匹配时精确锁定) before_text: Option, /// 可选:old_text 之后的上下文锚(去歧) after_text: Option, } ``` ### 2.2 响应结构 ```rust struct PatchResult { success: bool, patches_applied: usize, // 成功替换的 patch 数 total_matches: usize, // 每个 patch 的总命中数(含未替换的) lines_changed: i32, // 总行数变化(正=增加 负=减少) warnings: Vec, // ["匹配到 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>> = 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> (~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 |