经代码核验发现 9 份设计文档的状态标注严重滞后(标'待实施'但 实际已完整落地),本次批量同步: 已落地(核验确认): - 局部编辑工具:三层防御+三模式完整,仅文本不支持二进制 - 密钥迁移健壮性:空 key 不覆盖+即时迁移补密钥+阻断保存 - AST 符号解析:符号读取工具已注册+基线测试守护 - 查询能力补全:任务/项目/灵感均多维动态查询 - 条件表达式引擎:手写求值器+JSON Path+执行器集成+前端入口 - 工作流脚本边界:命令白名单/黑名单+危险关键词告警 - 消息拆分存储:消息表+全量迁移+读写全部切换 - 消息级溯源:消息 ID+四场景溯源+切读全部完成 部分落地: - 全局事件总线:基建+20 余个发射点就位,消费者未接(空转) 归档不实施: - 规格契约自检:核心价值已被求助协议+自审闸门覆盖,过度设计
361 lines
14 KiB
Markdown
361 lines
14 KiB
Markdown
# Patch File 工具设计
|
||
|
||
> 创建: 2026-06-15 | 状态: ✅ 已落地(2026-06-28 核验) | 优先级: 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 |
|