巡检发现:
- 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行已建,旧副本待清理但非阻塞)
14 KiB
14 KiB
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 全量覆盖
问题链:
- Token 浪费:大文件(几百行)只改 3 行却要重发全部内容
- 事故风险:write_file 全量覆盖已出事故(PROGRESS.md 762 行 → 248 字节,FR-S7 记录)
- 无审计粒度:无法知道「改了哪里」,只有「整个文件被替换了」
- 并发不安全:join_all 并行场景下多工具操作同一文件无保护
1.2 目标
提供 patch_file 工具,让 AI 能做精确的局部文本替换,形成完整的文件操作闭环:
read_file(读) → search_in_file(定位) → patch_file(改) → run_command(验证)
二、API 设计
2.1 请求结构
/// 局部文件更新请求
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 响应结构
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 返回值新增字段:
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 同一文件
实现
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 行后端 + 前端零改动)
mod.rs或tool_registry.rs顶层:加FILE_LOCKS静态 Mutex(~10 行)tool_registry.rs:注册patch_filehandler(~40 行)- 参数解析 + validate_path
- expected_hash 校验(可选,第一批可先加框架)
- 逐 patch 执行:扫描 old_text → 替换 → 记录结果
- .bak 备份(复用 write_file 逻辑)
- 截断输出(stdout/stderr 各 10KB)
- 测试:AI 调用
patch_file改一个已知文件,验证替换正确性
第二批(增强层,按需)
- before/after_text 锁定逻辑(~15 行)
- expected_hash 指纹校验完整接入(~10 行)
- read_file 返回值扩展 file_hash 字段(~5 行)
第三批(远期)
- replace_all 开关
- regex 支持(old_regex 字段)
- 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 |