# 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 |
